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
+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>
);
}