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:
co-authored by
Claude Sonnet 5
parent
2b5f4e437b
commit
7fb1f5a60f
@@ -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 }}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user