Support duplicatable list fields in form builder and value UIs, and guide fill prompts to put leftover specs in سایر ویژگیها. Co-authored-by: Cursor <cursoragent@cursor.com>
480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { formatCommentDate } from '../data/productComments'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import {
|
|
deleteComment,
|
|
listProductComments,
|
|
updateCommentApproval,
|
|
} from '../services/commentService'
|
|
import {
|
|
deleteExpertReview,
|
|
formatReviewDate,
|
|
listProductExpertReviews,
|
|
updateExpertReviewApproval,
|
|
} from '../services/expertReviewService'
|
|
import { getProductTechnicalInfo } from '../services/productTechnicalInfoService'
|
|
import type { ProductComment } from '../types/comment'
|
|
import type { ProductExpertReview } from '../types/expertReview'
|
|
import type { ProductTechnicalValue } from '../types/technicalForm'
|
|
import styles from './ProductDetailsTabs.module.css'
|
|
|
|
type DetailTab = 'technical' | 'comments' | 'reviews'
|
|
type TechnicalEmptyReason = 'no-category' | 'no-form' | 'no-data'
|
|
|
|
interface ProductDetailsTabsProps {
|
|
productId: string
|
|
commentCount: number
|
|
}
|
|
|
|
function formatTechnicalValue(item: ProductTechnicalValue): string {
|
|
if (item.type === 'multi_select') {
|
|
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
|
|
return labels.length ? labels.join(', ') : '—'
|
|
}
|
|
if (item.type === 'multi_value') {
|
|
const items = Array.isArray(item.value)
|
|
? item.value.map((entry) => String(entry).trim()).filter(Boolean)
|
|
: []
|
|
return items.length ? items.join('، ') : '—'
|
|
}
|
|
if (item.type === 'select') {
|
|
return item.optionLabel ?? (item.value ? String(item.value) : '—')
|
|
}
|
|
return item.value ? String(item.value) : '—'
|
|
}
|
|
|
|
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
|
const [activeTab, setActiveTab] = useState<DetailTab>('technical')
|
|
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
|
|
const [technicalEmptyReason, setTechnicalEmptyReason] = useState<TechnicalEmptyReason | null>(
|
|
null,
|
|
)
|
|
const [technicalLoading, setTechnicalLoading] = useState(false)
|
|
const [technicalError, setTechnicalError] = useState('')
|
|
const [comments, setComments] = useState<ProductComment[]>([])
|
|
const [commentsLoading, setCommentsLoading] = useState(false)
|
|
const [commentsError, setCommentsError] = useState('')
|
|
const [reviews, setReviews] = useState<ProductExpertReview[]>([])
|
|
const [reviewsLoading, setReviewsLoading] = useState(false)
|
|
const [reviewsError, setReviewsError] = useState('')
|
|
const [actionId, setActionId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'technical') return
|
|
|
|
const controller = new AbortController()
|
|
void loadTechnicalInfo(controller.signal)
|
|
return () => controller.abort()
|
|
}, [activeTab, productId])
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'comments') return
|
|
|
|
const controller = new AbortController()
|
|
void loadComments(controller.signal)
|
|
return () => controller.abort()
|
|
}, [activeTab, productId])
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'reviews') return
|
|
|
|
const controller = new AbortController()
|
|
void loadReviews(controller.signal)
|
|
return () => controller.abort()
|
|
}, [activeTab, productId])
|
|
|
|
async function loadComments(signal?: AbortSignal) {
|
|
setCommentsLoading(true)
|
|
setCommentsError('')
|
|
|
|
try {
|
|
const data = await listProductComments(productId, 1, 100, signal)
|
|
setComments(data.items)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setCommentsError(err.message)
|
|
} else {
|
|
setCommentsError(t('products.details.comments.errorLoad'))
|
|
}
|
|
} finally {
|
|
setCommentsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function loadReviews(signal?: AbortSignal) {
|
|
setReviewsLoading(true)
|
|
setReviewsError('')
|
|
|
|
try {
|
|
const data = await listProductExpertReviews(productId, 1, 100, signal)
|
|
setReviews(data.items)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setReviewsError(err.message)
|
|
} else {
|
|
setReviewsError(t('products.details.reviews.errorLoad'))
|
|
}
|
|
} finally {
|
|
setReviewsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function loadTechnicalInfo(signal?: AbortSignal) {
|
|
setTechnicalLoading(true)
|
|
setTechnicalError('')
|
|
setTechnicalEmptyReason(null)
|
|
|
|
try {
|
|
const data = await getProductTechnicalInfo(productId, signal)
|
|
setTechnicalValues(data.values)
|
|
if (data.message === 'Product has no category assigned') {
|
|
setTechnicalEmptyReason('no-category')
|
|
} else if (!data.form) {
|
|
setTechnicalEmptyReason('no-form')
|
|
} else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) {
|
|
setTechnicalEmptyReason('no-data')
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setTechnicalError(err.message)
|
|
} else {
|
|
setTechnicalError(t('products.details.technical.errorLoad'))
|
|
}
|
|
} finally {
|
|
setTechnicalLoading(false)
|
|
}
|
|
}
|
|
|
|
async function toggleCommentApproval(comment: ProductComment) {
|
|
setActionId(comment.id)
|
|
setCommentsError('')
|
|
|
|
try {
|
|
const updated = await updateCommentApproval(comment.id, !comment.approved)
|
|
setComments((prev) => prev.map((item) => (item.id === comment.id ? updated : item)))
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setCommentsError(err.message)
|
|
} else {
|
|
setCommentsError(t('products.details.comments.errorApprove'))
|
|
}
|
|
} finally {
|
|
setActionId(null)
|
|
}
|
|
}
|
|
|
|
async function removeComment(commentId: string) {
|
|
setActionId(commentId)
|
|
setCommentsError('')
|
|
|
|
try {
|
|
await deleteComment(commentId)
|
|
setComments((prev) => prev.filter((item) => item.id !== commentId))
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setCommentsError(err.message)
|
|
} else {
|
|
setCommentsError(t('products.details.comments.errorDelete'))
|
|
}
|
|
} finally {
|
|
setActionId(null)
|
|
}
|
|
}
|
|
|
|
async function toggleReviewApproval(review: ProductExpertReview) {
|
|
setActionId(review.id)
|
|
setReviewsError('')
|
|
|
|
try {
|
|
const updated = await updateExpertReviewApproval(review.id, !review.approved)
|
|
setReviews((prev) => prev.map((item) => (item.id === review.id ? updated : item)))
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setReviewsError(err.message)
|
|
} else {
|
|
setReviewsError(t('products.details.reviews.errorApprove'))
|
|
}
|
|
} finally {
|
|
setActionId(null)
|
|
}
|
|
}
|
|
|
|
async function removeReview(reviewId: string) {
|
|
setActionId(reviewId)
|
|
setReviewsError('')
|
|
|
|
try {
|
|
await deleteExpertReview(reviewId)
|
|
setReviews((prev) => prev.filter((item) => item.id !== reviewId))
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setReviewsError(err.message)
|
|
} else {
|
|
setReviewsError(t('products.details.reviews.errorDelete'))
|
|
}
|
|
} finally {
|
|
setActionId(null)
|
|
}
|
|
}
|
|
|
|
const displayedCommentCount = comments.length > 0 ? comments.length : commentCount
|
|
const technicalEmptyMessage =
|
|
technicalEmptyReason === 'no-category'
|
|
? t('products.details.technical.noCategory')
|
|
: technicalEmptyReason === 'no-form'
|
|
? t('products.details.technical.noForm')
|
|
: technicalEmptyReason === 'no-data'
|
|
? t('products.details.technical.noData')
|
|
: ''
|
|
|
|
return (
|
|
<section className={styles.tabsSection}>
|
|
<div className={styles.tabList} role="tablist" aria-label={t('products.details.tabsAria')}>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'technical'}
|
|
className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab('technical')}
|
|
>
|
|
{t('products.details.tab.technical')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'comments'}
|
|
className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab('comments')}
|
|
>
|
|
{t('products.details.tab.comments')}
|
|
{displayedCommentCount > 0 && (
|
|
<span className={styles.tabBadge}>{displayedCommentCount}</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'reviews'}
|
|
className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab('reviews')}
|
|
>
|
|
{t('products.details.tab.reviews')}
|
|
{reviews.length > 0 && (
|
|
<span className={styles.tabBadge}>{reviews.length}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.tabPanel} role="tabpanel">
|
|
{activeTab === 'technical' && (
|
|
<>
|
|
{technicalLoading ? (
|
|
<p className={styles.emptyText}>{t('products.details.technical.loading')}</p>
|
|
) : technicalError ? (
|
|
<p className={styles.errorText}>{technicalError}</p>
|
|
) : technicalEmptyMessage && technicalValues.length === 0 ? (
|
|
<p className={styles.emptyText}>{technicalEmptyMessage}</p>
|
|
) : technicalValues.length === 0 ? (
|
|
<p className={styles.emptyText}>{t('products.details.technical.empty')}</p>
|
|
) : (
|
|
<dl className={styles.techList}>
|
|
{technicalValues.map((item) => (
|
|
<div key={item.fieldKey} className={styles.techRow}>
|
|
<dt>{item.fieldLabel}</dt>
|
|
<dd>{formatTechnicalValue(item)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
{technicalEmptyMessage && technicalValues.length > 0 && (
|
|
<p className={styles.hintText}>{technicalEmptyMessage}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'comments' && (
|
|
<>
|
|
{commentsError && <p className={styles.errorText}>{commentsError}</p>}
|
|
{commentsLoading ? (
|
|
<p className={styles.emptyText}>{t('products.details.comments.loading')}</p>
|
|
) : comments.length === 0 ? (
|
|
<p className={styles.emptyText}>{t('products.details.comments.empty')}</p>
|
|
) : (
|
|
<div className={styles.commentList}>
|
|
{comments.map((comment) => (
|
|
<article
|
|
key={comment.id}
|
|
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
|
|
>
|
|
<div className={styles.commentHeader}>
|
|
<div>
|
|
<div className={styles.author}>{comment.author}</div>
|
|
<div className={styles.meta}>
|
|
{formatCommentDate(comment.createdAt, dateLocale)}
|
|
</div>
|
|
</div>
|
|
<span className={styles.likes}>
|
|
<ThumbsUp size={13} />
|
|
{comment.likesCount}
|
|
</span>
|
|
</div>
|
|
<p className={styles.commentText}>{comment.text}</p>
|
|
{!comment.approved && (
|
|
<span className={styles.pendingBadge}>{t('products.details.pending')}</span>
|
|
)}
|
|
<div className={styles.itemActions}>
|
|
{comment.approved ? (
|
|
<button
|
|
type="button"
|
|
className={styles.rejectBtn}
|
|
disabled={actionId === comment.id}
|
|
onClick={() => void toggleCommentApproval(comment)}
|
|
>
|
|
<XCircle size={14} />
|
|
{t('products.details.reject')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={styles.approveBtn}
|
|
disabled={actionId === comment.id}
|
|
onClick={() => void toggleCommentApproval(comment)}
|
|
>
|
|
<Check size={14} />
|
|
{t('products.details.approve')}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.removeBtn}
|
|
disabled={actionId === comment.id}
|
|
onClick={() => void removeComment(comment.id)}
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('products.details.remove')}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'reviews' && (
|
|
<>
|
|
{reviewsError && <p className={styles.errorText}>{reviewsError}</p>}
|
|
{reviewsLoading ? (
|
|
<p className={styles.emptyText}>{t('products.details.reviews.loading')}</p>
|
|
) : reviews.length === 0 ? (
|
|
<p className={styles.emptyText}>{t('products.details.reviews.empty')}</p>
|
|
) : (
|
|
<div className={styles.reviewList}>
|
|
{reviews.map((review) => (
|
|
<article
|
|
key={review.id}
|
|
className={`${styles.review} ${review.approved ? styles.reviewApproved : ''}`}
|
|
>
|
|
<div className={styles.reviewHeader}>
|
|
<div>
|
|
<div className={styles.reviewTitle}>{review.authorName}</div>
|
|
<div className={styles.meta}>
|
|
{formatReviewDate(review.createdAt, dateLocale)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={styles.rating}
|
|
aria-label={t('products.details.ratingAria', { rate: review.rate })}
|
|
>
|
|
<Star size={14} fill="currentColor" />
|
|
{review.rate}/10
|
|
</div>
|
|
</div>
|
|
|
|
{(review.positivePoints.length > 0 || review.negativePoints.length > 0) && (
|
|
<div className={styles.pointsGrid}>
|
|
{review.positivePoints.length > 0 && (
|
|
<div className={styles.pointsBlock}>
|
|
<h4 className={styles.pointsHeading}>
|
|
{t('products.details.positivePoints')}
|
|
</h4>
|
|
<ul className={styles.pointsList}>
|
|
{review.positivePoints.map((point) => (
|
|
<li key={point}>{point}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{review.negativePoints.length > 0 && (
|
|
<div className={styles.pointsBlock}>
|
|
<h4 className={styles.pointsHeading}>
|
|
{t('products.details.negativePoints')}
|
|
</h4>
|
|
<ul className={`${styles.pointsList} ${styles.pointsListNegative}`}>
|
|
{review.negativePoints.map((point) => (
|
|
<li key={point}>{point}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<p className={styles.reviewSummary}>{review.text}</p>
|
|
|
|
{!review.approved && (
|
|
<span className={styles.pendingBadge}>{t('products.details.pending')}</span>
|
|
)}
|
|
|
|
<div className={styles.itemActions}>
|
|
{review.approved ? (
|
|
<button
|
|
type="button"
|
|
className={styles.rejectBtn}
|
|
disabled={actionId === review.id}
|
|
onClick={() => void toggleReviewApproval(review)}
|
|
>
|
|
<XCircle size={14} />
|
|
{t('products.details.reject')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={styles.approveBtn}
|
|
disabled={actionId === review.id}
|
|
onClick={() => void toggleReviewApproval(review)}
|
|
>
|
|
<Check size={14} />
|
|
{t('products.details.approve')}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.removeBtn}
|
|
disabled={actionId === review.id}
|
|
onClick={() => void removeReview(review.id)}
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('products.details.remove')}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|