Polish product/blog detail pages: gallery, tabs, Persian names, image fixes

- 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 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-08-18 19:38:54 +03:30
co-authored by Claude Sonnet 5
parent 2b5f4e437b
commit 7fb1f5a60f
11 changed files with 351 additions and 168 deletions
-11
View File
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Placeholder } from "@/components/ui/Placeholder";
import { getBlog } from "@/lib/meshkee"; import { getBlog } from "@/lib/meshkee";
function formatDate(iso?: string) { function formatDate(iso?: string) {
@@ -39,16 +38,6 @@ export default async function BlogDetailPage({
{post.title} {post.title}
</h1> </h1>
{post.image && (
<Placeholder
src={post.image}
alt={post.title}
width="100%"
height="360px"
className="mb-8 w-full rounded-2xl"
/>
)}
<div <div
className="prose max-w-none" className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }} dangerouslySetInnerHTML={{ __html: post.mainTextHtml }}
+4 -2
View File
@@ -34,13 +34,15 @@ export default async function BlogListPage({
href={`/blog/${post.slug}`} href={`/blog/${post.slug}`}
className="group overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md" className="group overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md"
> >
<div className="p-[10px]">
<Placeholder <Placeholder
src={post.image ?? "/images/placeholder.svg"} src={post.image ?? "/images/placeholder.svg"}
alt={post.title} alt={post.title}
width="100%" width="100%"
height="180px" fit="contain"
className="w-full rounded-none border-0" className="aspect-square w-full rounded-lg border-0"
/> />
</div>
<div className="p-4"> <div className="p-4">
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted"> <div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
{post.categoryName && <span>{post.categoryName}</span>} {post.categoryName && <span>{post.categoryName}</span>}
+6
View File
@@ -83,3 +83,9 @@ body {
.carousel-track::-webkit-scrollbar { .carousel-track::-webkit-scrollbar {
display: none; display: none;
} }
.prose img {
width: 100%;
height: auto;
border-radius: 1rem;
}
+12 -3
View File
@@ -9,6 +9,7 @@ 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 { import {
getBlogs,
getBusinessInfo, getBusinessInfo,
getCategories, getCategories,
getProducts, getProducts,
@@ -20,14 +21,22 @@ import {
} from "@/lib/meshkee"; } from "@/lib/meshkee";
export default async function Home() { export default async function Home() {
const [categories, storeSpecials, productsResult, brandGroups, staticImageSlots, businessInfo] = const [
await Promise.all([ categories,
storeSpecials,
productsResult,
brandGroups,
staticImageSlots,
businessInfo,
blogsResult,
] = await Promise.all([
getCategories(), getCategories(),
getStoreSpecials(), getStoreSpecials(),
getProducts({ pageSize: 12 }), getProducts({ pageSize: 12 }),
getBrandGroups(), getBrandGroups(),
getStaticImageSlots(), getStaticImageSlots(),
getBusinessInfo(), getBusinessInfo(),
getBlogs({ pageSize: 8 }),
]); ]);
const contactPhone = businessInfo?.phoneNumbers[0]; const contactPhone = businessInfo?.phoneNumbers[0];
@@ -55,7 +64,7 @@ export default async function Home() {
/> />
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} /> <BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
<AboutSanihome /> <AboutSanihome />
<BlogCarousel /> <BlogCarousel posts={blogsResult.items} />
</> </>
); );
} }
+50 -54
View File
@@ -1,7 +1,13 @@
import { notFound } from "next/navigation"; 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 { formatPrice } from "@/data/sample";
import { getBusinessInfo, getProduct, getProductTechnicalInfo } from "@/lib/meshkee"; import {
getBusinessInfo,
getProduct,
getProductTechnicalInfo,
getProductVariations,
} from "@/lib/meshkee";
export default async function ProductDetailPage({ export default async function ProductDetailPage({
params, params,
@@ -9,9 +15,10 @@ export default async function ProductDetailPage({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const [product, technicalInfo, businessInfo] = await Promise.all([ const [product, technicalInfo, variations, businessInfo] = await Promise.all([
getProduct(slug), getProduct(slug),
getProductTechnicalInfo(slug), getProductTechnicalInfo(slug),
getProductVariations(slug),
getBusinessInfo(), getBusinessInfo(),
]); ]);
const contactPhone = businessInfo?.phoneNumbers[0]; const contactPhone = businessInfo?.phoneNumbers[0];
@@ -22,30 +29,12 @@ export default async function ProductDetailPage({
return ( return (
<div className="mx-auto max-w-7xl px-4 py-10"> <div className="mx-auto max-w-7xl px-4 py-10">
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-12">
<div className="grid gap-3"> <div className="md:col-span-5">
<Placeholder <ProductGallery images={images} alt={product.name} />
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>
<div> <div className="md:col-span-7">
{product.brand && ( {product.brand && (
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase"> <p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
{product.brand} {product.brand}
@@ -61,17 +50,17 @@ export default async function ProductDetailPage({
</a> </a>
)} )}
<div className="my-5 flex flex-wrap items-center gap-3"> <div className="my-5 flex flex-wrap items-center gap-4">
{product.minPrice != null ? ( {product.minPrice != null && (
<span className="text-2xl font-bold text-red-dark"> <span className="text-2xl font-bold text-red-dark">
{product.minPrice === product.maxPrice {product.minPrice === product.maxPrice
? formatPrice(product.minPrice) ? formatPrice(product.minPrice)
: `${formatPrice(product.minPrice)} - ${formatPrice(product.maxPrice ?? product.minPrice)}`} : `${formatPrice(product.minPrice)} - ${formatPrice(product.maxPrice ?? product.minPrice)}`}
<span className="mr-1 text-sm font-normal text-muted">تومان</span> <span className="mr-1 text-sm font-normal text-muted">تومان</span>
</span> </span>
) : (
<span className="text-lg text-muted">تماس با فروشگاه</span>
)} )}
<span className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">موجودی:</span>
<span <span
className={`rounded-full px-3 py-1 text-xs font-medium ${ 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 ? "bg-green-50 text-green-700" : "bg-red-light text-red-dark"
@@ -79,19 +68,47 @@ export default async function ProductDetailPage({
> >
{product.inStock ? "موجود" : "ناموجود"} {product.inStock ? "موجود" : "ناموجود"}
</span> </span>
</span>
</div> </div>
{variations.length > 0 && (
<div className="mb-6 space-y-4">
{variations.map((variation) => (
<div key={variation.id}>
<p className="mb-2 text-sm font-bold">{variation.name}</p>
<div className="flex flex-wrap gap-2">
{variation.options.map((option) => (
<span
key={option.id}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm"
>
{option.colorHex && (
<span
className="h-3 w-3 shrink-0 rounded-full border border-border"
style={{ backgroundColor: option.colorHex }}
aria-hidden
/>
)}
{option.label}
</span>
))}
</div>
</div>
))}
</div>
)}
{product.minPrice != null ? ( {product.minPrice != null ? (
<button <button
type="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" className="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> </button>
) : contactPhone ? ( ) : contactPhone ? (
<a <a
href={`tel:${contactPhone}`} 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" className="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> <span aria-hidden>📞</span>
تماس با فروشگاه تماس با فروشگاه
@@ -100,36 +117,15 @@ export default async function ProductDetailPage({
<button <button
type="button" type="button"
disabled 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" className="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> </button>
)} )}
{product.descriptionHtml && (
<div
className="prose prose-sm max-w-none border-t border-border pt-5"
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
)}
</div> </div>
</div> </div>
{technicalInfo.length > 0 && ( <ProductTabs technicalInfo={technicalInfo} descriptionHtml={product.descriptionHtml} />
<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> </div>
); );
} }
+28 -18
View File
@@ -1,9 +1,15 @@
import { Placeholder } from "@/components/ui/Placeholder"; import { Placeholder } from "@/components/ui/Placeholder";
import { SectionTitle } from "@/components/ui/SectionTitle"; import { SectionTitle } from "@/components/ui/SectionTitle";
import { Carousel } from "@/components/ui/Carousel"; 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 ( 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">
@@ -12,42 +18,46 @@ export function BlogCarousel() {
enTitle="Latest Blog" enTitle="Latest Blog"
action="وبلاگ" action="وبلاگ"
/> />
{posts.length === 0 ? (
<EmptyState message="هنوز مطلبی ثبت نشده است." />
) : (
<Carousel ariaLabel="کاروسل وبلاگ"> <Carousel ariaLabel="کاروسل وبلاگ">
{blogPosts.map((post) => ( {posts.map((post) => (
<article <article
key={post.id} key={post.id}
className="w-[280px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[300px]" className="w-[280px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[300px]"
> >
<a href={`/blog/${post.slug}`}>
<div className="p-[10px]">
<Placeholder <Placeholder
src={post.image} src={post.image ?? "/images/placeholder.svg"}
alt={post.title} alt={post.title}
width="100%" width="100%"
height="160px" fit="contain"
label="300×160" className="aspect-square w-full rounded-lg border-0"
className="w-full rounded-none border-0 border-b border-dashed"
/> />
<div className="p-4">
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
<span>{post.date}</span>
<span>•</span>
<span>{post.readTime} مطالعه</span>
</div> </div>
<div className="p-4 pt-0">
{post.publishedAt && (
<div className="mb-2 text-[11px] text-muted">{formatDate(post.publishedAt)}</div>
)}
<h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed"> <h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed">
{post.title} {post.title}
</h3> </h3>
{post.abstract && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted"> <p className="line-clamp-2 text-xs leading-relaxed text-muted">
{post.excerpt} {post.abstract}
</p> </p>
<button )}
type="button" <span className="mt-3 block text-xs font-medium text-red">
className="mt-3 text-xs font-medium text-red transition-colors hover:text-red-dark"
>
ادامه مطلب ← ادامه مطلب ←
</button> </span>
</div> </div>
</a>
</article> </article>
))} ))}
</Carousel> </Carousel>
)}
</div> </div>
</section> </section>
); );
+94
View File
@@ -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 (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
<path d={d} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
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 (
<div className="grid gap-3">
<div
className="relative aspect-square w-full overflow-hidden rounded-2xl border border-border bg-surface"
onMouseMove={(event) => {
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 */}
<img
src={list[index]}
alt={alt}
className="h-full w-full object-contain transition-transform duration-200 ease-out"
style={{
transform: zoomed ? "scale(2)" : "scale(1)",
transformOrigin: `${origin.x}% ${origin.y}%`,
cursor: zoomed ? "zoom-out" : "zoom-in",
}}
/>
{hasMultiple && (
<>
<button
type="button"
onClick={() => go(-1)}
aria-label="تصویر بعدی"
className="absolute top-1/2 right-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
>
<ChevronIcon direction="prev" className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="تصویر قبلی"
className="absolute top-1/2 left-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
>
<ChevronIcon direction="next" className="h-5 w-5" />
</button>
</>
)}
</div>
{hasMultiple && (
<div className="flex flex-wrap gap-3">
{list.map((img, i) => (
<button
key={img + i}
type="button"
onClick={() => setIndex(i)}
aria-label={`تصویر ${i + 1}`}
className={`h-16 w-16 shrink-0 overflow-hidden rounded-lg border-2 transition-colors ${
i === index ? "border-red" : "border-border hover:border-red-muted"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}
+81
View File
@@ -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<Tab>("specs");
const activeLabel = TABS.find((t) => t.id === active)?.label ?? "";
return (
<div className="mt-10">
<div className="flex items-center gap-8 border-b border-border">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActive(tab.id)}
className={`relative pb-4 text-sm transition-colors ${
active === tab.id ? "font-bold text-foreground" : "text-muted hover:text-red"
}`}
>
{tab.label}
{active === tab.id && (
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
))}
</div>
<div className="pt-8">
<h2 className="mb-5 flex items-center gap-2 text-lg font-bold">
<span className="h-2 w-2 shrink-0 rounded-full bg-red" aria-hidden />
{activeLabel}
</h2>
{active === "specs" &&
(technicalInfo.length === 0 ? (
<EmptyState message="هنوز مشخصات فنی‌ای برای این محصول ثبت نشده است." />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{technicalInfo.map((field) => (
<div key={field.label} className="rounded-xl bg-background p-4">
<p className="mb-1 text-sm font-bold">{field.label} :</p>
<p className="text-sm text-muted">{field.value}</p>
</div>
))}
</div>
))}
{active === "review" &&
(descriptionHtml ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<EmptyState message="هنوز نقد و بررسی‌ای برای این محصول ثبت نشده است." />
))}
{active === "comments" && (
<EmptyState message="هنوز نظری برای این محصول ثبت نشده است." />
)}
</div>
</div>
);
}
+2 -2
View File
@@ -28,13 +28,13 @@ export function ProductCard({
}: ProductCardProps) { }: ProductCardProps) {
const info = ( const info = (
<> <>
<div className="relative"> <div className="relative p-[10px]">
<Placeholder <Placeholder
src={image} src={image}
alt={name} alt={name}
width="100%" width="100%"
fit="contain" fit="contain"
className="aspect-square w-full rounded-none border-0 border-b border-dashed" className="aspect-square w-full rounded-lg border-0"
/> />
{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">
-35
View File
@@ -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 { export function formatPrice(price: number): string {
return new Intl.NumberFormat("fa-IR").format(price); return new Intl.NumberFormat("fa-IR").format(price);
} }
+36 -5
View File
@@ -136,8 +136,9 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
// product slug, so leave it empty rather than fabricating a broken URL from // 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. // the id — callers should fall back to a name search when this is empty.
slug: String(raw.slug ?? ""), slug: String(raw.slug ?? ""),
// Prefer the Persian name — sanihome.ir is a Persian storefront.
name: String( name: String(
raw.title ?? raw.nameFa ?? raw.name ?? raw.productTitle ?? raw.productNameFa ?? "", raw.nameFa || raw.productNameFa || raw.name || raw.title || raw.productTitle || "",
), ),
brand: brand:
typeof brand === "string" typeof brand === "string"
@@ -194,10 +195,16 @@ export type ProductDetail = Product & {
function normalizeProductDetail(raw: Record<string, unknown>): ProductDetail { function normalizeProductDetail(raw: Record<string, unknown>): ProductDetail {
const store = raw.store as Record<string, unknown> | undefined; const store = raw.store as Record<string, unknown> | undefined;
const images = raw.images as unknown[] | 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<string, unknown> | undefined)?.url as string | undefined),
)
.filter((url): url is string => Boolean(url));
return { return {
...normalizeProduct(raw), ...normalizeProduct(raw),
descriptionHtml: raw.descriptionHtml as string | undefined, descriptionHtml: raw.descriptionHtml as string | undefined,
images: (images ?? []).filter((i): i is string => typeof i === "string"), images: imageUrls,
categoryId: raw.categoryId as string | undefined, categoryId: raw.categoryId as string | undefined,
categoryName: categoryName:
(raw.categoryNameFa as string | undefined) ?? (raw.categoryName as string | undefined), (raw.categoryNameFa as string | undefined) ?? (raw.categoryName as string | undefined),
@@ -214,11 +221,35 @@ export async function getProduct(slug: string): Promise<ProductDetail | null> {
return res?.product ? normalizeProductDetail(res.product) : null; return res?.product ? normalizeProductDetail(res.product) : null;
} }
export async function getProductVariations(slug: string): Promise<unknown[]> { export type VariationOption = {
const res = await apiGet<{ variations: unknown[] }>( 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<ProductVariation[]> {
const res = await apiGet<{ variations: Record<string, unknown>[] }>(
`/tenants/${DOMAIN}/products/${slug}/variations`, `/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<string, unknown>[] | 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 }; export type TechnicalInfoField = { label: string; value: string };