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
@@ -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";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #fafafa;
|
||||
|
||||
+17
-2
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user