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:
co-authored by
Cursor
parent
404c008ced
commit
f622e6d605
@@ -0,0 +1,56 @@
|
||||
.roleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.roleOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.roleOption:has(input:checked) {
|
||||
border-color: rgba(var(--primary-rgb) / 0.35);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.roleOptionLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.roleHint {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.roleListNested {
|
||||
margin-top: 4px;
|
||||
margin-inline-start: 12px;
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.2);
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.18);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
assignBusinessAccess,
|
||||
type BusinessAccess,
|
||||
} from '../services/teamService'
|
||||
import type { BusinessCustomerListItem } from '../services/customerService'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
import styles from './ChangeUserAccessModal.module.css'
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
const ALL_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const
|
||||
const BUSINESS_TEAM_ROLES = ['editor', 'viewer'] as const
|
||||
|
||||
export interface ChangeUserAccessModalProps {
|
||||
open: boolean
|
||||
user: BusinessCustomerListItem | null
|
||||
/** When true, Admin team role is offered (super-admin only). */
|
||||
canAssignAdmin: boolean
|
||||
onClose: () => void
|
||||
onSaved: (user: BusinessCustomerListItem) => void
|
||||
}
|
||||
|
||||
function displayName(user: BusinessCustomerListItem) {
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
export function ChangeUserAccessModal({
|
||||
open,
|
||||
user,
|
||||
canAssignAdmin,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: ChangeUserAccessModalProps) {
|
||||
const t = useT()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [access, setAccess] = useState<BusinessAccess>('customer')
|
||||
const [teamRole, setTeamRole] = useState('editor')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const teamRoles = useMemo(
|
||||
() => (canAssignAdmin ? [...ALL_TEAM_ROLES] : [...BUSINESS_TEAM_ROLES]),
|
||||
[canAssignAdmin],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner
|
||||
setAccess(isStaff ? 'staff' : 'customer')
|
||||
const current = user.teamRole
|
||||
if (isStaff && current && teamRoles.includes(current as (typeof ALL_TEAM_ROLES)[number])) {
|
||||
setTeamRole(current)
|
||||
} else {
|
||||
setTeamRole(teamRoles[0] ?? 'editor')
|
||||
}
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, user, mounted, teamRoles])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
if (!mounted || !user) return null
|
||||
|
||||
const canSave =
|
||||
!user.isBusinessOwner && (access === 'customer' || Boolean(teamRole))
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!user || !canSave) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await assignBusinessAccess({
|
||||
userId: user.id,
|
||||
access,
|
||||
roleSlug: access === 'staff' ? teamRole : undefined,
|
||||
})
|
||||
|
||||
onSaved({
|
||||
...user,
|
||||
businessMemberId:
|
||||
access === 'staff'
|
||||
? result.member?.id != null
|
||||
? String(result.member.id)
|
||||
: user.businessMemberId
|
||||
: null,
|
||||
isBusinessOwner: false,
|
||||
teamRole: access === 'staff' ? teamRole : null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('customers.access.error'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="change-access-title"
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="change-access-title" className={modalStyles.title}>
|
||||
{t('customers.access.title')}
|
||||
</h3>
|
||||
<p className={modalStyles.subtitle}>
|
||||
{t('customers.access.subtitle', { name: displayName(user) })}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.body}>
|
||||
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
|
||||
|
||||
<div className={styles.roleList}>
|
||||
<label className={styles.roleOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="business-access"
|
||||
value="customer"
|
||||
checked={access === 'customer'}
|
||||
onChange={() => setAccess('customer')}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className={styles.roleOptionLabel}>{t('customers.access.customer')}</span>
|
||||
</label>
|
||||
<label className={styles.roleOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="business-access"
|
||||
value="staff"
|
||||
checked={access === 'staff'}
|
||||
onChange={() => {
|
||||
setAccess('staff')
|
||||
if (!teamRoles.includes(teamRole as (typeof ALL_TEAM_ROLES)[number])) {
|
||||
setTeamRole(teamRoles[0] ?? 'editor')
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className={styles.roleOptionLabel}>{t('customers.access.manager')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{access === 'staff' ? (
|
||||
<>
|
||||
<p className={styles.roleHint}>
|
||||
{canAssignAdmin
|
||||
? t('customers.access.hintSuperAdmin')
|
||||
: t('customers.access.hintBusiness')}
|
||||
</p>
|
||||
<div className={`${styles.roleList} ${styles.roleListNested}`}>
|
||||
{teamRoles.map((slug) => (
|
||||
<label key={slug} className={styles.roleOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="team-role"
|
||||
value={slug}
|
||||
checked={teamRole === slug}
|
||||
onChange={() => setTeamRole(slug)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className={styles.roleOptionLabel}>
|
||||
{t(`customers.access.role.${slug}`)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('customers.access.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.submitBtn}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting || !canSave}
|
||||
>
|
||||
{t('customers.access.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, #ffffff 42%, transparent);
|
||||
backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px));
|
||||
-webkit-backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px));
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
/* Keep sticky header on its own compositor layer so backdrop blur samples content beneath */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.left {
|
||||
|
||||
@@ -90,6 +90,19 @@
|
||||
|
||||
.field select {
|
||||
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: rgba(255, 255, 255, 0.65);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--select-arrow-offset) center;
|
||||
background-size: var(--select-arrow-size);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .field select {
|
||||
padding: var(--field-padding-y) var(--field-padding-x) var(--field-padding-y) var(--select-padding-end);
|
||||
background-position: left var(--select-arrow-offset) center;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -99,6 +99,14 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0.5em 0;
|
||||
margin-inline-start: 1.5em;
|
||||
margin-inline-end: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { Bold, Italic, Underline, List, ListOrdered, ImagePlus } from 'lucide-react'
|
||||
import {
|
||||
Bold,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Italic,
|
||||
Underline,
|
||||
List,
|
||||
ListOrdered,
|
||||
ImagePlus,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { uploadMediaFiles } from '../services/mediaService'
|
||||
import styles from './RichTextEditor.module.css'
|
||||
|
||||
const INDENT_STEP_PX = 24
|
||||
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
@@ -132,6 +144,51 @@ export function RichTextEditor({
|
||||
syncChange()
|
||||
}
|
||||
|
||||
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
|
||||
let node: Node | null = selection.anchorNode
|
||||
while (node && node !== editor) {
|
||||
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
|
||||
return node
|
||||
}
|
||||
node = node.parentNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function changeIndent(direction: 1 | -1) {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
editor.focus()
|
||||
const block = findIndentBlock(editor)
|
||||
if (!block) {
|
||||
document.execCommand(direction > 0 ? 'indent' : 'outdent')
|
||||
syncChange()
|
||||
return
|
||||
}
|
||||
|
||||
const current =
|
||||
Number.parseFloat(block.style.paddingInlineStart) ||
|
||||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
|
||||
0
|
||||
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
|
||||
if (next === 0) {
|
||||
block.style.paddingInlineStart = ''
|
||||
} else {
|
||||
block.style.paddingInlineStart = `${next}px`
|
||||
}
|
||||
syncChange()
|
||||
}
|
||||
|
||||
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
|
||||
e.preventDefault()
|
||||
changeIndent(e.shiftKey ? -1 : 1)
|
||||
}
|
||||
|
||||
function styleInsertedImage(img: HTMLImageElement) {
|
||||
img.style.width = '100%'
|
||||
img.style.maxWidth = '100%'
|
||||
@@ -223,6 +280,23 @@ export function RichTextEditor({
|
||||
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
|
||||
<ListOrdered size={16} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeIndent(-1)}
|
||||
title="Decrease indent"
|
||||
aria-label="Decrease indent"
|
||||
>
|
||||
<IndentDecrease size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeIndent(1)}
|
||||
title="Increase indent"
|
||||
aria-label="Increase indent"
|
||||
>
|
||||
<IndentIncrease size={16} />
|
||||
</button>
|
||||
{allowImages && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
@@ -262,6 +336,7 @@ export function RichTextEditor({
|
||||
lang={locale}
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
onKeyDown={onEditorKeyDown}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{allowImages && selectedImageEl && handlePos && (
|
||||
|
||||
@@ -66,13 +66,14 @@
|
||||
}
|
||||
|
||||
.badge[data-status='draft'] {
|
||||
color: #fff;
|
||||
color: #0f172a;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(251, 191, 36, 0.55) 0%,
|
||||
rgba(245, 158, 11, 0.32) 100%
|
||||
rgba(255, 255, 255, 0.78) 0%,
|
||||
rgba(255, 255, 255, 0.42) 100%
|
||||
);
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.badge[data-status='published'] {
|
||||
|
||||
Reference in New Issue
Block a user