From 7fb1f5a60f1e2385ad533df7dd178a6921647164 Mon Sep 17 00:00:00 2001 From: "amirhosein.ashourloo" Date: Tue, 18 Aug 2026 19:38:54 +0330 Subject: [PATCH] Polish product/blog detail pages: gallery, tabs, Persian names, image fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product detail: interactive image gallery (thumbnails, prev/next, hover-zoom), tabbed spec/review/comments section matching the site's standard pattern, variations/options display, and an availability label instead of duplicating the contact-store message. - Fix product.images normalization — the API returns {url} media objects, not plain strings, so galleries were silently rendering empty. - Prefer the Persian name (nameFa/productNameFa) everywhere a product name is shown. - Product cards: padded, rounded images instead of edge-to-edge with a dashed divider. - Blog: wire the homepage carousel to real posts (was static sample data), drop the detail page's separate cover image, and make images embedded in article/description HTML fill their container with rounded corners. Co-Authored-By: Claude Sonnet 5 --- src/app/blog/[slug]/page.tsx | 11 -- src/app/blog/page.tsx | 16 +-- src/app/globals.css | 6 ++ src/app/page.tsx | 29 ++++-- src/app/products/[slug]/page.tsx | 116 +++++++++++----------- src/components/home/BlogCarousel.tsx | 86 +++++++++------- src/components/product/ProductGallery.tsx | 94 ++++++++++++++++++ src/components/product/ProductTabs.tsx | 81 +++++++++++++++ src/components/ui/ProductCard.tsx | 4 +- src/data/sample.ts | 35 ------- src/lib/meshkee.ts | 41 +++++++- 11 files changed, 351 insertions(+), 168 deletions(-) create mode 100644 src/components/product/ProductGallery.tsx create mode 100644 src/components/product/ProductTabs.tsx diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 3b41633..84ff3b9 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,5 +1,4 @@ import { notFound } from "next/navigation"; -import { Placeholder } from "@/components/ui/Placeholder"; import { getBlog } from "@/lib/meshkee"; function formatDate(iso?: string) { @@ -39,16 +38,6 @@ export default async function BlogDetailPage({ {post.title} - {post.image && ( - - )} -
- +
+ +
{post.categoryName && {post.categoryName}} diff --git a/src/app/globals.css b/src/app/globals.css index 323febe..2cfcfe1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -83,3 +83,9 @@ body { .carousel-track::-webkit-scrollbar { display: none; } + +.prose img { + width: 100%; + height: auto; + border-radius: 1rem; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9c4ac2a..2f69b9b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,6 +9,7 @@ import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels"; import { AboutSanihome } from "@/components/home/AboutSanihome"; import { BlogCarousel } from "@/components/home/BlogCarousel"; import { + getBlogs, getBusinessInfo, getCategories, getProducts, @@ -20,15 +21,23 @@ import { } from "@/lib/meshkee"; export default async function Home() { - const [categories, storeSpecials, productsResult, brandGroups, staticImageSlots, businessInfo] = - await Promise.all([ - getCategories(), - getStoreSpecials(), - getProducts({ pageSize: 12 }), - getBrandGroups(), - getStaticImageSlots(), - getBusinessInfo(), - ]); + const [ + categories, + storeSpecials, + productsResult, + brandGroups, + staticImageSlots, + businessInfo, + blogsResult, + ] = await Promise.all([ + getCategories(), + getStoreSpecials(), + getProducts({ pageSize: 12 }), + getBrandGroups(), + getStaticImageSlots(), + getBusinessInfo(), + getBlogs({ pageSize: 8 }), + ]); const contactPhone = businessInfo?.phoneNumbers[0]; const specialSaleProducts = storeSpecials[0]?.items ?? []; @@ -55,7 +64,7 @@ export default async function Home() { /> - + ); } diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 40cfb7f..1ffee33 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -1,7 +1,13 @@ import { notFound } from "next/navigation"; -import { Placeholder } from "@/components/ui/Placeholder"; +import { ProductGallery } from "@/components/product/ProductGallery"; +import { ProductTabs } from "@/components/product/ProductTabs"; import { formatPrice } from "@/data/sample"; -import { getBusinessInfo, getProduct, getProductTechnicalInfo } from "@/lib/meshkee"; +import { + getBusinessInfo, + getProduct, + getProductTechnicalInfo, + getProductVariations, +} from "@/lib/meshkee"; export default async function ProductDetailPage({ params, @@ -9,9 +15,10 @@ export default async function ProductDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const [product, technicalInfo, businessInfo] = await Promise.all([ + const [product, technicalInfo, variations, businessInfo] = await Promise.all([ getProduct(slug), getProductTechnicalInfo(slug), + getProductVariations(slug), getBusinessInfo(), ]); const contactPhone = businessInfo?.phoneNumbers[0]; @@ -22,30 +29,12 @@ export default async function ProductDetailPage({ return (
-
-
- - {images.length > 1 && ( -
- {images.slice(1, 5).map((img) => ( - - ))} -
- )} +
+
+
-
+
{product.brand && (

{product.brand} @@ -61,37 +50,65 @@ export default async function ProductDetailPage({ )} -

- {product.minPrice != null ? ( +
+ {product.minPrice != null && ( {product.minPrice === product.maxPrice ? formatPrice(product.minPrice) : `${formatPrice(product.minPrice)} - ${formatPrice(product.maxPrice ?? product.minPrice)}`} تومان - ) : ( - تماس با فروشگاه )} - - {product.inStock ? "موجود" : "ناموجود"} + + موجودی: + + {product.inStock ? "موجود" : "ناموجود"} +
+ {variations.length > 0 && ( +
+ {variations.map((variation) => ( +
+

{variation.name}

+
+ {variation.options.map((option) => ( + + {option.colorHex && ( + + )} + {option.label} + + ))} +
+
+ ))} +
+ )} + {product.minPrice != null ? ( ) : contactPhone ? ( 📞 تماس با فروشگاه @@ -100,36 +117,15 @@ export default async function ProductDetailPage({ )} - - {product.descriptionHtml && ( -
- )}
- {technicalInfo.length > 0 && ( -
-

مشخصات فنی

- - - {technicalInfo.map((field) => ( - - - - - ))} - -
{field.label}{field.value}
-
- )} +
); } diff --git a/src/components/home/BlogCarousel.tsx b/src/components/home/BlogCarousel.tsx index 4ba16e4..76bb23c 100644 --- a/src/components/home/BlogCarousel.tsx +++ b/src/components/home/BlogCarousel.tsx @@ -1,9 +1,15 @@ import { Placeholder } from "@/components/ui/Placeholder"; import { SectionTitle } from "@/components/ui/SectionTitle"; import { Carousel } from "@/components/ui/Carousel"; -import { blogPosts } from "@/data/sample"; +import { EmptyState } from "@/components/ui/EmptyState"; +import type { BlogSummary } from "@/lib/meshkee"; -export function BlogCarousel() { +function formatDate(iso?: string) { + if (!iso) return ""; + return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso)); +} + +export function BlogCarousel({ posts }: { posts: BlogSummary[] }) { return (
); diff --git a/src/components/product/ProductGallery.tsx b/src/components/product/ProductGallery.tsx new file mode 100644 index 0000000..d410e75 --- /dev/null +++ b/src/components/product/ProductGallery.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useState } from "react"; + +function ChevronIcon({ direction, className }: { direction: "prev" | "next"; className?: string }) { + const d = direction === "prev" ? "M14.5 6.5 9 12l5.5 5.5" : "M9.5 6.5 15 12l-5.5 5.5"; + return ( + + + + ); +} + +export function ProductGallery({ images, alt }: { images: string[]; alt: string }) { + const [index, setIndex] = useState(0); + const [zoomed, setZoomed] = useState(false); + const [origin, setOrigin] = useState({ x: 50, y: 50 }); + + const list = images.length > 0 ? images : ["/images/placeholder.svg"]; + const hasMultiple = list.length > 1; + + const go = (delta: number) => { + setIndex((prev) => (prev + delta + list.length) % list.length); + }; + + return ( +
+
{ + const rect = event.currentTarget.getBoundingClientRect(); + setOrigin({ + x: ((event.clientX - rect.left) / rect.width) * 100, + y: ((event.clientY - rect.top) / rect.height) * 100, + }); + }} + onMouseEnter={() => setZoomed(true)} + onMouseLeave={() => setZoomed(false)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {alt} + + {hasMultiple && ( + <> + + + + )} +
+ + {hasMultiple && ( +
+ {list.map((img, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/product/ProductTabs.tsx b/src/components/product/ProductTabs.tsx new file mode 100644 index 0000000..b15cab3 --- /dev/null +++ b/src/components/product/ProductTabs.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { EmptyState } from "@/components/ui/EmptyState"; +import type { TechnicalInfoField } from "@/lib/meshkee"; + +type Tab = "specs" | "review" | "comments"; + +const TABS: { id: Tab; label: string }[] = [ + { id: "specs", label: "مشخصات فنی" }, + { id: "review", label: "نقد و بررسی" }, + { id: "comments", label: "نظرات کاربران" }, +]; + +export function ProductTabs({ + technicalInfo, + descriptionHtml, +}: { + technicalInfo: TechnicalInfoField[]; + descriptionHtml?: string; +}) { + const [active, setActive] = useState("specs"); + const activeLabel = TABS.find((t) => t.id === active)?.label ?? ""; + + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+ +
+

+ + {activeLabel} +

+ + {active === "specs" && + (technicalInfo.length === 0 ? ( + + ) : ( +
+ {technicalInfo.map((field) => ( +
+

{field.label} :

+

{field.value}

+
+ ))} +
+ ))} + + {active === "review" && + (descriptionHtml ? ( +
+ ) : ( + + ))} + + {active === "comments" && ( + + )} +
+
+ ); +} diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx index 465e1ee..6485bca 100644 --- a/src/components/ui/ProductCard.tsx +++ b/src/components/ui/ProductCard.tsx @@ -28,13 +28,13 @@ export function ProductCard({ }: ProductCardProps) { const info = ( <> -
+
{discount && ( diff --git a/src/data/sample.ts b/src/data/sample.ts index 625eacd..2e6d0b3 100644 --- a/src/data/sample.ts +++ b/src/data/sample.ts @@ -22,41 +22,6 @@ export const sliderSlides = [ }, ]; -export const blogPosts = [ - { - id: 1, - title: "راهنمای خرید یخچال مناسب برای خانواده", - excerpt: "نکات مهم برای انتخاب یخچال با توجه به فضای آشپزخانه و تعداد اعضای خانواده.", - date: "۱۲ مرداد ۱۴۰۵", - readTime: "۵ دقیقه", - image: "/images/blog-1.svg", - }, - { - id: 2, - title: "مقایسه تلویزیون QLED و OLED", - excerpt: "تفاوت‌های فنی و کاربردی دو تکنولوژی محبوب نمایشگر در سال ۱۴۰۵.", - date: "۸ مرداد ۱۴۰۵", - readTime: "۷ دقیقه", - image: "/images/blog-2.svg", - }, - { - id: 3, - title: "چگونه مصرف برق لوازم خانگی را کاهش دهیم؟", - excerpt: "راهکارهای عملی برای صرفه‌جویی در قبض برق با استفاده هوشمند از لوازم برقی.", - date: "۳ مرداد ۱۴۰۵", - readTime: "۴ دقیقه", - image: "/images/blog-3.svg", - }, - { - id: 4, - title: "معرفی جدیدترین محصولات سامسونگ ۲۰۲۶", - excerpt: "نگاهی به سری Bespoke و Neo QLED در نمایشگاه لوازم خانگی امسال.", - date: "۲۸ تیر ۱۴۰۵", - readTime: "۶ دقیقه", - image: "/images/blog-4.svg", - }, -]; - export function formatPrice(price: number): string { return new Intl.NumberFormat("fa-IR").format(price); } diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 8e78bc8..747726f 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -136,8 +136,9 @@ function normalizeProduct(raw: Record): Product { // 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 ?? ""), + // Prefer the Persian name — sanihome.ir is a Persian storefront. name: String( - raw.title ?? raw.nameFa ?? raw.name ?? raw.productTitle ?? raw.productNameFa ?? "", + raw.nameFa || raw.productNameFa || raw.name || raw.title || raw.productTitle || "", ), brand: typeof brand === "string" @@ -194,10 +195,16 @@ export type ProductDetail = Product & { function normalizeProductDetail(raw: Record): ProductDetail { const store = raw.store as Record | undefined; const images = raw.images as unknown[] | undefined; + // Each entry is either a plain url string or a { url } media object. + const imageUrls = (images ?? []) + .map((i) => + typeof i === "string" ? i : ((i as Record | undefined)?.url as string | undefined), + ) + .filter((url): url is string => Boolean(url)); return { ...normalizeProduct(raw), descriptionHtml: raw.descriptionHtml as string | undefined, - images: (images ?? []).filter((i): i is string => typeof i === "string"), + images: imageUrls, categoryId: raw.categoryId as string | undefined, categoryName: (raw.categoryNameFa as string | undefined) ?? (raw.categoryName as string | undefined), @@ -214,11 +221,35 @@ export async function getProduct(slug: string): Promise { return res?.product ? normalizeProductDetail(res.product) : null; } -export async function getProductVariations(slug: string): Promise { - const res = await apiGet<{ variations: unknown[] }>( +export type VariationOption = { + id: string; + label: string; + value: string; + colorHex?: string | null; +}; + +export type ProductVariation = { + id: string; + name: string; + type: string; + options: VariationOption[]; +}; + +export async function getProductVariations(slug: string): Promise { + const res = await apiGet<{ variations: Record[] }>( `/tenants/${DOMAIN}/products/${slug}/variations`, ); - return res?.variations ?? []; + return (res?.variations ?? []).map((v) => ({ + id: String(v.id ?? ""), + name: String(v.name ?? ""), + type: String(v.type ?? ""), + options: ((v.options as Record[] | undefined) ?? []).map((o) => ({ + id: String(o.id ?? ""), + label: String(o.label ?? o.value ?? ""), + value: String(o.value ?? ""), + colorHex: (o.colorHex as string | null | undefined) ?? null, + })), + })); } export type TechnicalInfoField = { label: string; value: string };