Add customer/manager access UI and tighten customers filters.

Business and super-admin can set Customer vs Manager roles (Admin only for super-admin), and the customers page drops clutter while filtering by all/customers/managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 21:31:28 +03:30
co-authored by Cursor
parent 404c008ced
commit f622e6d605
28 changed files with 1280 additions and 241 deletions
@@ -2,6 +2,7 @@ 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,
@@ -21,6 +22,7 @@ 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
@@ -39,11 +41,14 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
}
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 [technicalMessage, setTechnicalMessage] = useState('')
const [technicalEmptyReason, setTechnicalEmptyReason] = useState<TechnicalEmptyReason | null>(
null,
)
const [technicalLoading, setTechnicalLoading] = useState(false)
const [technicalError, setTechnicalError] = useState('')
const [comments, setComments] = useState<ProductComment[]>([])
@@ -90,7 +95,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to load comments.')
setCommentsError(t('products.details.comments.errorLoad'))
}
} finally {
setCommentsLoading(false)
@@ -109,7 +114,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to load expert reviews.')
setReviewsError(t('products.details.reviews.errorLoad'))
}
} finally {
setReviewsLoading(false)
@@ -119,24 +124,24 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
async function loadTechnicalInfo(signal?: AbortSignal) {
setTechnicalLoading(true)
setTechnicalError('')
setTechnicalMessage('')
setTechnicalEmptyReason(null)
try {
const data = await getProductTechnicalInfo(productId, signal)
setTechnicalValues(data.values)
if (data.message) {
setTechnicalMessage(data.message)
if (data.message === 'Product has no category assigned') {
setTechnicalEmptyReason('no-category')
} else if (!data.form) {
setTechnicalMessage('No technical form is defined for this product category.')
setTechnicalEmptyReason('no-form')
} else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) {
setTechnicalMessage('No technical data has been added for this product yet.')
setTechnicalEmptyReason('no-data')
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setTechnicalError(err.message)
} else {
setTechnicalError('Unable to load technical info.')
setTechnicalError(t('products.details.technical.errorLoad'))
}
} finally {
setTechnicalLoading(false)
@@ -154,7 +159,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to update comment approval.')
setCommentsError(t('products.details.comments.errorApprove'))
}
} finally {
setActionId(null)
@@ -172,7 +177,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to delete comment.')
setCommentsError(t('products.details.comments.errorDelete'))
}
} finally {
setActionId(null)
@@ -190,7 +195,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to update expert review approval.')
setReviewsError(t('products.details.reviews.errorApprove'))
}
} finally {
setActionId(null)
@@ -208,7 +213,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to delete expert review.')
setReviewsError(t('products.details.reviews.errorDelete'))
}
} finally {
setActionId(null)
@@ -216,10 +221,18 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
}
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="Product details">
<div className={styles.tabList} role="tablist" aria-label={t('products.details.tabsAria')}>
<button
type="button"
role="tab"
@@ -227,7 +240,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('technical')}
>
Technical Info
{t('products.details.tab.technical')}
</button>
<button
type="button"
@@ -236,7 +249,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('comments')}
>
Comments
{t('products.details.tab.comments')}
{displayedCommentCount > 0 && (
<span className={styles.tabBadge}>{displayedCommentCount}</span>
)}
@@ -248,7 +261,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('reviews')}
>
Expert Reviews
{t('products.details.tab.reviews')}
{reviews.length > 0 && (
<span className={styles.tabBadge}>{reviews.length}</span>
)}
@@ -259,13 +272,13 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
{activeTab === 'technical' && (
<>
{technicalLoading ? (
<p className={styles.emptyText}>Loading technical info...</p>
<p className={styles.emptyText}>{t('products.details.technical.loading')}</p>
) : technicalError ? (
<p className={styles.errorText}>{technicalError}</p>
) : technicalMessage && technicalValues.length === 0 ? (
<p className={styles.emptyText}>{technicalMessage}</p>
) : technicalEmptyMessage && technicalValues.length === 0 ? (
<p className={styles.emptyText}>{technicalEmptyMessage}</p>
) : technicalValues.length === 0 ? (
<p className={styles.emptyText}>No technical data available.</p>
<p className={styles.emptyText}>{t('products.details.technical.empty')}</p>
) : (
<dl className={styles.techList}>
{technicalValues.map((item) => (
@@ -276,8 +289,8 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
))}
</dl>
)}
{technicalMessage && technicalValues.length > 0 && (
<p className={styles.hintText}>{technicalMessage}</p>
{technicalEmptyMessage && technicalValues.length > 0 && (
<p className={styles.hintText}>{technicalEmptyMessage}</p>
)}
</>
)}
@@ -286,9 +299,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<>
{commentsError && <p className={styles.errorText}>{commentsError}</p>}
{commentsLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
<p className={styles.emptyText}>{t('products.details.comments.loading')}</p>
) : comments.length === 0 ? (
<p className={styles.emptyText}>No comments yet for this product.</p>
<p className={styles.emptyText}>{t('products.details.comments.empty')}</p>
) : (
<div className={styles.commentList}>
{comments.map((comment) => (
@@ -310,7 +323,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
</div>
<p className={styles.commentText}>{comment.text}</p>
{!comment.approved && (
<span className={styles.pendingBadge}>Pending approval</span>
<span className={styles.pendingBadge}>{t('products.details.pending')}</span>
)}
<div className={styles.itemActions}>
{comment.approved ? (
@@ -321,7 +334,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleCommentApproval(comment)}
>
<XCircle size={14} />
Reject
{t('products.details.reject')}
</button>
) : (
<button
@@ -331,7 +344,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleCommentApproval(comment)}
>
<Check size={14} />
Approve
{t('products.details.approve')}
</button>
)}
<button
@@ -341,7 +354,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
{t('products.details.remove')}
</button>
</div>
</article>
@@ -355,9 +368,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<>
{reviewsError && <p className={styles.errorText}>{reviewsError}</p>}
{reviewsLoading ? (
<p className={styles.emptyText}>Loading expert reviews...</p>
<p className={styles.emptyText}>{t('products.details.reviews.loading')}</p>
) : reviews.length === 0 ? (
<p className={styles.emptyText}>No expert reviews yet.</p>
<p className={styles.emptyText}>{t('products.details.reviews.empty')}</p>
) : (
<div className={styles.reviewList}>
{reviews.map((review) => (
@@ -372,7 +385,10 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
{formatReviewDate(review.createdAt, dateLocale)}
</div>
</div>
<div className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}>
<div
className={styles.rating}
aria-label={t('products.details.ratingAria', { rate: review.rate })}
>
<Star size={14} fill="currentColor" />
{review.rate}/10
</div>
@@ -382,7 +398,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<div className={styles.pointsGrid}>
{review.positivePoints.length > 0 && (
<div className={styles.pointsBlock}>
<h4 className={styles.pointsHeading}>Positive points</h4>
<h4 className={styles.pointsHeading}>
{t('products.details.positivePoints')}
</h4>
<ul className={styles.pointsList}>
{review.positivePoints.map((point) => (
<li key={point}>{point}</li>
@@ -392,7 +410,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
)}
{review.negativePoints.length > 0 && (
<div className={styles.pointsBlock}>
<h4 className={styles.pointsHeading}>Negative points</h4>
<h4 className={styles.pointsHeading}>
{t('products.details.negativePoints')}
</h4>
<ul className={`${styles.pointsList} ${styles.pointsListNegative}`}>
{review.negativePoints.map((point) => (
<li key={point}>{point}</li>
@@ -406,7 +426,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<p className={styles.reviewSummary}>{review.text}</p>
{!review.approved && (
<span className={styles.pendingBadge}>Pending approval</span>
<span className={styles.pendingBadge}>{t('products.details.pending')}</span>
)}
<div className={styles.itemActions}>
@@ -418,7 +438,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleReviewApproval(review)}
>
<XCircle size={14} />
Reject
{t('products.details.reject')}
</button>
) : (
<button
@@ -428,7 +448,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleReviewApproval(review)}
>
<Check size={14} />
Approve
{t('products.details.approve')}
</button>
)}
<button
@@ -438,7 +458,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void removeReview(review.id)}
>
<Trash2 size={14} />
Remove
{t('products.details.remove')}
</button>
</div>
</article>