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:
amirhosein.ashourloo
2026-08-18 13:42:08 +03:30
co-authored by Claude Sonnet 5
parent 1f81236d0a
commit 2b5f4e437b
19 changed files with 1018 additions and 175 deletions
+12
View File
@@ -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 });
}
+64
View File
@@ -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>
);
}
+97
View File
@@ -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
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #fafafa;
+17 -2
View File
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import { Vazirmatn, Syne } from "next/font/google";
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({
variable: "--font-vazirmatn",
@@ -19,14 +23,25 @@ export const metadata: Metadata = {
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 (
<html
lang="fa"
dir="rtl"
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>
);
}
+26 -42
View File
@@ -1,5 +1,3 @@
import { TopBar } from "@/components/home/TopBar";
import { Header } from "@/components/home/Header";
import { FeatureBar } from "@/components/home/FeatureBar";
import { HeroSlider } from "@/components/home/HeroSlider";
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
@@ -10,11 +8,9 @@ import { ThreeBanners } from "@/components/home/ThreeBanners";
import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
import { AboutSanihome } from "@/components/home/AboutSanihome";
import { BlogCarousel } from "@/components/home/BlogCarousel";
import { Footer } from "@/components/home/Footer";
import {
getBusinessInfo,
getCategories,
getCategoryGroups,
getProducts,
getBrandGroups,
getStoreSpecials,
@@ -24,24 +20,17 @@ import {
} from "@/lib/meshkee";
export default async function Home() {
const [
categoryGroups,
categories,
storeSpecials,
productsResult,
brandGroups,
businessInfo,
staticImageSlots,
] = await Promise.all([
getCategoryGroups(),
getCategories(),
getStoreSpecials(),
getProducts({ pageSize: 12 }),
getBrandGroups(),
getBusinessInfo(),
getStaticImageSlots(),
]);
const [categories, storeSpecials, productsResult, brandGroups, staticImageSlots, businessInfo] =
await Promise.all([
getCategories(),
getStoreSpecials(),
getProducts({ pageSize: 12 }),
getBrandGroups(),
getStaticImageSlots(),
getBusinessInfo(),
]);
const contactPhone = businessInfo?.phoneNumbers[0];
const specialSaleProducts = storeSpecials[0]?.items ?? [];
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
const threeBannerSlot = staticImageSlots.find(
@@ -51,27 +40,22 @@ export default async function Home() {
return (
<>
<TopBar />
<Header categoryGroups={categoryGroups} categories={categories} />
<main>
<HeroSlider images={heroImages} />
<SpecialSaleCarousel products={specialSaleProducts} />
<FeatureBar />
<FeaturedCategories categories={categories} />
<TwoBanners
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
aspectRatio={twoBannerSlot?.aspectRatio}
/>
<TopSellingCarousel products={productsResult.items} />
<ThreeBanners
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
aspectRatio={threeBannerSlot?.aspectRatio}
/>
<BrandGroupCarousels brandGroups={brandGroups} />
<AboutSanihome />
<BlogCarousel />
</main>
<Footer businessInfo={businessInfo} categories={categories} />
<HeroSlider images={heroImages} />
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
<FeatureBar />
<FeaturedCategories categories={categories} />
<TwoBanners
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
aspectRatio={twoBannerSlot?.aspectRatio}
/>
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} />
<ThreeBanners
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
aspectRatio={threeBannerSlot?.aspectRatio}
/>
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
<AboutSanihome />
<BlogCarousel />
</>
);
}
+135
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}