- 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>
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|