Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
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'
|
||||
|
||||
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 === 'select') {
|
||||
return item.optionLabel ?? (item.value ? String(item.value) : '—')
|
||||
}
|
||||
return item.value ? String(item.value) : '—'
|
||||
}
|
||||
|
||||
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('technical')
|
||||
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
|
||||
const [technicalMessage, setTechnicalMessage] = useState('')
|
||||
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('Unable to load comments.')
|
||||
}
|
||||
} 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('Unable to load expert reviews.')
|
||||
}
|
||||
} finally {
|
||||
setReviewsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTechnicalInfo(signal?: AbortSignal) {
|
||||
setTechnicalLoading(true)
|
||||
setTechnicalError('')
|
||||
setTechnicalMessage('')
|
||||
|
||||
try {
|
||||
const data = await getProductTechnicalInfo(productId, signal)
|
||||
setTechnicalValues(data.values)
|
||||
if (data.message) {
|
||||
setTechnicalMessage(data.message)
|
||||
} else if (!data.form) {
|
||||
setTechnicalMessage('No technical form is defined for this product category.')
|
||||
} else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) {
|
||||
setTechnicalMessage('No technical data has been added for this product yet.')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setTechnicalError(err.message)
|
||||
} else {
|
||||
setTechnicalError('Unable to load technical info.')
|
||||
}
|
||||
} 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('Unable to update comment approval.')
|
||||
}
|
||||
} 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('Unable to delete comment.')
|
||||
}
|
||||
} 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('Unable to update expert review approval.')
|
||||
}
|
||||
} 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('Unable to delete expert review.')
|
||||
}
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const displayedCommentCount = comments.length > 0 ? comments.length : commentCount
|
||||
|
||||
return (
|
||||
<section className={styles.tabsSection}>
|
||||
<div className={styles.tabList} role="tablist" aria-label="Product details">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'technical'}
|
||||
className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('technical')}
|
||||
>
|
||||
Technical Info
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'comments'}
|
||||
className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('comments')}
|
||||
>
|
||||
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')}
|
||||
>
|
||||
Expert 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}>Loading technical info...</p>
|
||||
) : technicalError ? (
|
||||
<p className={styles.errorText}>{technicalError}</p>
|
||||
) : technicalMessage && technicalValues.length === 0 ? (
|
||||
<p className={styles.emptyText}>{technicalMessage}</p>
|
||||
) : technicalValues.length === 0 ? (
|
||||
<p className={styles.emptyText}>No technical data available.</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>
|
||||
)}
|
||||
{technicalMessage && technicalValues.length > 0 && (
|
||||
<p className={styles.hintText}>{technicalMessage}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'comments' && (
|
||||
<>
|
||||
{commentsError && <p className={styles.errorText}>{commentsError}</p>}
|
||||
{commentsLoading ? (
|
||||
<p className={styles.emptyText}>Loading comments...</p>
|
||||
) : comments.length === 0 ? (
|
||||
<p className={styles.emptyText}>No comments yet for this product.</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)}</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}>Pending approval</span>
|
||||
)}
|
||||
<div className={styles.itemActions}>
|
||||
{comment.approved ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rejectBtn}
|
||||
disabled={actionId === comment.id}
|
||||
onClick={() => void toggleCommentApproval(comment)}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
Reject
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.approveBtn}
|
||||
disabled={actionId === comment.id}
|
||||
onClick={() => void toggleCommentApproval(comment)}
|
||||
>
|
||||
<Check size={14} />
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={actionId === comment.id}
|
||||
onClick={() => void removeComment(comment.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'reviews' && (
|
||||
<>
|
||||
{reviewsError && <p className={styles.errorText}>{reviewsError}</p>}
|
||||
{reviewsLoading ? (
|
||||
<p className={styles.emptyText}>Loading expert reviews...</p>
|
||||
) : reviews.length === 0 ? (
|
||||
<p className={styles.emptyText}>No expert reviews yet.</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)}</div>
|
||||
</div>
|
||||
<div className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}>
|
||||
<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}>Positive points</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}>Negative points</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}>Pending approval</span>
|
||||
)}
|
||||
|
||||
<div className={styles.itemActions}>
|
||||
{review.approved ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rejectBtn}
|
||||
disabled={actionId === review.id}
|
||||
onClick={() => void toggleReviewApproval(review)}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
Reject
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.approveBtn}
|
||||
disabled={actionId === review.id}
|
||||
onClick={() => void toggleReviewApproval(review)}
|
||||
>
|
||||
<Check size={14} />
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={actionId === review.id}
|
||||
onClick={() => void removeReview(review.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user