Files
hoomat-store/app/components/store-detail-client.tsx
T

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