Connect product/blog pages, mega menu, and instant search to Meshkee backend
Add /products (list + detail) and /blog (list + detail) routes backed by the real Meshkee API, a real category-hierarchy mega menu (level 2/3 only), debounced instant search proxied through a same-origin API route, and tel:-link fallbacks on product cards when no price is set. Also fixes Product price normalization to read the real API shape instead of a guessed one, and hardens product linking against catalog sources that don't expose a slug. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1f81236d0a
commit
2b5f4e437b
Generated
+48
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1605,6 +1606,19 @@
|
|||||||
"tailwindcss": "4.3.3"
|
"tailwindcss": "4.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/typography": {
|
||||||
|
"version": "0.5.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
|
||||||
|
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
@@ -2801,6 +2815,19 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cssesc": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"cssesc": "bin/cssesc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -5557,6 +5584,20 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6639,6 +6680,13 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Domain: sanihome.ir
|
||||||
|
Documentation for backend is:https://api.meshkee.com/docs/website
|
||||||
|
|
||||||
|
|
||||||
|
Login or user icon should go to customer.sanihome.ir.com
|
||||||
|
|
||||||
|
Create product page and details >> connect to store items
|
||||||
|
Create blog page and details >> connect to blog
|
||||||
|
|
||||||
|
Product Menu should be a mega menu with level 2 and 3 of categories - skip level 1
|
||||||
|
|
||||||
|
Search input should be instant search - activate when type more than 2 char
|
||||||
|
|
||||||
|
First carousel >> special-sale
|
||||||
|
Second carousel >> top-selling
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getProducts } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||||
|
if (query.length <= 2) {
|
||||||
|
return NextResponse.json({ items: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = await getProducts({ name: query, pageSize: 6 });
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Placeholder } from "@/components/ui/Placeholder";
|
||||||
|
import { getBlog } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
function formatDate(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await getBlog(slug);
|
||||||
|
|
||||||
|
if (!post) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<div className="mb-6 flex items-center gap-2 text-xs text-muted">
|
||||||
|
{post.categoryName && <span>{post.categoryName}</span>}
|
||||||
|
{post.publishedAt && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(post.publishedAt)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{post.author && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{post.author}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-6 text-2xl font-bold leading-relaxed text-red-dark md:text-3xl">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{post.image && (
|
||||||
|
<Placeholder
|
||||||
|
src={post.image}
|
||||||
|
alt={post.title}
|
||||||
|
width="100%"
|
||||||
|
height="360px"
|
||||||
|
className="mb-8 w-full rounded-2xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="prose max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-10 border-t border-border pt-6">
|
||||||
|
<a href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
|
||||||
|
→ بازگشت به وبلاگ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Placeholder } from "@/components/ui/Placeholder";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { getBlogs } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
function formatDate(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogListPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||||
|
const { items, total } = await getBlogs({ page, pageSize: PAGE_SIZE });
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold text-red-dark">وبلاگ</h1>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState message="هنوز مطلبی ثبت نشده است." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((post) => (
|
||||||
|
<a
|
||||||
|
key={post.id}
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
className="group overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Placeholder
|
||||||
|
src={post.image ?? "/images/placeholder.svg"}
|
||||||
|
alt={post.title}
|
||||||
|
width="100%"
|
||||||
|
height="180px"
|
||||||
|
className="w-full rounded-none border-0"
|
||||||
|
/>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
|
||||||
|
{post.categoryName && <span>{post.categoryName}</span>}
|
||||||
|
{post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
{post.abstract && (
|
||||||
|
<p className="line-clamp-2 text-xs leading-relaxed text-muted">
|
||||||
|
{post.abstract}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-3">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a
|
||||||
|
href={`/blog?page=${page - 1}`}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||||
|
قبلی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
صفحه {page} از {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a
|
||||||
|
href={`/blog?page=${page + 1}`}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||||
|
بعدی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #fafafa;
|
--background: #fafafa;
|
||||||
|
|||||||
+17
-2
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Vazirmatn, Syne } from "next/font/google";
|
import { Vazirmatn, Syne } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { TopBar } from "@/components/home/TopBar";
|
||||||
|
import { Header } from "@/components/home/Header";
|
||||||
|
import { Footer } from "@/components/home/Footer";
|
||||||
|
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
||||||
|
|
||||||
const vazirmatn = Vazirmatn({
|
const vazirmatn = Vazirmatn({
|
||||||
variable: "--font-vazirmatn",
|
variable: "--font-vazirmatn",
|
||||||
@@ -19,14 +23,25 @@ export const metadata: Metadata = {
|
|||||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const [categoryTree, categories, businessInfo] = await Promise.all([
|
||||||
|
getCategoryTree(),
|
||||||
|
getCategories(),
|
||||||
|
getBusinessInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="fa"
|
lang="fa"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className={`${vazirmatn.variable} ${syne.variable} h-full antialiased`}
|
className={`${vazirmatn.variable} ${syne.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<Header categoryTree={categoryTree} categories={categories} />
|
||||||
|
<main>{children}</main>
|
||||||
|
<Footer businessInfo={businessInfo} categories={categories} />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-42
@@ -1,5 +1,3 @@
|
|||||||
import { TopBar } from "@/components/home/TopBar";
|
|
||||||
import { Header } from "@/components/home/Header";
|
|
||||||
import { FeatureBar } from "@/components/home/FeatureBar";
|
import { FeatureBar } from "@/components/home/FeatureBar";
|
||||||
import { HeroSlider } from "@/components/home/HeroSlider";
|
import { HeroSlider } from "@/components/home/HeroSlider";
|
||||||
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
||||||
@@ -10,11 +8,9 @@ import { ThreeBanners } from "@/components/home/ThreeBanners";
|
|||||||
import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
|
import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
|
||||||
import { AboutSanihome } from "@/components/home/AboutSanihome";
|
import { AboutSanihome } from "@/components/home/AboutSanihome";
|
||||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||||
import { Footer } from "@/components/home/Footer";
|
|
||||||
import {
|
import {
|
||||||
getBusinessInfo,
|
getBusinessInfo,
|
||||||
getCategories,
|
getCategories,
|
||||||
getCategoryGroups,
|
|
||||||
getProducts,
|
getProducts,
|
||||||
getBrandGroups,
|
getBrandGroups,
|
||||||
getStoreSpecials,
|
getStoreSpecials,
|
||||||
@@ -24,24 +20,17 @@ import {
|
|||||||
} from "@/lib/meshkee";
|
} from "@/lib/meshkee";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const [
|
const [categories, storeSpecials, productsResult, brandGroups, staticImageSlots, businessInfo] =
|
||||||
categoryGroups,
|
await Promise.all([
|
||||||
categories,
|
getCategories(),
|
||||||
storeSpecials,
|
getStoreSpecials(),
|
||||||
productsResult,
|
getProducts({ pageSize: 12 }),
|
||||||
brandGroups,
|
getBrandGroups(),
|
||||||
businessInfo,
|
getStaticImageSlots(),
|
||||||
staticImageSlots,
|
getBusinessInfo(),
|
||||||
] = await Promise.all([
|
]);
|
||||||
getCategoryGroups(),
|
|
||||||
getCategories(),
|
|
||||||
getStoreSpecials(),
|
|
||||||
getProducts({ pageSize: 12 }),
|
|
||||||
getBrandGroups(),
|
|
||||||
getBusinessInfo(),
|
|
||||||
getStaticImageSlots(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||||
const specialSaleProducts = storeSpecials[0]?.items ?? [];
|
const specialSaleProducts = storeSpecials[0]?.items ?? [];
|
||||||
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
|
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
|
||||||
const threeBannerSlot = staticImageSlots.find(
|
const threeBannerSlot = staticImageSlots.find(
|
||||||
@@ -51,27 +40,22 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar />
|
<HeroSlider images={heroImages} />
|
||||||
<Header categoryGroups={categoryGroups} categories={categories} />
|
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
|
||||||
<main>
|
<FeatureBar />
|
||||||
<HeroSlider images={heroImages} />
|
<FeaturedCategories categories={categories} />
|
||||||
<SpecialSaleCarousel products={specialSaleProducts} />
|
<TwoBanners
|
||||||
<FeatureBar />
|
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
||||||
<FeaturedCategories categories={categories} />
|
aspectRatio={twoBannerSlot?.aspectRatio}
|
||||||
<TwoBanners
|
/>
|
||||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} />
|
||||||
aspectRatio={twoBannerSlot?.aspectRatio}
|
<ThreeBanners
|
||||||
/>
|
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||||
<TopSellingCarousel products={productsResult.items} />
|
aspectRatio={threeBannerSlot?.aspectRatio}
|
||||||
<ThreeBanners
|
/>
|
||||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
|
||||||
aspectRatio={threeBannerSlot?.aspectRatio}
|
<AboutSanihome />
|
||||||
/>
|
<BlogCarousel />
|
||||||
<BrandGroupCarousels brandGroups={brandGroups} />
|
|
||||||
<AboutSanihome />
|
|
||||||
<BlogCarousel />
|
|
||||||
</main>
|
|
||||||
<Footer businessInfo={businessInfo} categories={categories} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Placeholder } from "@/components/ui/Placeholder";
|
||||||
|
import { formatPrice } from "@/data/sample";
|
||||||
|
import { getBusinessInfo, getProduct, getProductTechnicalInfo } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const [product, technicalInfo, businessInfo] = await Promise.all([
|
||||||
|
getProduct(slug),
|
||||||
|
getProductTechnicalInfo(slug),
|
||||||
|
getBusinessInfo(),
|
||||||
|
]);
|
||||||
|
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||||
|
|
||||||
|
if (!product) notFound();
|
||||||
|
|
||||||
|
const images = product.images.length > 0 ? product.images : product.image ? [product.image] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||||
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Placeholder
|
||||||
|
src={images[0] ?? "/images/placeholder.svg"}
|
||||||
|
alt={product.name}
|
||||||
|
width="100%"
|
||||||
|
className="aspect-square w-full rounded-2xl"
|
||||||
|
/>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{images.slice(1, 5).map((img) => (
|
||||||
|
<Placeholder
|
||||||
|
key={img}
|
||||||
|
src={img}
|
||||||
|
alt={product.name}
|
||||||
|
width="100%"
|
||||||
|
className="aspect-square rounded-xl"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{product.brand && (
|
||||||
|
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
|
||||||
|
{product.brand}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1 className="mb-3 text-xl font-bold leading-relaxed md:text-2xl">{product.name}</h1>
|
||||||
|
{product.categoryName && (
|
||||||
|
<a
|
||||||
|
href={`/products?categoryId=${product.categoryId}`}
|
||||||
|
className="text-sm text-red hover:text-red-dark"
|
||||||
|
>
|
||||||
|
{product.categoryName}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="my-5 flex flex-wrap items-center gap-3">
|
||||||
|
{product.minPrice != null ? (
|
||||||
|
<span className="text-2xl font-bold text-red-dark">
|
||||||
|
{product.minPrice === product.maxPrice
|
||||||
|
? formatPrice(product.minPrice)
|
||||||
|
: `${formatPrice(product.minPrice)} - ${formatPrice(product.maxPrice ?? product.minPrice)}`}
|
||||||
|
<span className="mr-1 text-sm font-normal text-muted">تومان</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg text-muted">تماس با فروشگاه</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
product.inStock ? "bg-green-50 text-green-700" : "bg-red-light text-red-dark"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.inStock ? "موجود" : "ناموجود"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.minPrice != null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mb-6 w-full rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark md:w-auto md:px-10"
|
||||||
|
>
|
||||||
|
افزودن به سبد خرید
|
||||||
|
</button>
|
||||||
|
) : contactPhone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${contactPhone}`}
|
||||||
|
className="mb-6 flex w-full items-center justify-center gap-2 rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark md:w-auto md:px-10"
|
||||||
|
>
|
||||||
|
<span aria-hidden>📞</span>
|
||||||
|
تماس با فروشگاه
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="mb-6 w-full cursor-not-allowed rounded-xl bg-red/60 py-3 text-sm font-medium text-white md:w-auto md:px-10"
|
||||||
|
>
|
||||||
|
تماس با فروشگاه
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product.descriptionHtml && (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none border-t border-border pt-5"
|
||||||
|
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{technicalInfo.length > 0 && (
|
||||||
|
<div className="mt-10 border-t border-border pt-8">
|
||||||
|
<h2 className="mb-4 text-lg font-bold text-red-dark">مشخصات فنی</h2>
|
||||||
|
<table className="w-full overflow-hidden rounded-xl border border-border text-sm">
|
||||||
|
<tbody>
|
||||||
|
{technicalInfo.map((field) => (
|
||||||
|
<tr key={field.label} className="border-b border-border last:border-0 odd:bg-background">
|
||||||
|
<th className="w-1/3 p-3 text-right font-medium text-muted">{field.label}</th>
|
||||||
|
<td className="p-3">{field.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { getBusinessInfo, getProducts, productHref } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
export default async function ProductsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||||
|
const [{ items, total }, businessInfo] = await Promise.all([
|
||||||
|
getProducts({
|
||||||
|
categoryId: params.categoryId,
|
||||||
|
name: params.q,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
getBusinessInfo(),
|
||||||
|
]);
|
||||||
|
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const pageHref = (p: number) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.categoryId) sp.set("categoryId", params.categoryId);
|
||||||
|
if (params.q) sp.set("q", params.q);
|
||||||
|
sp.set("page", String(p));
|
||||||
|
return `/products?${sp.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||||
|
{params.q ? `نتایج جستجو برای «${params.q}»` : "محصولات"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState message="محصولی یافت نشد." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{items.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
name={product.name}
|
||||||
|
brand={product.brand ?? ""}
|
||||||
|
price={product.price}
|
||||||
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
|
href={productHref(product)}
|
||||||
|
fluid
|
||||||
|
contactPhone={contactPhone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-3">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a
|
||||||
|
href={pageHref(page - 1)}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||||
|
قبلی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
صفحه {page} از {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a
|
||||||
|
href={pageHref(page + 1)}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||||
|
بعدی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@ import { Carousel } from "@/components/ui/Carousel";
|
|||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import type { ProductGroup } from "@/lib/meshkee";
|
import { productHref, type ProductGroup } from "@/lib/meshkee";
|
||||||
|
|
||||||
export function BrandGroupCarousels({ brandGroups }: { brandGroups: ProductGroup[] }) {
|
export function BrandGroupCarousels({
|
||||||
|
brandGroups,
|
||||||
|
contactPhone,
|
||||||
|
}: {
|
||||||
|
brandGroups: ProductGroup[];
|
||||||
|
contactPhone?: string;
|
||||||
|
}) {
|
||||||
if (brandGroups.length === 0) {
|
if (brandGroups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 md:py-14">
|
<section className="py-10 md:py-14">
|
||||||
@@ -31,6 +37,8 @@ export function BrandGroupCarousels({ brandGroups }: { brandGroups: ProductGroup
|
|||||||
price={product.price}
|
price={product.price}
|
||||||
image={product.image ?? "/images/placeholder.svg"}
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
variant="brand"
|
variant="brand"
|
||||||
|
href={productHref(product)}
|
||||||
|
contactPhone={contactPhone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { Category, CategoryGroup } from "@/lib/meshkee";
|
import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: "فروش ویژه", href: "#" },
|
{ label: "فروش ویژه", href: "#" },
|
||||||
@@ -12,6 +13,8 @@ const navLinks = [
|
|||||||
{ label: "تماس با ما", href: "#" },
|
{ label: "تماس با ما", href: "#" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LOGIN_URL = "https://customer.sanihome.ir";
|
||||||
|
|
||||||
function Logo({ large = false }: { large?: boolean }) {
|
function Logo({ large = false }: { large?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<a href="/" className="flex shrink-0 items-center">
|
<a href="/" className="flex shrink-0 items-center">
|
||||||
@@ -43,35 +46,22 @@ function ChevronDownIcon({ className = "" }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchBar() {
|
function flattenLevel2(categoryTree: CategoryTreeGroup[]): CategoryNode[] {
|
||||||
return (
|
return categoryTree.flatMap((group) => group.items);
|
||||||
<div className="flex overflow-hidden rounded-xl border border-border bg-background">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="جستجوی محصول، برند یا دستهبندی..."
|
|
||||||
className="flex-1 bg-transparent px-4 py-2.5 text-sm outline-none placeholder:text-muted"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bg-red px-5 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
|
||||||
>
|
|
||||||
جستجو
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileDrawer({
|
function MobileDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
categoryGroups,
|
categoryTree,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
categoryGroups: CategoryGroup[];
|
categoryTree: CategoryTreeGroup[];
|
||||||
}) {
|
}) {
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const level2 = flattenLevel2(categoryTree);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -144,32 +134,37 @@ function MobileDrawer({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
{categoryGroups.length === 0 ? (
|
{level2.length === 0 ? (
|
||||||
<div className="bg-background px-5 pb-4">
|
<div className="bg-background px-5 pb-4">
|
||||||
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 bg-background px-5 pb-4">
|
<ul className="space-y-1 bg-background px-5 pb-4">
|
||||||
{categoryGroups.map((group) => (
|
{level2.map((node) => (
|
||||||
<div key={group.id}>
|
<li key={node.id}>
|
||||||
<p className="mb-1.5 pt-3 text-xs font-bold text-red-dark">
|
<a
|
||||||
{group.title}
|
href={`/products?categoryId=${node.id}`}
|
||||||
</p>
|
className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red"
|
||||||
<ul className="space-y-1">
|
>
|
||||||
{group.items.map((item) => (
|
{node.nameFa}
|
||||||
<li key={item.name}>
|
</a>
|
||||||
<a
|
{node.children.length > 0 && (
|
||||||
href="#"
|
<ul className="mr-3 space-y-1 border-r border-border pr-3">
|
||||||
className="block rounded-lg px-3 py-1.5 text-muted transition-colors hover:bg-red-light hover:text-red"
|
{node.children.map((child) => (
|
||||||
>
|
<li key={child.id}>
|
||||||
{item.name}
|
<a
|
||||||
</a>
|
href={`/products?categoryId=${child.id}`}
|
||||||
</li>
|
className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||||
))}
|
>
|
||||||
</ul>
|
{child.nameFa}
|
||||||
</div>
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,13 +180,13 @@ function MobileDrawer({
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={LOGIN_URL}
|
||||||
className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
|
className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
|
||||||
>
|
>
|
||||||
<span aria-hidden>👤</span>
|
<span aria-hidden>👤</span>
|
||||||
ورود
|
ورود
|
||||||
</button>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -200,15 +195,16 @@ function MobileDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
categoryGroups,
|
categoryTree,
|
||||||
categories,
|
categories,
|
||||||
}: {
|
}: {
|
||||||
categoryGroups: CategoryGroup[];
|
categoryTree: CategoryTreeGroup[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
}) {
|
}) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const level2 = flattenLevel2(categoryTree);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClickOutside = (event: MouseEvent) => {
|
const onClickOutside = (event: MouseEvent) => {
|
||||||
@@ -248,13 +244,13 @@ export function Header({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 text-sm">
|
<div className="flex items-center justify-end gap-3 text-sm">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={LOGIN_URL}
|
||||||
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
|
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
|
||||||
>
|
>
|
||||||
<span>👤</span>
|
<span>👤</span>
|
||||||
<span>ورود</span>
|
<span>ورود</span>
|
||||||
</button>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 transition-colors hover:border-red hover:text-red"
|
className="relative flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 transition-colors hover:border-red hover:text-red"
|
||||||
@@ -291,7 +287,7 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute top-full right-0 z-50 mt-0 w-[min(92vw,760px)] animate-fade-up rounded-b-2xl border border-t-0 border-border bg-surface p-5 shadow-xl">
|
<div className="absolute top-full right-0 z-50 mt-0 max-h-[70vh] w-[min(92vw,900px)] animate-fade-up overflow-y-auto rounded-b-2xl border border-t-0 border-border bg-surface p-5 shadow-xl">
|
||||||
<div className="mb-4 flex items-end justify-between gap-3 border-b border-border pb-3">
|
<div className="mb-4 flex items-end justify-between gap-3 border-b border-border pb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-red-dark">همه دستهبندیها</p>
|
<p className="font-bold text-red-dark">همه دستهبندیها</p>
|
||||||
@@ -299,49 +295,57 @@ export function Header({
|
|||||||
Shop by Category
|
Shop by Category
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" className="text-xs font-medium text-red hover:text-red-dark">
|
<a
|
||||||
|
href="/products"
|
||||||
|
className="text-xs font-medium text-red hover:text-red-dark"
|
||||||
|
>
|
||||||
مشاهده همه ←
|
مشاهده همه ←
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categoryGroups.length === 0 ? (
|
{level2.length === 0 ? (
|
||||||
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="columns-1 gap-6 sm:columns-2 lg:columns-4">
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
{level2.map((node) => (
|
||||||
{categoryGroups.map((column) => (
|
<div key={node.id} className="mb-4 break-inside-avoid">
|
||||||
<div key={column.id}>
|
<a
|
||||||
<p className="mb-3 text-sm font-bold text-red-dark">{column.title}</p>
|
href={`/products?categoryId=${node.id}`}
|
||||||
<ul className="space-y-2">
|
className="text-sm font-bold text-red-dark transition-colors hover:text-red"
|
||||||
{column.items.map((item) => (
|
>
|
||||||
<li key={item.name}>
|
{node.nameFa}
|
||||||
|
</a>
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<li key={child.id}>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href={`/products?categoryId=${child.id}`}
|
||||||
className="text-sm text-muted transition-colors hover:text-red"
|
className="text-sm text-muted transition-colors hover:text-red"
|
||||||
>
|
>
|
||||||
{item.name}
|
{child.nameFa}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4">
|
|
||||||
{categories.slice(0, 4).map((cat) => (
|
|
||||||
<a
|
|
||||||
key={cat.id}
|
|
||||||
href="#"
|
|
||||||
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light"
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4">
|
||||||
|
{categories.slice(0, 4).map((cat) => (
|
||||||
|
<a
|
||||||
|
key={cat.id}
|
||||||
|
href={`/products?categoryId=${cat.id}`}
|
||||||
|
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -362,7 +366,7 @@ export function Header({
|
|||||||
<MobileDrawer
|
<MobileDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
categoryGroups={categoryGroups}
|
categoryTree={categoryTree}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Placeholder } from "@/components/ui/Placeholder";
|
||||||
|
import { formatPrice } from "@/data/sample";
|
||||||
|
import type { Product } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Product[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length <= 2) {
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
|
const data = (await res.json()) as { items: Product[] };
|
||||||
|
setResults(data.items);
|
||||||
|
setOpen(true);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed) window.location.href = `/products?q=${encodeURIComponent(trimmed)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative flex overflow-hidden rounded-xl border border-border bg-background">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onFocus={() => results.length > 0 && setOpen(true)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
|
placeholder="جستجوی محصول، برند یا دستهبندی..."
|
||||||
|
className="flex-1 bg-transparent px-4 py-2.5 text-sm outline-none placeholder:text-muted"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
className="bg-red px-5 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||||
|
>
|
||||||
|
جستجو
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 left-0 z-50 mt-2 max-h-[70vh] overflow-y-auto rounded-xl border border-border bg-surface shadow-xl">
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-4 text-center text-sm text-muted">در حال جستجو...</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="p-4 text-center text-sm text-muted">نتیجهای یافت نشد.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{results.map((product) => (
|
||||||
|
<li key={product.id}>
|
||||||
|
<a
|
||||||
|
href={`/products/${product.slug}`}
|
||||||
|
className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40"
|
||||||
|
>
|
||||||
|
<Placeholder
|
||||||
|
src={product.image ?? "/images/placeholder.svg"}
|
||||||
|
alt={product.name}
|
||||||
|
width="48px"
|
||||||
|
height="48px"
|
||||||
|
className="shrink-0 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{product.name}</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{product.price != null ? `${formatPrice(product.price)} تومان` : "قیمت نامشخص"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Placeholder } from "@/components/ui/Placeholder";
|
|||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import type { Product } from "@/lib/meshkee";
|
import { productHref, type Product } from "@/lib/meshkee";
|
||||||
|
|
||||||
function SpecialSaleBanner() {
|
function SpecialSaleBanner() {
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +21,13 @@ function SpecialSaleBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpecialSaleCarousel({ products }: { products: Product[] }) {
|
export function SpecialSaleCarousel({
|
||||||
|
products,
|
||||||
|
contactPhone,
|
||||||
|
}: {
|
||||||
|
products: Product[];
|
||||||
|
contactPhone?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
|
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
@@ -45,6 +51,8 @@ export function SpecialSaleCarousel({ products }: { products: Product[] }) {
|
|||||||
discount={product.discount}
|
discount={product.discount}
|
||||||
image={product.image ?? "/images/placeholder.svg"}
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
variant="sale"
|
variant="sale"
|
||||||
|
href={productHref(product)}
|
||||||
|
contactPhone={contactPhone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { Carousel } from "@/components/ui/Carousel";
|
|||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import type { Product } from "@/lib/meshkee";
|
import { productHref, type Product } from "@/lib/meshkee";
|
||||||
|
|
||||||
export function TopSellingCarousel({ products }: { products: Product[] }) {
|
export function TopSellingCarousel({
|
||||||
|
products,
|
||||||
|
contactPhone,
|
||||||
|
}: {
|
||||||
|
products: Product[];
|
||||||
|
contactPhone?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 md:py-14">
|
<section className="py-10 md:py-14">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
@@ -24,6 +30,8 @@ export function TopSellingCarousel({ products }: { products: Product[] }) {
|
|||||||
brand={product.brand ?? ""}
|
brand={product.brand ?? ""}
|
||||||
price={product.price}
|
price={product.price}
|
||||||
image={product.image ?? "/images/placeholder.svg"}
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
|
href={productHref(product)}
|
||||||
|
contactPhone={contactPhone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type PlaceholderProps = {
|
|||||||
height?: string;
|
height?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
fit?: "cover" | "contain";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Placeholder({
|
export function Placeholder({
|
||||||
@@ -14,6 +15,7 @@ export function Placeholder({
|
|||||||
height,
|
height,
|
||||||
label,
|
label,
|
||||||
className = "",
|
className = "",
|
||||||
|
fit = "cover",
|
||||||
}: PlaceholderProps) {
|
}: PlaceholderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -23,7 +25,7 @@ export function Placeholder({
|
|||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className={`absolute inset-0 h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
|
||||||
/>
|
/>
|
||||||
{label && (
|
{label && (
|
||||||
<span className="font-display pointer-events-none relative z-10 rounded bg-white/70 px-1.5 py-0.5 text-xs tracking-wider text-red-dark/50 uppercase">
|
<span className="font-display pointer-events-none relative z-10 rounded bg-white/70 px-1.5 py-0.5 text-xs tracking-wider text-red-dark/50 uppercase">
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { formatPrice } from "@/data/sample";
|
|||||||
type ProductCardProps = {
|
type ProductCardProps = {
|
||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
price: number;
|
price: number | null;
|
||||||
oldPrice?: number;
|
oldPrice?: number | null;
|
||||||
discount?: number;
|
discount?: number;
|
||||||
image: string;
|
image: string;
|
||||||
variant?: "sale" | "default" | "brand";
|
variant?: "sale" | "default" | "brand";
|
||||||
|
href?: string;
|
||||||
|
fluid?: boolean;
|
||||||
|
contactPhone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
@@ -19,17 +22,19 @@ export function ProductCard({
|
|||||||
discount,
|
discount,
|
||||||
image,
|
image,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
href,
|
||||||
|
fluid = false,
|
||||||
|
contactPhone,
|
||||||
}: ProductCardProps) {
|
}: ProductCardProps) {
|
||||||
return (
|
const info = (
|
||||||
<article className="group w-[220px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[240px]">
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Placeholder
|
<Placeholder
|
||||||
src={image}
|
src={image}
|
||||||
alt={name}
|
alt={name}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="200px"
|
fit="contain"
|
||||||
label="240×200"
|
className="aspect-square w-full rounded-none border-0 border-b border-dashed"
|
||||||
className="w-full rounded-none border-0 border-b border-dashed"
|
|
||||||
/>
|
/>
|
||||||
{discount && (
|
{discount && (
|
||||||
<span className="absolute top-3 right-3 rounded-md bg-red px-2 py-0.5 text-xs font-bold text-white">
|
<span className="absolute top-3 right-3 rounded-md bg-red px-2 py-0.5 text-xs font-bold text-white">
|
||||||
@@ -42,7 +47,7 @@ export function ProductCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4 pb-0">
|
||||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
||||||
{brand}
|
{brand}
|
||||||
</p>
|
</p>
|
||||||
@@ -51,8 +56,14 @@ export function ProductCard({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-base font-bold text-red-dark">
|
<span className="text-base font-bold text-red-dark">
|
||||||
{formatPrice(price)}
|
{price != null ? (
|
||||||
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span>
|
<>
|
||||||
|
{formatPrice(price)}
|
||||||
|
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-normal text-muted">تماس با فروشگاه</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{oldPrice && (
|
{oldPrice && (
|
||||||
<span className="text-xs text-muted line-through">
|
<span className="text-xs text-muted line-through">
|
||||||
@@ -60,12 +71,40 @@ export function ProductCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</>
|
||||||
className="mt-3 w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
);
|
||||||
>
|
|
||||||
افزودن به سبد
|
return (
|
||||||
</button>
|
<article
|
||||||
|
className={`group ${fluid ? "w-full" : "w-[220px] shrink-0 md:w-[240px]"} overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md`}
|
||||||
|
>
|
||||||
|
{href ? <a href={href}>{info}</a> : info}
|
||||||
|
<div className="p-4">
|
||||||
|
{price != null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||||
|
>
|
||||||
|
افزودن به سبد
|
||||||
|
</button>
|
||||||
|
) : contactPhone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${contactPhone}`}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||||
|
>
|
||||||
|
<span aria-hidden>📞</span>
|
||||||
|
تماس با فروشگاه
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="w-full cursor-not-allowed rounded-lg bg-red/60 py-2 text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
تماس با فروشگاه
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
+220
-29
@@ -60,32 +60,51 @@ export async function getCategories(): Promise<Category[]> {
|
|||||||
return res?.items ?? [];
|
return res?.items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CategoryGroup = {
|
// Real category hierarchy from /categories (id, parentId, name, nameFa, slug).
|
||||||
|
// Level 1 = root, used only as an internal grouping key for menu columns — never
|
||||||
|
// rendered. Level 2 items carry their level 3 children (if any) in `children`.
|
||||||
|
export type CategoryNode = {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
slug: string;
|
||||||
title: string;
|
name: string;
|
||||||
items: { name: string; href?: string }[];
|
nameFa: string;
|
||||||
|
children: CategoryNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeGroupLinkItem(raw: unknown): { name: string; href?: string } {
|
export type CategoryTreeGroup = {
|
||||||
if (typeof raw === "string") return { name: raw };
|
id: string;
|
||||||
const item = raw as Record<string, unknown>;
|
items: CategoryNode[];
|
||||||
return {
|
};
|
||||||
name: String(item.name ?? item.title ?? item.label ?? ""),
|
|
||||||
href: typeof item.slug === "string" ? item.slug : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCategoryGroups(): Promise<CategoryGroup[]> {
|
export async function getCategoryTree(): Promise<CategoryTreeGroup[]> {
|
||||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
const res = await apiGet<{ items: Record<string, unknown>[] }>(`/tenants/${DOMAIN}/categories`, {
|
||||||
`/tenants/${DOMAIN}/website/category-groups`,
|
entityType: "product",
|
||||||
);
|
});
|
||||||
return (res?.items ?? []).map((group) => ({
|
const raw = res?.items ?? [];
|
||||||
id: String(group.id ?? group.key ?? ""),
|
|
||||||
key: String(group.key ?? group.id ?? ""),
|
const toNode = (item: Record<string, unknown>): CategoryNode => ({
|
||||||
title: String(group.title ?? ""),
|
id: String(item.id ?? ""),
|
||||||
items: Array.isArray(group.items) ? group.items.map(normalizeGroupLinkItem) : [],
|
slug: String(item.slug ?? ""),
|
||||||
}));
|
name: String(item.name ?? ""),
|
||||||
|
nameFa: String(item.nameFa ?? item.name ?? ""),
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roots = raw.filter((item) => !item.parentId);
|
||||||
|
const groups: CategoryTreeGroup[] = roots.map((root) => {
|
||||||
|
const level2 = raw
|
||||||
|
.filter((item) => String(item.parentId) === String(root.id))
|
||||||
|
.map((item) => {
|
||||||
|
const node = toNode(item);
|
||||||
|
node.children = raw
|
||||||
|
.filter((child) => String(child.parentId) === String(item.id))
|
||||||
|
.map(toNode);
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
return { id: String(root.id ?? ""), items: level2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups.filter((group) => group.items.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
@@ -93,8 +112,8 @@ export type Product = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
price: number;
|
price: number | null;
|
||||||
oldPrice?: number;
|
oldPrice?: number | null;
|
||||||
discount?: number;
|
discount?: number;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
};
|
};
|
||||||
@@ -103,20 +122,35 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
|||||||
const images = raw.images as unknown[] | undefined;
|
const images = raw.images as unknown[] | undefined;
|
||||||
const firstImage = images?.[0];
|
const firstImage = images?.[0];
|
||||||
const brand = raw.brand;
|
const brand = raw.brand;
|
||||||
|
const store = raw.store as Record<string, unknown> | undefined;
|
||||||
|
// Some sources (store-specials, brand-groups) nest price on the first variant
|
||||||
|
// instead of the item itself.
|
||||||
|
const variants = raw.variants as Record<string, unknown>[] | undefined;
|
||||||
|
const firstVariant = variants?.[0];
|
||||||
|
const price =
|
||||||
|
store?.minPrice ?? raw.discountedPrice ?? raw.price ?? firstVariant?.discountedPrice ?? firstVariant?.price;
|
||||||
|
const oldPrice = raw.discountedPrice != null && raw.price != null ? raw.price : raw.oldPrice;
|
||||||
return {
|
return {
|
||||||
id: String(raw.id ?? raw.slug ?? ""),
|
id: String(raw.id ?? ""),
|
||||||
slug: String(raw.slug ?? raw.id ?? ""),
|
// Group sources (store-specials, brand-groups, store-items) don't include a
|
||||||
name: String(raw.name ?? raw.title ?? ""),
|
// product slug, so leave it empty rather than fabricating a broken URL from
|
||||||
|
// the id — callers should fall back to a name search when this is empty.
|
||||||
|
slug: String(raw.slug ?? ""),
|
||||||
|
name: String(
|
||||||
|
raw.title ?? raw.nameFa ?? raw.name ?? raw.productTitle ?? raw.productNameFa ?? "",
|
||||||
|
),
|
||||||
brand:
|
brand:
|
||||||
typeof brand === "string"
|
typeof brand === "string"
|
||||||
? brand
|
? brand
|
||||||
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||||
(raw.brandName as string | undefined),
|
(raw.brandName as string | undefined),
|
||||||
price: Number(raw.price ?? raw.salePrice ?? 0),
|
price: price != null ? Number(price) : null,
|
||||||
oldPrice: raw.oldPrice != null ? Number(raw.oldPrice) : undefined,
|
oldPrice: oldPrice != null ? Number(oldPrice) : undefined,
|
||||||
discount: raw.discount != null ? Number(raw.discount) : undefined,
|
discount: raw.discount != null ? Number(raw.discount) : undefined,
|
||||||
image:
|
image:
|
||||||
|
(raw.thumbnailUrl as string | undefined) ??
|
||||||
(raw.image as string | undefined) ??
|
(raw.image as string | undefined) ??
|
||||||
|
(raw.productImage as string | undefined) ??
|
||||||
(raw.imageUrl as string | undefined) ??
|
(raw.imageUrl as string | undefined) ??
|
||||||
(typeof firstImage === "string"
|
(typeof firstImage === "string"
|
||||||
? firstImage
|
? firstImage
|
||||||
@@ -125,9 +159,16 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function productHref(product: Product): string {
|
||||||
|
return product.slug
|
||||||
|
? `/products/${product.slug}`
|
||||||
|
: `/products?q=${encodeURIComponent(product.name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProducts(params?: {
|
export async function getProducts(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
name?: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
brandId?: string;
|
brandId?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
@@ -140,6 +181,156 @@ export async function getProducts(params?: {
|
|||||||
return { items: (res?.items ?? []).map(normalizeProduct), total: res?.total ?? 0 };
|
return { items: (res?.items ?? []).map(normalizeProduct), total: res?.total ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductDetail = Product & {
|
||||||
|
descriptionHtml?: string;
|
||||||
|
images: string[];
|
||||||
|
categoryId?: string;
|
||||||
|
categoryName?: string;
|
||||||
|
inStock: boolean;
|
||||||
|
minPrice: number | null;
|
||||||
|
maxPrice: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeProductDetail(raw: Record<string, unknown>): ProductDetail {
|
||||||
|
const store = raw.store as Record<string, unknown> | undefined;
|
||||||
|
const images = raw.images as unknown[] | undefined;
|
||||||
|
return {
|
||||||
|
...normalizeProduct(raw),
|
||||||
|
descriptionHtml: raw.descriptionHtml as string | undefined,
|
||||||
|
images: (images ?? []).filter((i): i is string => typeof i === "string"),
|
||||||
|
categoryId: raw.categoryId as string | undefined,
|
||||||
|
categoryName:
|
||||||
|
(raw.categoryNameFa as string | undefined) ?? (raw.categoryName as string | undefined),
|
||||||
|
inStock: Boolean(store?.inStock),
|
||||||
|
minPrice: store?.minPrice != null ? Number(store.minPrice) : null,
|
||||||
|
maxPrice: store?.maxPrice != null ? Number(store.maxPrice) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProduct(slug: string): Promise<ProductDetail | null> {
|
||||||
|
const res = await apiGet<{ product: Record<string, unknown> }>(
|
||||||
|
`/tenants/${DOMAIN}/products/${slug}`,
|
||||||
|
);
|
||||||
|
return res?.product ? normalizeProductDetail(res.product) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductVariations(slug: string): Promise<unknown[]> {
|
||||||
|
const res = await apiGet<{ variations: unknown[] }>(
|
||||||
|
`/tenants/${DOMAIN}/products/${slug}/variations`,
|
||||||
|
);
|
||||||
|
return res?.variations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TechnicalInfoField = { label: string; value: string };
|
||||||
|
|
||||||
|
export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> {
|
||||||
|
const res = await apiGet<{ form: unknown; values: Record<string, unknown>[] }>(
|
||||||
|
`/tenants/${DOMAIN}/products/${slug}/technical-info`,
|
||||||
|
);
|
||||||
|
return (res?.values ?? []).map((v) => ({
|
||||||
|
label: String(v.label ?? v.name ?? v.key ?? ""),
|
||||||
|
value: String(v.value ?? ""),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreItem = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
price: number | null;
|
||||||
|
discountedPrice: number | null;
|
||||||
|
stockQuantity: number;
|
||||||
|
isFestival: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStoreItem(raw: Record<string, unknown>): StoreItem {
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ""),
|
||||||
|
productId: String(raw.productId ?? ""),
|
||||||
|
name: String(raw.productTitle ?? raw.productNameFa ?? raw.label ?? ""),
|
||||||
|
image: (raw.productImage as string | undefined) ?? null,
|
||||||
|
price: raw.price != null ? Number(raw.price) : null,
|
||||||
|
discountedPrice: raw.discountedPrice != null ? Number(raw.discountedPrice) : null,
|
||||||
|
stockQuantity: Number(raw.stockQuantity ?? 0),
|
||||||
|
isFestival: Boolean(raw.isFestival),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStoreItems(params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
brandId?: string;
|
||||||
|
productId?: string;
|
||||||
|
name?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
isFestival?: boolean;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
}): Promise<{ items: StoreItem[]; total: number }> {
|
||||||
|
const res = await apiGet<{ items: Record<string, unknown>[]; total: number }>(
|
||||||
|
`/tenants/${DOMAIN}/store-items`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return { items: (res?.items ?? []).map(normalizeStoreItem), total: res?.total ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogSummary = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
abstract?: string;
|
||||||
|
image?: string | null;
|
||||||
|
publishedAt?: string;
|
||||||
|
categoryName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ""),
|
||||||
|
slug: String(raw.slug ?? ""),
|
||||||
|
title: String(raw.title ?? ""),
|
||||||
|
abstract: raw.abstract as string | undefined,
|
||||||
|
image: (raw.titleImageUrl as string | undefined) ?? null,
|
||||||
|
publishedAt: raw.publishedAt as string | undefined,
|
||||||
|
categoryName: raw.categoryName as string | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlogs(params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
title?: string;
|
||||||
|
}): Promise<{ items: BlogSummary[]; total: number }> {
|
||||||
|
const res = await apiGet<{ items: Record<string, unknown>[]; total: number }>(
|
||||||
|
`/tenants/${DOMAIN}/blogs`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return { items: (res?.items ?? []).map(normalizeBlog), total: res?.total ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogDetail = BlogSummary & {
|
||||||
|
mainTextHtml: string;
|
||||||
|
author?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getBlog(slug: string): Promise<BlogDetail | null> {
|
||||||
|
const res = await apiGet<{ blog: Record<string, unknown> }>(`/tenants/${DOMAIN}/blogs/${slug}`);
|
||||||
|
if (!res?.blog) return null;
|
||||||
|
const blog = res.blog;
|
||||||
|
const author = blog.author as Record<string, unknown> | undefined;
|
||||||
|
const authorName = author
|
||||||
|
? `${(author.firstName as string | undefined) ?? ""} ${(author.lastName as string | undefined) ?? ""}`.trim()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...normalizeBlog(blog),
|
||||||
|
mainTextHtml: String(blog.mainTextHtml ?? ""),
|
||||||
|
author: authorName || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type ProductGroup = {
|
export type ProductGroup = {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user