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 { 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}
</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 }}
+9 -7
View File
@@ -34,13 +34,15 @@ export default async function BlogListPage({
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-[10px]">
<Placeholder
src={post.image ?? "/images/placeholder.svg"}
alt={post.title}
width="100%"
fit="contain"
className="aspect-square w-full rounded-lg border-0"
/>
</div>
<div className="p-4">
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
{post.categoryName && <span>{post.categoryName}</span>}
+6
View File
@@ -83,3 +83,9 @@ body {
.carousel-track::-webkit-scrollbar {
display: none;
}
.prose img {
width: 100%;
height: auto;
border-radius: 1rem;
}
+19 -10
View File
@@ -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() {
/>
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
<AboutSanihome />
<BlogCarousel />
<BlogCarousel posts={blogsResult.items} />
</>
);
}
+56 -60
View File
@@ -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 (
<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 className="grid gap-8 md:grid-cols-12">
<div className="md:col-span-5">
<ProductGallery images={images} alt={product.name} />
</div>
<div>
<div className="md:col-span-7">
{product.brand && (
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
{product.brand}
@@ -61,37 +50,65 @@ export default async function ProductDetailPage({
</a>
)}
<div className="my-5 flex flex-wrap items-center gap-3">
{product.minPrice != null ? (
<div className="my-5 flex flex-wrap items-center gap-4">
{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 className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">موجودی:</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>
</span>
</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 ? (
<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>
) : 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"
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>
تماس با فروشگاه
@@ -100,36 +117,15 @@ export default async function ProductDetailPage({
<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"
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>
)}
{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>
)}
<ProductTabs technicalInfo={technicalInfo} descriptionHtml={product.descriptionHtml} />
</div>
);
}