340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useMemo, useState } from "react";
|
|
import { useCart } from "@/app/providers/cart-provider";
|
|
import { useAuth } from "@/app/providers/auth-provider";
|
|
import { useToast } from "@/app/providers/toast-provider";
|
|
import { submitComment } from "@/lib/meshkee/api";
|
|
import {
|
|
formatPrice,
|
|
storeItemName,
|
|
storeItemPrice,
|
|
} from "@/lib/meshkee/format";
|
|
import type {
|
|
Comment,
|
|
Product,
|
|
StoreItemVariant,
|
|
TechnicalInfo,
|
|
} from "@/lib/meshkee/types";
|
|
import { customerLoginUrl, storeItemHref } from "@/lib/meshkee/urls";
|
|
|
|
type TabKey = "description" | "technical" | "comments";
|
|
|
|
export function StoreDetailClient({
|
|
variant,
|
|
product,
|
|
technical,
|
|
comments: initialComments,
|
|
similar,
|
|
}: {
|
|
variant: StoreItemVariant;
|
|
product: Product;
|
|
technical: TechnicalInfo;
|
|
comments: Comment[];
|
|
similar: StoreItemVariant[];
|
|
}) {
|
|
const name = storeItemName(variant);
|
|
const price = storeItemPrice(variant);
|
|
const images = useMemo(() => {
|
|
const list = (product.images ?? [])
|
|
.map((image) => image.url)
|
|
.filter(Boolean);
|
|
const fallback = variant.productImage || product.thumbnailUrl || product.image;
|
|
if (fallback && !list.includes(fallback)) list.unshift(fallback);
|
|
return list;
|
|
}, [product, variant]);
|
|
|
|
const [activeImage, setActiveImage] = useState(images[0] || "");
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
const [tab, setTab] = useState<TabKey>("description");
|
|
const [quantity, setQuantity] = useState(1);
|
|
const [comments, setComments] = useState(initialComments);
|
|
const [commentText, setCommentText] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const { addItem } = useCart();
|
|
const { isAuthenticated, user, getAccessToken } = useAuth();
|
|
const { showToast } = useToast();
|
|
|
|
const techRows = useMemo(() => {
|
|
const fields = technical.form?.fields ?? [];
|
|
if (!fields.length || !technical.values?.length) return [];
|
|
return [...fields]
|
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
|
.map((field) => {
|
|
const value = technical.values.find((entry) => entry.fieldId === field.id);
|
|
if (!value) return null;
|
|
let display = value.textValue ?? "";
|
|
if (value.optionId) {
|
|
display =
|
|
field.options?.find((option) => option.id === value.optionId)?.labelFa ||
|
|
field.options?.find((option) => option.id === value.optionId)?.label ||
|
|
display;
|
|
}
|
|
if (value.optionIds?.length) {
|
|
display = value.optionIds
|
|
.map(
|
|
(id) =>
|
|
field.options?.find((option) => option.id === id)?.labelFa ||
|
|
field.options?.find((option) => option.id === id)?.label ||
|
|
id,
|
|
)
|
|
.join("، ");
|
|
}
|
|
if (!display) return null;
|
|
return {
|
|
label: field.labelFa || field.label || field.key || "ویژگی",
|
|
value: display,
|
|
};
|
|
})
|
|
.filter(Boolean) as Array<{ label: string; value: string }>;
|
|
}, [technical]);
|
|
|
|
async function handleAddToCart() {
|
|
await addItem({
|
|
storeItemVariantId: variant.id,
|
|
name,
|
|
price,
|
|
quantity,
|
|
image: activeImage || variant.productImage,
|
|
slug: product.slug || undefined,
|
|
});
|
|
}
|
|
|
|
async function handleCommentSubmit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
if (!isAuthenticated || !user) return;
|
|
const authorName =
|
|
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() || "کاربر";
|
|
setSubmitting(true);
|
|
try {
|
|
await submitComment(
|
|
{
|
|
entityType: "product",
|
|
entityId: product.id,
|
|
authorName,
|
|
authorEmail: user.email || undefined,
|
|
text: commentText.trim(),
|
|
},
|
|
getAccessToken(),
|
|
);
|
|
setComments((prev) => [
|
|
{
|
|
id: `local-${Date.now()}`,
|
|
authorName,
|
|
text: commentText.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
...prev,
|
|
]);
|
|
setCommentText("");
|
|
showToast("نظر شما ارسال شد و پس از تایید نمایش داده میشود");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "ارسال نظر ناموفق بود");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="store-detail">
|
|
<div className="store-detail-gallery">
|
|
<button
|
|
type="button"
|
|
className="store-main-image zoomable"
|
|
onClick={() => activeImage && setLightboxOpen(true)}
|
|
>
|
|
{activeImage ? (
|
|
<Image src={activeImage} alt={name} width={900} height={900} priority />
|
|
) : (
|
|
<div className="store-card-fallback large" />
|
|
)}
|
|
</button>
|
|
{images.length > 1 ? (
|
|
<div className="store-thumbs">
|
|
{images.map((image) => (
|
|
<button
|
|
key={image}
|
|
type="button"
|
|
className={image === activeImage ? "is-active" : ""}
|
|
onClick={() => setActiveImage(image)}
|
|
>
|
|
<Image src={image} alt={name} width={120} height={120} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="store-detail-info">
|
|
<p className="eyebrow">{variant.label || product.categoryNameFa || "Store Item"}</p>
|
|
<h1>{name}</h1>
|
|
<strong className="store-detail-price ltr-number" dir="ltr" lang="en">
|
|
{formatPrice(price)}
|
|
</strong>
|
|
{variant.selections?.length ? (
|
|
<ul className="store-selections">
|
|
{variant.selections.map((selection, index) => (
|
|
<li key={`${selection.optionId}-${index}`}>
|
|
{selection.variationName}: {selection.value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
<div className="store-buy-row">
|
|
<label>
|
|
تعداد
|
|
<input
|
|
className="ltr-number"
|
|
dir="ltr"
|
|
lang="en"
|
|
type="number"
|
|
min={1}
|
|
value={quantity}
|
|
onChange={(event) =>
|
|
setQuantity(Math.max(1, Number(event.target.value) || 1))
|
|
}
|
|
/>
|
|
</label>
|
|
<button type="button" className="primary-btn" onClick={() => void handleAddToCart()}>
|
|
افزودن به سبد خرید
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="store-detail-tabs">
|
|
<div className="tab-list" role="tablist">
|
|
{(
|
|
[
|
|
["description", "توضیحات"],
|
|
["technical", "اطلاعات فنی"],
|
|
["comments", "نظرات"],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === key}
|
|
className={tab === key ? "is-active" : ""}
|
|
onClick={() => setTab(key)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="tab-panel">
|
|
{tab === "description" ? (
|
|
product.descriptionHtml ? (
|
|
<div
|
|
className="rich-html"
|
|
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
|
/>
|
|
) : (
|
|
<p className="empty-note">توضیحاتی ثبت نشده است.</p>
|
|
)
|
|
) : null}
|
|
{tab === "technical" ? (
|
|
techRows.length ? (
|
|
<table className="tech-table">
|
|
<tbody>
|
|
{techRows.map((row) => (
|
|
<tr key={row.label}>
|
|
<th>{row.label}</th>
|
|
<td>{row.value}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<p className="empty-note">اطلاعات فنی موجود نیست.</p>
|
|
)
|
|
) : null}
|
|
{tab === "comments" ? (
|
|
<div className="comments-block">
|
|
{isAuthenticated ? (
|
|
<form className="form" onSubmit={(event) => void handleCommentSubmit(event)}>
|
|
<textarea
|
|
required
|
|
value={commentText}
|
|
onChange={(event) => setCommentText(event.target.value)}
|
|
placeholder="نظر خود را بنویسید"
|
|
/>
|
|
<button className="primary-btn" type="submit" disabled={submitting}>
|
|
ارسال نظر
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<div className="login-box">
|
|
<p>برای ثبت نظر وارد حساب کاربری شوید.</p>
|
|
<a className="primary-btn" href={customerLoginUrl(`/store/${variant.id}`)}>
|
|
ورود / ثبتنام
|
|
</a>
|
|
</div>
|
|
)}
|
|
<div className="comments-list">
|
|
{comments.length === 0 ? (
|
|
<p className="empty-note">هنوز نظری ثبت نشده است.</p>
|
|
) : (
|
|
comments.map((comment) => (
|
|
<article key={comment.id}>
|
|
<h3>{comment.authorName || "کاربر"}</h3>
|
|
<p>{comment.text}</p>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{similar.length > 0 ? (
|
|
<section className="section similar-section">
|
|
<h2>محصولات مشابه</h2>
|
|
<div className="store-grid">
|
|
{similar.map((item) => {
|
|
const itemName = storeItemName(item);
|
|
return (
|
|
<Link key={item.id} href={storeItemHref(item.id, itemName)} className="store-card">
|
|
<div className="store-card-media">
|
|
{item.productImage ? (
|
|
<Image src={item.productImage} alt={itemName} width={320} height={320} />
|
|
) : (
|
|
<div className="store-card-fallback" />
|
|
)}
|
|
</div>
|
|
<div className="store-card-body">
|
|
<h3>{itemName}</h3>
|
|
<strong className="ltr-number" dir="ltr" lang="en">
|
|
{formatPrice(storeItemPrice(item))}
|
|
</strong>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{lightboxOpen && activeImage ? (
|
|
<div className="lightbox" role="dialog" aria-modal="true">
|
|
<button
|
|
type="button"
|
|
className="lightbox-backdrop"
|
|
aria-label="بستن"
|
|
onClick={() => setLightboxOpen(false)}
|
|
/>
|
|
<div className="lightbox-content">
|
|
<Image src={activeImage} alt={name} width={1200} height={1200} />
|
|
<button type="button" className="icon-btn" onClick={() => setLightboxOpen(false)}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|