Initial commit: Hoomat storefront with Meshkee API and static-image slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user