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
@@ -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; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 32px; 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; position: sticky;
top: 0; top: 0;
z-index: 50; 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 { .left {
@@ -90,6 +90,19 @@
.field select { .field select {
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x); 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, .field input:focus,
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react' import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
deleteComment, deleteComment,
@@ -21,6 +22,7 @@ import type { ProductTechnicalValue } from '../types/technicalForm'
import styles from './ProductDetailsTabs.module.css' import styles from './ProductDetailsTabs.module.css'
type DetailTab = 'technical' | 'comments' | 'reviews' type DetailTab = 'technical' | 'comments' | 'reviews'
type TechnicalEmptyReason = 'no-category' | 'no-form' | 'no-data'
interface ProductDetailsTabsProps { interface ProductDetailsTabsProps {
productId: string productId: string
@@ -39,11 +41,14 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
} }
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) { export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en' const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [activeTab, setActiveTab] = useState<DetailTab>('technical') const [activeTab, setActiveTab] = useState<DetailTab>('technical')
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([]) const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
const [technicalMessage, setTechnicalMessage] = useState('') const [technicalEmptyReason, setTechnicalEmptyReason] = useState<TechnicalEmptyReason | null>(
null,
)
const [technicalLoading, setTechnicalLoading] = useState(false) const [technicalLoading, setTechnicalLoading] = useState(false)
const [technicalError, setTechnicalError] = useState('') const [technicalError, setTechnicalError] = useState('')
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
@@ -90,7 +95,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setCommentsError(err.message) setCommentsError(err.message)
} else { } else {
setCommentsError('Unable to load comments.') setCommentsError(t('products.details.comments.errorLoad'))
} }
} finally { } finally {
setCommentsLoading(false) setCommentsLoading(false)
@@ -109,7 +114,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setReviewsError(err.message) setReviewsError(err.message)
} else { } else {
setReviewsError('Unable to load expert reviews.') setReviewsError(t('products.details.reviews.errorLoad'))
} }
} finally { } finally {
setReviewsLoading(false) setReviewsLoading(false)
@@ -119,24 +124,24 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
async function loadTechnicalInfo(signal?: AbortSignal) { async function loadTechnicalInfo(signal?: AbortSignal) {
setTechnicalLoading(true) setTechnicalLoading(true)
setTechnicalError('') setTechnicalError('')
setTechnicalMessage('') setTechnicalEmptyReason(null)
try { try {
const data = await getProductTechnicalInfo(productId, signal) const data = await getProductTechnicalInfo(productId, signal)
setTechnicalValues(data.values) setTechnicalValues(data.values)
if (data.message) { if (data.message === 'Product has no category assigned') {
setTechnicalMessage(data.message) setTechnicalEmptyReason('no-category')
} else if (!data.form) { } 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) !== '—')) { } else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) {
setTechnicalMessage('No technical data has been added for this product yet.') setTechnicalEmptyReason('no-data')
} }
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) { if (err instanceof ApiError) {
setTechnicalError(err.message) setTechnicalError(err.message)
} else { } else {
setTechnicalError('Unable to load technical info.') setTechnicalError(t('products.details.technical.errorLoad'))
} }
} finally { } finally {
setTechnicalLoading(false) setTechnicalLoading(false)
@@ -154,7 +159,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setCommentsError(err.message) setCommentsError(err.message)
} else { } else {
setCommentsError('Unable to update comment approval.') setCommentsError(t('products.details.comments.errorApprove'))
} }
} finally { } finally {
setActionId(null) setActionId(null)
@@ -172,7 +177,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setCommentsError(err.message) setCommentsError(err.message)
} else { } else {
setCommentsError('Unable to delete comment.') setCommentsError(t('products.details.comments.errorDelete'))
} }
} finally { } finally {
setActionId(null) setActionId(null)
@@ -190,7 +195,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setReviewsError(err.message) setReviewsError(err.message)
} else { } else {
setReviewsError('Unable to update expert review approval.') setReviewsError(t('products.details.reviews.errorApprove'))
} }
} finally { } finally {
setActionId(null) setActionId(null)
@@ -208,7 +213,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
if (err instanceof ApiError) { if (err instanceof ApiError) {
setReviewsError(err.message) setReviewsError(err.message)
} else { } else {
setReviewsError('Unable to delete expert review.') setReviewsError(t('products.details.reviews.errorDelete'))
} }
} finally { } finally {
setActionId(null) setActionId(null)
@@ -216,10 +221,18 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
} }
const displayedCommentCount = comments.length > 0 ? comments.length : commentCount 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 ( return (
<section className={styles.tabsSection}> <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 <button
type="button" type="button"
role="tab" role="tab"
@@ -227,7 +240,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('technical')} onClick={() => setActiveTab('technical')}
> >
Technical Info {t('products.details.tab.technical')}
</button> </button>
<button <button
type="button" type="button"
@@ -236,7 +249,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('comments')} onClick={() => setActiveTab('comments')}
> >
Comments {t('products.details.tab.comments')}
{displayedCommentCount > 0 && ( {displayedCommentCount > 0 && (
<span className={styles.tabBadge}>{displayedCommentCount}</span> <span className={styles.tabBadge}>{displayedCommentCount}</span>
)} )}
@@ -248,7 +261,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('reviews')} onClick={() => setActiveTab('reviews')}
> >
Expert Reviews {t('products.details.tab.reviews')}
{reviews.length > 0 && ( {reviews.length > 0 && (
<span className={styles.tabBadge}>{reviews.length}</span> <span className={styles.tabBadge}>{reviews.length}</span>
)} )}
@@ -259,13 +272,13 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
{activeTab === 'technical' && ( {activeTab === 'technical' && (
<> <>
{technicalLoading ? ( {technicalLoading ? (
<p className={styles.emptyText}>Loading technical info...</p> <p className={styles.emptyText}>{t('products.details.technical.loading')}</p>
) : technicalError ? ( ) : technicalError ? (
<p className={styles.errorText}>{technicalError}</p> <p className={styles.errorText}>{technicalError}</p>
) : technicalMessage && technicalValues.length === 0 ? ( ) : technicalEmptyMessage && technicalValues.length === 0 ? (
<p className={styles.emptyText}>{technicalMessage}</p> <p className={styles.emptyText}>{technicalEmptyMessage}</p>
) : technicalValues.length === 0 ? ( ) : 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}> <dl className={styles.techList}>
{technicalValues.map((item) => ( {technicalValues.map((item) => (
@@ -276,8 +289,8 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
))} ))}
</dl> </dl>
)} )}
{technicalMessage && technicalValues.length > 0 && ( {technicalEmptyMessage && technicalValues.length > 0 && (
<p className={styles.hintText}>{technicalMessage}</p> <p className={styles.hintText}>{technicalEmptyMessage}</p>
)} )}
</> </>
)} )}
@@ -286,9 +299,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<> <>
{commentsError && <p className={styles.errorText}>{commentsError}</p>} {commentsError && <p className={styles.errorText}>{commentsError}</p>}
{commentsLoading ? ( {commentsLoading ? (
<p className={styles.emptyText}>Loading comments...</p> <p className={styles.emptyText}>{t('products.details.comments.loading')}</p>
) : comments.length === 0 ? ( ) : 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}> <div className={styles.commentList}>
{comments.map((comment) => ( {comments.map((comment) => (
@@ -310,7 +323,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
</div> </div>
<p className={styles.commentText}>{comment.text}</p> <p className={styles.commentText}>{comment.text}</p>
{!comment.approved && ( {!comment.approved && (
<span className={styles.pendingBadge}>Pending approval</span> <span className={styles.pendingBadge}>{t('products.details.pending')}</span>
)} )}
<div className={styles.itemActions}> <div className={styles.itemActions}>
{comment.approved ? ( {comment.approved ? (
@@ -321,7 +334,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleCommentApproval(comment)} onClick={() => void toggleCommentApproval(comment)}
> >
<XCircle size={14} /> <XCircle size={14} />
Reject {t('products.details.reject')}
</button> </button>
) : ( ) : (
<button <button
@@ -331,7 +344,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleCommentApproval(comment)} onClick={() => void toggleCommentApproval(comment)}
> >
<Check size={14} /> <Check size={14} />
Approve {t('products.details.approve')}
</button> </button>
)} )}
<button <button
@@ -341,7 +354,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void removeComment(comment.id)} onClick={() => void removeComment(comment.id)}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Remove {t('products.details.remove')}
</button> </button>
</div> </div>
</article> </article>
@@ -355,9 +368,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<> <>
{reviewsError && <p className={styles.errorText}>{reviewsError}</p>} {reviewsError && <p className={styles.errorText}>{reviewsError}</p>}
{reviewsLoading ? ( {reviewsLoading ? (
<p className={styles.emptyText}>Loading expert reviews...</p> <p className={styles.emptyText}>{t('products.details.reviews.loading')}</p>
) : reviews.length === 0 ? ( ) : 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}> <div className={styles.reviewList}>
{reviews.map((review) => ( {reviews.map((review) => (
@@ -372,7 +385,10 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
{formatReviewDate(review.createdAt, dateLocale)} {formatReviewDate(review.createdAt, dateLocale)}
</div> </div>
</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" /> <Star size={14} fill="currentColor" />
{review.rate}/10 {review.rate}/10
</div> </div>
@@ -382,7 +398,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<div className={styles.pointsGrid}> <div className={styles.pointsGrid}>
{review.positivePoints.length > 0 && ( {review.positivePoints.length > 0 && (
<div className={styles.pointsBlock}> <div className={styles.pointsBlock}>
<h4 className={styles.pointsHeading}>Positive points</h4> <h4 className={styles.pointsHeading}>
{t('products.details.positivePoints')}
</h4>
<ul className={styles.pointsList}> <ul className={styles.pointsList}>
{review.positivePoints.map((point) => ( {review.positivePoints.map((point) => (
<li key={point}>{point}</li> <li key={point}>{point}</li>
@@ -392,7 +410,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
)} )}
{review.negativePoints.length > 0 && ( {review.negativePoints.length > 0 && (
<div className={styles.pointsBlock}> <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}`}> <ul className={`${styles.pointsList} ${styles.pointsListNegative}`}>
{review.negativePoints.map((point) => ( {review.negativePoints.map((point) => (
<li key={point}>{point}</li> <li key={point}>{point}</li>
@@ -406,7 +426,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<p className={styles.reviewSummary}>{review.text}</p> <p className={styles.reviewSummary}>{review.text}</p>
{!review.approved && ( {!review.approved && (
<span className={styles.pendingBadge}>Pending approval</span> <span className={styles.pendingBadge}>{t('products.details.pending')}</span>
)} )}
<div className={styles.itemActions}> <div className={styles.itemActions}>
@@ -418,7 +438,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleReviewApproval(review)} onClick={() => void toggleReviewApproval(review)}
> >
<XCircle size={14} /> <XCircle size={14} />
Reject {t('products.details.reject')}
</button> </button>
) : ( ) : (
<button <button
@@ -428,7 +448,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void toggleReviewApproval(review)} onClick={() => void toggleReviewApproval(review)}
> >
<Check size={14} /> <Check size={14} />
Approve {t('products.details.approve')}
</button> </button>
)} )}
<button <button
@@ -438,7 +458,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
onClick={() => void removeReview(review.id)} onClick={() => void removeReview(review.id)}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Remove {t('products.details.remove')}
</button> </button>
</div> </div>
</article> </article>
@@ -99,6 +99,14 @@
margin-bottom: 0; margin-bottom: 0;
} }
.editor blockquote {
margin: 0.5em 0;
margin-inline-start: 1.5em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
.editor img { .editor img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@@ -1,9 +1,21 @@
import { useRef, useEffect, useState, useCallback } from 'react' 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 { useLocale } from '@meshkee/dashboard-ui'
import { uploadMediaFiles } from '../services/mediaService' import { uploadMediaFiles } from '../services/mediaService'
import styles from './RichTextEditor.module.css' 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 { interface RichTextEditorProps {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
@@ -132,6 +144,51 @@ export function RichTextEditor({
syncChange() 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) { function styleInsertedImage(img: HTMLImageElement) {
img.style.width = '100%' img.style.width = '100%'
img.style.maxWidth = '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"> <button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} /> <ListOrdered size={16} />
</button> </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 && ( {allowImages && (
<> <>
<span className={styles.divider} /> <span className={styles.divider} />
@@ -262,6 +336,7 @@ export function RichTextEditor({
lang={locale} lang={locale}
data-placeholder={placeholder} data-placeholder={placeholder}
onInput={syncChange} onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning suppressContentEditableWarning
/> />
{allowImages && selectedImageEl && handlePos && ( {allowImages && selectedImageEl && handlePos && (
@@ -66,13 +66,14 @@
} }
.badge[data-status='draft'] { .badge[data-status='draft'] {
color: #fff; color: #0f172a;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
rgba(251, 191, 36, 0.55) 0%, rgba(255, 255, 255, 0.78) 0%,
rgba(245, 158, 11, 0.32) 100% 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'] { .badge[data-status='published'] {
+134 -4
View File
@@ -407,6 +407,43 @@ const en = {
'products.card.remove': 'Remove product', 'products.card.remove': 'Remove product',
'products.card.draft': 'Draft', 'products.card.draft': 'Draft',
'products.details.loading': 'Loading product...',
'products.details.notFound': 'Product not found.',
'products.details.errorLoad': 'Unable to load product.',
'products.details.back': 'Back to My Products',
'products.details.openGallery': 'Open image gallery',
'products.details.noImage': 'No image',
'products.details.viewImage': 'View image {index}',
'products.details.tabsAria': 'Product details',
'products.details.tab.technical': 'Technical Info',
'products.details.tab.comments': 'Comments',
'products.details.tab.reviews': 'Expert Reviews',
'products.details.technical.loading': 'Loading technical info...',
'products.details.technical.noForm':
'No technical form is defined for this product category.',
'products.details.technical.noData':
'No technical data has been added for this product yet.',
'products.details.technical.noCategory': 'Product has no category assigned.',
'products.details.technical.empty': 'No technical data available.',
'products.details.technical.errorLoad': 'Unable to load technical info.',
'products.details.comments.loading': 'Loading comments...',
'products.details.comments.empty': 'No comments yet for this product.',
'products.details.comments.errorLoad': 'Unable to load comments.',
'products.details.comments.errorApprove': 'Unable to update comment approval.',
'products.details.comments.errorDelete': 'Unable to delete comment.',
'products.details.reviews.loading': 'Loading expert reviews...',
'products.details.reviews.empty': 'No expert reviews yet.',
'products.details.reviews.errorLoad': 'Unable to load expert reviews.',
'products.details.reviews.errorApprove': 'Unable to update expert review approval.',
'products.details.reviews.errorDelete': 'Unable to delete expert review.',
'products.details.pending': 'Pending approval',
'products.details.approve': 'Approve',
'products.details.reject': 'Reject',
'products.details.remove': 'Remove',
'products.details.positivePoints': 'Positive points',
'products.details.negativePoints': 'Negative points',
'products.details.ratingAria': 'Rating {rate} out of 10',
'products.form.errorOptions': 'Unable to load form options.', 'products.form.errorOptions': 'Unable to load form options.',
'products.form.errorSave': 'Unable to save product.', 'products.form.errorSave': 'Unable to save product.',
'products.form.thumbnail': 'Thumbnail Image', 'products.form.thumbnail': 'Thumbnail Image',
@@ -735,9 +772,13 @@ const en = {
'customers.filters': 'Filters', 'customers.filters': 'Filters',
'customers.filter.name': 'Search by name', 'customers.filter.name': 'Search by name',
'customers.filter.cell': 'Cell number', 'customers.filter.cell': 'Cell number',
'customers.filter.access': 'User role',
'customers.filter.access.all': 'All users',
'customers.filter.access.customers': 'Customers',
'customers.filter.access.managers': 'Managers',
'customers.search': 'Search', 'customers.search': 'Search',
'customers.clearFilters': 'Clear filters', 'customers.clearFilters': 'Clear filters',
'customers.listTitle': 'Customer list', 'customers.listTitle': 'User list',
'customers.showing': 'Showing {from} - {to} of {total}', 'customers.showing': 'Showing {from} - {to} of {total}',
'customers.none': 'No customers', 'customers.none': 'No customers',
'customers.empty': 'No results found.', 'customers.empty': 'No results found.',
@@ -792,6 +833,29 @@ const en = {
'customers.addModal.error': 'Unable to add customer.', 'customers.addModal.error': 'Unable to add customer.',
'customers.addModal.errorPasswordMatch': 'Passwords do not match.', 'customers.addModal.errorPasswordMatch': 'Passwords do not match.',
'customers.addModal.errorPasswordLength': 'Password must be at least 8 characters.', 'customers.addModal.errorPasswordLength': 'Password must be at least 8 characters.',
'customers.access.title': 'Change access',
'customers.access.subtitle': 'Choose customer or manager access for {name}.',
'customers.access.customer': 'Customer',
'customers.access.manager': 'Manager',
'customers.access.role.admin': 'Admin',
'customers.access.role.editor': 'Editor',
'customers.access.role.viewer': 'Viewer',
'customers.access.hintSuperAdmin':
'Admin can manage other managers (except other admins). Editor and viewer manage content only.',
'customers.access.hintBusiness':
'Managers can be Editor or Viewer. Only a super admin can assign Admin.',
'customers.access.cancel': 'Cancel',
'customers.access.save': 'Save',
'customers.access.error': 'Unable to update access.',
'customers.access.change': 'Change access',
'customers.access.ownerLocked': 'Business owner cannot be changed',
'customers.access.adminLocked': 'Only a super admin can change an admin',
'customers.access.badge.owner': 'Owner',
'customers.access.badge.admin': 'Admin',
'customers.access.badge.editor': 'Editor',
'customers.access.badge.viewer': 'Viewer',
'customers.access.badge.manager': 'Manager',
'customers.toast.accessUpdated': 'Access updated to {role}.',
'blog.page.subtitle': 'Create and manage blog posts and categories.', 'blog.page.subtitle': 'Create and manage blog posts and categories.',
'blog.card.list.desc': 'View, edit and manage all your blog posts.', 'blog.card.list.desc': 'View, edit and manage all your blog posts.',
@@ -806,7 +870,9 @@ const en = {
'blog.list.subtitle': '{count} posts · View, edit and manage your blog content.', 'blog.list.subtitle': '{count} posts · View, edit and manage your blog content.',
'blog.list.loading': 'Loading blog posts...', 'blog.list.loading': 'Loading blog posts...',
'blog.list.empty': 'No blog posts found.', 'blog.list.empty': 'No blog posts yet.',
'blog.list.emptyHint': 'Share news, stories, and updates with your audience.',
'blog.list.emptyCta': 'Write your first post',
'blog.list.errorLoad': 'Unable to load blog posts.', 'blog.list.errorLoad': 'Unable to load blog posts.',
'blog.list.errorDelete': 'Unable to delete blog post.', 'blog.list.errorDelete': 'Unable to delete blog post.',
'blog.list.errorVerify': 'Unable to update blog verification.', 'blog.list.errorVerify': 'Unable to update blog verification.',
@@ -1710,6 +1776,41 @@ const fa: Record<MessageKey, string> = {
'products.card.remove': 'حذف محصول', 'products.card.remove': 'حذف محصول',
'products.card.draft': 'پیش‌نویس', 'products.card.draft': 'پیش‌نویس',
'products.details.loading': 'در حال بارگذاری محصول...',
'products.details.notFound': 'محصول یافت نشد.',
'products.details.errorLoad': 'بارگذاری محصول ممکن نشد.',
'products.details.back': 'بازگشت به محصولات من',
'products.details.openGallery': 'باز کردن گالری تصاویر',
'products.details.noImage': 'بدون تصویر',
'products.details.viewImage': 'مشاهده تصویر {index}',
'products.details.tabsAria': 'جزئیات محصول',
'products.details.tab.technical': 'اطلاعات فنی',
'products.details.tab.comments': 'نظرات',
'products.details.tab.reviews': 'بررسی کارشناسان',
'products.details.technical.loading': 'در حال بارگذاری اطلاعات فنی...',
'products.details.technical.noForm': 'برای این دسته‌بندی فرم فنی تعریف نشده است.',
'products.details.technical.noData': 'هنوز اطلاعات فنی برای این محصول ثبت نشده است.',
'products.details.technical.noCategory': 'این محصول به دسته‌بندی اختصاص داده نشده است.',
'products.details.technical.empty': 'اطلاعات فنی موجود نیست.',
'products.details.technical.errorLoad': 'بارگذاری اطلاعات فنی ممکن نشد.',
'products.details.comments.loading': 'در حال بارگذاری نظرات...',
'products.details.comments.empty': 'هنوز نظری برای این محصول ثبت نشده است.',
'products.details.comments.errorLoad': 'بارگذاری نظرات ممکن نشد.',
'products.details.comments.errorApprove': 'به‌روزرسانی تأیید نظر ممکن نشد.',
'products.details.comments.errorDelete': 'حذف نظر ممکن نشد.',
'products.details.reviews.loading': 'در حال بارگذاری بررسی کارشناسان...',
'products.details.reviews.empty': 'هنوز بررسی کارشناسی ثبت نشده است.',
'products.details.reviews.errorLoad': 'بارگذاری بررسی کارشناسان ممکن نشد.',
'products.details.reviews.errorApprove': 'به‌روزرسانی تأیید بررسی ممکن نشد.',
'products.details.reviews.errorDelete': 'حذف بررسی کارشناسی ممکن نشد.',
'products.details.pending': 'در انتظار تأیید',
'products.details.approve': 'تأیید',
'products.details.reject': 'رد',
'products.details.remove': 'حذف',
'products.details.positivePoints': 'نقاط قوت',
'products.details.negativePoints': 'نقاط ضعف',
'products.details.ratingAria': 'امتیاز {rate} از ۱۰',
'products.form.errorOptions': 'بارگذاری گزینه‌های فرم ممکن نشد.', 'products.form.errorOptions': 'بارگذاری گزینه‌های فرم ممکن نشد.',
'products.form.errorSave': 'ذخیره محصول ممکن نشد.', 'products.form.errorSave': 'ذخیره محصول ممکن نشد.',
'products.form.thumbnail': 'تصویر بندانگشتی', 'products.form.thumbnail': 'تصویر بندانگشتی',
@@ -2038,9 +2139,13 @@ const fa: Record<MessageKey, string> = {
'customers.filters': 'فیلترها', 'customers.filters': 'فیلترها',
'customers.filter.name': 'جستجو بر اساس نام', 'customers.filter.name': 'جستجو بر اساس نام',
'customers.filter.cell': 'شماره موبایل', 'customers.filter.cell': 'شماره موبایل',
'customers.filter.access': 'نقش کاربر',
'customers.filter.access.all': 'تمام کاربران',
'customers.filter.access.customers': 'مشتریان',
'customers.filter.access.managers': 'مدیران سیستم',
'customers.search': 'جستجو', 'customers.search': 'جستجو',
'customers.clearFilters': 'پاک کردن فیلترها', 'customers.clearFilters': 'پاک کردن فیلترها',
'customers.listTitle': 'فهرست مشتریان', 'customers.listTitle': 'فهرست کاربران',
'customers.showing': 'نمایش {from} تا {to} از {total}', 'customers.showing': 'نمایش {from} تا {to} از {total}',
'customers.none': 'بدون مشتری', 'customers.none': 'بدون مشتری',
'customers.empty': 'نتیجه‌ای یافت نشد.', 'customers.empty': 'نتیجه‌ای یافت نشد.',
@@ -2095,6 +2200,29 @@ const fa: Record<MessageKey, string> = {
'customers.addModal.error': 'افزودن مشتری ممکن نشد.', 'customers.addModal.error': 'افزودن مشتری ممکن نشد.',
'customers.addModal.errorPasswordMatch': 'رمزهای عبور یکسان نیستند.', 'customers.addModal.errorPasswordMatch': 'رمزهای عبور یکسان نیستند.',
'customers.addModal.errorPasswordLength': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'customers.addModal.errorPasswordLength': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'customers.access.title': 'تغییر دسترسی',
'customers.access.subtitle': 'دسترسی مشتری یا مدیر را برای {name} انتخاب کنید.',
'customers.access.customer': 'مشتری',
'customers.access.manager': 'مدیر',
'customers.access.role.admin': 'ادمین',
'customers.access.role.editor': 'ویرایشگر',
'customers.access.role.viewer': 'بیننده',
'customers.access.hintSuperAdmin':
'ادمین می‌تواند مدیران دیگر را مدیریت کند (به‌جز ادمین‌های دیگر). ویرایشگر و بیننده فقط محتوا را مدیریت می‌کنند.',
'customers.access.hintBusiness':
'مدیران می‌توانند ویرایشگر یا بیننده باشند. فقط سوپرادمین می‌تواند ادمین تعیین کند.',
'customers.access.cancel': 'انصراف',
'customers.access.save': 'ذخیره',
'customers.access.error': 'به‌روزرسانی دسترسی ممکن نشد.',
'customers.access.change': 'تغییر دسترسی',
'customers.access.ownerLocked': 'نقش صاحب کسب‌وکار قابل تغییر نیست',
'customers.access.adminLocked': 'فقط سوپرادمین می‌تواند ادمین را تغییر دهد',
'customers.access.badge.owner': 'صاحب',
'customers.access.badge.admin': 'ادمین',
'customers.access.badge.editor': 'ویرایشگر',
'customers.access.badge.viewer': 'بیننده',
'customers.access.badge.manager': 'مدیر',
'customers.toast.accessUpdated': 'دسترسی به {role} به‌روزرسانی شد.',
'blog.page.subtitle': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', 'blog.page.subtitle': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.',
'blog.card.list.desc': 'همه مطالب بلاگ را مشاهده، ویرایش و مدیریت کنید.', 'blog.card.list.desc': 'همه مطالب بلاگ را مشاهده، ویرایش و مدیریت کنید.',
@@ -2109,7 +2237,9 @@ const fa: Record<MessageKey, string> = {
'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.', 'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.',
'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...', 'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...',
'blog.list.empty': 'مطلب بلاگی یافت نشد.', 'blog.list.empty': 'هنوز مطلب بلاگی ندارید.',
'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.',
'blog.list.emptyCta': 'اولین مطلب را بنویسید',
'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.', 'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.',
'blog.list.errorDelete': 'حذف مطلب بلاگ ممکن نشد.', 'blog.list.errorDelete': 'حذف مطلب بلاگ ممکن نشد.',
'blog.list.errorVerify': 'به‌روزرسانی وضعیت تأیید بلاگ ممکن نشد.', 'blog.list.errorVerify': 'به‌روزرسانی وضعیت تأیید بلاگ ممکن نشد.',
+16 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { FileText, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { BlogCard } from '../components/BlogCard' import { BlogCard } from '../components/BlogCard'
import { BlogCommentsModal } from '../components/BlogCommentsModal' import { BlogCommentsModal } from '../components/BlogCommentsModal'
@@ -180,7 +180,21 @@ export function BlogListPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('blog.list.loading')}</p> <p className={styles.empty}>{t('blog.list.loading')}</p>
) : blogs.length === 0 ? ( ) : blogs.length === 0 ? (
<p className={styles.empty}>{t('blog.list.empty')}</p> <div className={styles.emptyState}>
<div className={styles.emptyIcon} aria-hidden="true">
<FileText size={28} strokeWidth={1.75} />
</div>
<h3 className={styles.emptyTitle}>{t('blog.list.empty')}</h3>
<p className={styles.emptyHint}>{t('blog.list.emptyHint')}</p>
<button
type="button"
className={styles.emptyCta}
onClick={() => navigate('/blog/new')}
>
<Plus size={18} strokeWidth={2.25} />
{t('blog.list.emptyCta')}
</button>
</div>
) : ( ) : (
<> <>
<div className={pageStyles.grid}> <div className={pageStyles.grid}>
@@ -8,6 +8,82 @@
border-radius: var(--radius); border-radius: var(--radius);
} }
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 56px 28px;
text-align: center;
background: var(--glass-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.emptyIcon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
border-radius: 16px;
color: var(--primary);
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(var(--primary-rgb) / 0.12) 100%
);
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.emptyTitle {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
}
.emptyHint {
margin: 0 0 8px;
max-width: 360px;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
}
.emptyCta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: #fff;
text-align: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: 999px;
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.32);
transition: transform 0.2s, box-shadow 0.2s;
}
button.emptyCta {
text-align: center;
}
.emptyCta:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px rgba(var(--primary-rgb) / 0.4);
}
.error { .error {
margin-bottom: 16px; margin-bottom: 16px;
padding: 12px 14px; padding: 12px 14px;
@@ -126,12 +126,14 @@
} }
.badge[data-status='draft'] { .badge[data-status='draft'] {
color: #fff; color: #0f172a;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
rgba(251, 191, 36, 0.55) 0%, rgba(255, 255, 255, 0.78) 0%,
rgba(245, 158, 11, 0.32) 100% rgba(255, 255, 255, 0.42) 100%
); );
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
} }
.badge[data-status='published'] { .badge[data-status='published'] {
@@ -1,3 +1,11 @@
.pageHeaderCompact {
margin-bottom: 12px;
}
.pageHeaderCompact :global(h2) {
margin-bottom: 0;
}
.tablePanel { .tablePanel {
margin-top: 12px; margin-top: 12px;
padding: 0; padding: 0;
@@ -61,7 +69,7 @@
} }
.colActions { .colActions {
width: 206px; width: 240px;
} }
.th, .th,
@@ -132,10 +140,9 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0; gap: 0;
direction: ltr;
} }
/* FA: عملیات column is on the physical left — pin controls to that edge. */ /* FA: عملیات column is on the physical left. */
.tableFa .thActions, .tableFa .thActions,
.tableFa .tdActions { .tableFa .tdActions {
text-align: left; text-align: left;
@@ -143,12 +150,8 @@
padding-right: 10px; padding-right: 10px;
} }
.tableFa .rowActions {
justify-content: flex-start;
}
.toggleInActions { .toggleInActions {
margin-right: 10px; margin-inline-end: 10px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
+117 -7
View File
@@ -1,28 +1,33 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react' import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal' import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal' import { EditCustomerModal } from '../components/EditCustomerModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import {
listCustomers, listCustomers,
removeCustomer, removeCustomer,
updateCustomerEnabled, updateCustomerEnabled,
type BusinessCustomerListItem, type BusinessCustomerListItem,
type CustomerAccessFilter,
type CustomersListResponse, type CustomersListResponse,
} from '../services/customerService' } from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
import { textLocaleAttrs } from '../utils/textLocale' import { textLocaleAttrs } from '../utils/textLocale'
import filterStyles from '../components/ListFiltersPanel.module.css' import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import accessStyles from '../components/ChangeUserAccessModal.module.css'
import styles from './CustomersPage.module.css' import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
@@ -47,26 +52,66 @@ function formatTransactionTotal(total: number | null | undefined) {
return formatIrtPrice(total) return formatIrtPrice(total)
} }
function accessBadgeKey(user: BusinessCustomerListItem): string | null {
if (user.isBusinessOwner) return 'customers.access.badge.owner'
if (user.teamRole === 'admin') return 'customers.access.badge.admin'
if (user.teamRole === 'editor') return 'customers.access.badge.editor'
if (user.teamRole === 'viewer') return 'customers.access.badge.viewer'
if (user.businessMemberId) return 'customers.access.badge.manager'
return null
}
export function CustomersPage() { export function CustomersPage() {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
const { user: authUser } = useAuth()
const { showToast } = useToast() const { showToast } = useToast()
const [data, setData] = useState<CustomersListResponse | null>(null) const [data, setData] = useState<CustomersListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<{ name?: string; cellNumber?: string }>({}) const [appliedFilters, setAppliedFilters] = useState<{
name?: string
cellNumber?: string
access: CustomerAccessFilter
}>({ access: 'all' })
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [draftName, setDraftName] = useState('') const [draftName, setDraftName] = useState('')
const [draftCell, setDraftCell] = useState('') const [draftCell, setDraftCell] = useState('')
const [draftAccess, setDraftAccess] = useState<CustomerAccessFilter>('all')
const [togglingId, setTogglingId] = useState<string | null>(null) const [togglingId, setTogglingId] = useState<string | null>(null)
const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null) const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null)
const [accessTarget, setAccessTarget] = useState<BusinessCustomerListItem | null>(null)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
const membership = useMemo(() => {
const activeId = getActiveBusinessId()
if (!authUser?.businesses.length) return undefined
return (
authUser.businesses.find((b) => String(b.id) === String(activeId)) ??
authUser.businesses[0]
)
}, [authUser])
const isSuperAdmin = Boolean(
authUser?.isSuperAdmin || authUser?.roles.includes('super_admin'),
)
const canManageAccess =
isSuperAdmin ||
Boolean(membership?.isOwner) ||
Boolean(membership?.permissions.includes('business.team.update'))
function accessChangeDisabledReason(customer: BusinessCustomerListItem): string | null {
if (customer.isBusinessOwner) return t('customers.access.ownerLocked')
if (customer.teamRole === 'admin' && !isSuperAdmin) {
return t('customers.access.adminLocked')
}
return null
}
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -97,7 +142,7 @@ export function CustomersPage() {
return () => { return () => {
controller.abort() controller.abort()
} }
}, [page, appliedFilters.name, appliedFilters.cellNumber, t]) }, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
const total = data?.total ?? 0 const total = data?.total ?? 0
@@ -117,6 +162,7 @@ export function CustomersPage() {
function applyFilters() { function applyFilters() {
setPage(1) setPage(1)
setAppliedFilters({ setAppliedFilters({
access: draftAccess,
...(draftName.trim() ? { name: draftName.trim() } : {}), ...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}), ...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
}) })
@@ -125,8 +171,19 @@ export function CustomersPage() {
function clearFilters() { function clearFilters() {
setDraftName('') setDraftName('')
setDraftCell('') setDraftCell('')
setDraftAccess('all')
setPage(1) setPage(1)
setAppliedFilters({}) setAppliedFilters({ access: 'all' })
}
function handleAccessFilterChange(value: CustomerAccessFilter) {
setDraftAccess(value)
setPage(1)
setAppliedFilters({
access: value,
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
})
} }
async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) { async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) {
@@ -201,6 +258,22 @@ export function CustomersPage() {
showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success') showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success')
} }
function handleAccessSaved(updated: BusinessCustomerListItem) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)),
}
})
const roleLabel = updated.isBusinessOwner
? t('customers.access.badge.owner')
: updated.teamRole
? t(`customers.access.role.${updated.teamRole}`)
: t('customers.access.customer')
showToast(t('customers.toast.accessUpdated', { role: roleLabel }), 'success')
}
function handleCustomerCreated(customer: BusinessCustomerListItem) { function handleCustomerCreated(customer: BusinessCustomerListItem) {
setPage(1) setPage(1)
setData((prev) => { setData((prev) => {
@@ -234,17 +307,28 @@ export function CustomersPage() {
]} ]}
/> />
<div className={pageStyles.pageHeader}> <div className={`${pageStyles.pageHeader} ${styles.pageHeaderCompact}`}>
<div> <div>
<h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2> <h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('customers.subtitle')}</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}> <form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
<div className={filterStyles.filtersInputs}> <div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<select
id="filter-customer-access"
value={draftAccess}
onChange={(e) => handleAccessFilterChange(e.target.value as CustomerAccessFilter)}
aria-label={t('customers.filter.access')}
disabled={loading}
>
<option value="all">{t('customers.filter.access.all')}</option>
<option value="customers">{t('customers.filter.access.customers')}</option>
<option value="managers">{t('customers.filter.access.managers')}</option>
</select>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<input <input
id="filter-customer-name" id="filter-customer-name"
@@ -355,6 +439,8 @@ export function CustomersPage() {
data?.items?.map((customer) => { data?.items?.map((customer) => {
const name = displayName(customer) const name = displayName(customer)
const nameLocale = textLocaleAttrs(name) const nameLocale = textLocaleAttrs(name)
const badgeKey = accessBadgeKey(customer)
const accessLocked = accessChangeDisabledReason(customer)
return ( return (
<tr <tr
key={customer.id} key={customer.id}
@@ -374,6 +460,9 @@ export function CustomersPage() {
> >
{name} {name}
</div> </div>
{badgeKey ? (
<span className={accessStyles.roleBadge}>{t(badgeKey)}</span>
) : null}
{!customer.isEnabled && ( {!customer.isEnabled && (
<div className={styles.statusDisabled}>{t('customers.disabled')}</div> <div className={styles.statusDisabled}>{t('customers.disabled')}</div>
)} )}
@@ -417,6 +506,19 @@ export function CustomersPage() {
/> />
</span> </span>
</Tooltip> </Tooltip>
{canManageAccess ? (
<Tooltip label={accessLocked ?? t('customers.access.change')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => setAccessTarget(customer)}
aria-label={accessLocked ?? t('customers.access.change')}
disabled={Boolean(accessLocked) || removing}
>
<Shield size={15} />
</button>
</Tooltip>
) : null}
<Tooltip label={t('customers.edit')}> <Tooltip label={t('customers.edit')}>
<button <button
type="button" type="button"
@@ -499,6 +601,14 @@ export function CustomersPage() {
onSaved={handleCustomerSaved} onSaved={handleCustomerSaved}
/> />
<ChangeUserAccessModal
open={accessTarget !== null}
user={accessTarget}
canAssignAdmin={isSuperAdmin}
onClose={() => setAccessTarget(null)}
onSaved={handleAccessSaved}
/>
<ConfirmDeleteModal <ConfirmDeleteModal
open={removeTarget !== null} open={removeTarget !== null}
title={t('customers.deleteTitle')} title={t('customers.deleteTitle')}
+42 -24
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox' import { ImageLightbox } from '../components/ImageLightbox'
import { ProductDetailsTabs } from '../components/ProductDetailsTabs' import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getProduct, getProduct,
@@ -14,6 +16,9 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.module.css' import styles from './ProductDetailsPage.module.css'
export function ProductDetailsPage() { export function ProductDetailsPage() {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const { id } = useParams() const { id } = useParams()
const [product, setProduct] = useState<Product | null>(null) const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -40,7 +45,7 @@ export function ProductDetailsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load product.') setError(t('products.details.errorLoad'))
} }
setProduct(null) setProduct(null)
} finally { } finally {
@@ -50,7 +55,7 @@ export function ProductDetailsPage() {
void loadProduct() void loadProduct()
return () => controller.abort() return () => controller.abort()
}, [id]) }, [id, t])
const galleryImages = useMemo(() => product?.images ?? [], [product]) const galleryImages = useMemo(() => product?.images ?? [], [product])
const currentImage = galleryImages[activeIndex] ?? galleryImages[0] ?? '' const currentImage = galleryImages[activeIndex] ?? galleryImages[0] ?? ''
@@ -63,7 +68,7 @@ export function ProductDetailsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<p className={styles.status}>Loading product...</p> <p className={styles.status}>{t('products.details.loading')}</p>
</main> </main>
) )
} }
@@ -71,16 +76,27 @@ export function ProductDetailsPage() {
if (error || !product) { if (error || !product) {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<p className={styles.error}>{error || 'Product not found.'}</p> <p className={styles.error}>{error || t('products.details.notFound')}</p>
<Link to="/products/list" className={styles.backLink}> <Link to="/products/list" className={styles.backLink}>
Back to My Products {t('products.details.back')}
</Link> </Link>
</main> </main>
) )
} }
const nameEnLocale = textLocaleAttrs(product.nameEn) const primaryName = isFa
const nameFaLocale = textLocaleAttrs(product.nameFa) ? product.nameFa?.trim() || product.nameEn
: product.nameEn?.trim() || product.nameFa
const secondaryName = isFa
? product.nameEn?.trim() && product.nameEn !== primaryName
? product.nameEn
: ''
: product.nameFa?.trim() && product.nameFa !== primaryName
? product.nameFa
: ''
const primaryLocale = textLocaleAttrs(primaryName)
const secondaryLocale = textLocaleAttrs(secondaryName)
const summaryLocale = textLocaleAttrs(product.summary) const summaryLocale = textLocaleAttrs(product.summary)
const descriptionLocale = textLocaleAttrs( const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '', product.description?.replace(/<[^>]+>/g, ' ') ?? '',
@@ -94,7 +110,7 @@ export function ProductDetailsPage() {
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' }, { label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' }, { label: 'My Products', href: '/products/list' },
{ label: product.nameEn }, { label: primaryName },
]} ]}
/> />
@@ -104,15 +120,17 @@ export function ProductDetailsPage() {
type="button" type="button"
className={styles.mainImage} className={styles.mainImage}
onClick={() => currentImage && openLightbox(activeIndex)} onClick={() => currentImage && openLightbox(activeIndex)}
aria-label="Open image gallery" aria-label={t('products.details.openGallery')}
disabled={!currentImage} disabled={!currentImage}
> >
{currentImage ? ( {currentImage ? (
<img src={currentImage} alt={product.nameEn} /> <img src={currentImage} alt={primaryName} />
) : ( ) : (
<div className={styles.imagePlaceholder}>No image</div> <div className={styles.imagePlaceholder}>{t('products.details.noImage')}</div>
)}
{product.status === 'draft' && (
<span className={styles.draftBadge}>{t('products.card.draft')}</span>
)} )}
{product.status === 'draft' && <span className={styles.draftBadge}>Draft</span>}
</button> </button>
{galleryImages.length > 1 && ( {galleryImages.length > 1 && (
@@ -126,7 +144,7 @@ export function ProductDetailsPage() {
setActiveIndex(index) setActiveIndex(index)
openLightbox(index) openLightbox(index)
}} }}
aria-label={`View image ${index + 1}`} aria-label={t('products.details.viewImage', { index: index + 1 })}
> >
<img src={src} alt="" /> <img src={src} alt="" />
</button> </button>
@@ -147,21 +165,21 @@ export function ProductDetailsPage() {
)} )}
<h1 <h1
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')} className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')}
lang={nameEnLocale.lang} lang={primaryLocale.lang}
dir={nameEnLocale.dir} dir={primaryLocale.dir}
> >
{product.nameEn} {primaryName}
</h1> </h1>
{product.nameFa && ( {secondaryName ? (
<p <p
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')} className={[styles.nameFa, secondaryLocale.className].filter(Boolean).join(' ')}
lang={nameFaLocale.lang} lang={secondaryLocale.lang}
dir={nameFaLocale.dir} dir={secondaryLocale.dir}
> >
{product.nameFa} {secondaryName}
</p> </p>
)} ) : null}
{product.summary && ( {product.summary && (
<p <p
@@ -210,7 +228,7 @@ export function ProductDetailsPage() {
open={lightboxOpen} open={lightboxOpen}
images={galleryImages} images={galleryImages}
initialIndex={lightboxIndex} initialIndex={lightboxIndex}
alt={product.nameEn} alt={primaryName}
onClose={() => setLightboxOpen(false)} onClose={() => setLightboxOpen(false)}
/> />
</main> </main>
@@ -17,6 +17,10 @@ export interface BusinessCustomerListItem extends BusinessCustomer {
orderCount?: number | null orderCount?: number | null
/** Placeholder until orders stats API exists */ /** Placeholder until orders stats API exists */
totalTransactionsIrt?: number | null totalTransactionsIrt?: number | null
/** Present when this customer is also staff/owner on the business. */
businessMemberId?: string | null
isBusinessOwner?: boolean
teamRole?: string | null
} }
export interface CustomersListResponse { export interface CustomersListResponse {
@@ -26,11 +30,14 @@ export interface CustomersListResponse {
pageSize: number pageSize: number
} }
export type CustomerAccessFilter = 'all' | 'customers' | 'managers'
export interface ListCustomersParams { export interface ListCustomersParams {
page?: number page?: number
pageSize?: number pageSize?: number
name?: string name?: string
cellNumber?: string cellNumber?: string
access?: CustomerAccessFilter
} }
function businessPath(suffix = '') { function businessPath(suffix = '') {
@@ -50,6 +57,7 @@ export async function listCustomers(
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize)) if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.name) q.set('name', params.name) if (params.name) q.set('name', params.name)
if (params.cellNumber) q.set('cellNumber', params.cellNumber) if (params.cellNumber) q.set('cellNumber', params.cellNumber)
if (params.access) q.set('access', params.access)
const query = q.toString() const query = q.toString()
const path = `${businessPath()}${query ? `?${query}` : ''}` const path = `${businessPath()}${query ? `?${query}` : ''}`
+44
View File
@@ -0,0 +1,44 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export type BusinessAccess = 'customer' | 'staff'
export interface AssignBusinessAccessPayload {
userId: string
access: BusinessAccess
roleSlug?: string
}
export interface AssignBusinessAccessResult {
message: string
access: BusinessAccess
member: {
id: string | number
userId: string | number
isOwner: boolean
teamRole: string | null
} | null
}
function teamPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/team${suffix}`
}
/** Set customer vs manager (admin/editor/viewer) for a user on this business. */
export async function assignBusinessAccess(payload: AssignBusinessAccessPayload) {
return apiRequest<AssignBusinessAccessResult>(teamPath('/access'), {
method: 'PATCH',
auth: true,
body: {
userId: String(payload.userId),
access: payload.access,
...(payload.access === 'staff' && payload.roleSlug
? { roleSlug: payload.roleSlug }
: {}),
},
})
}
@@ -3,13 +3,14 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 32px; 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; position: sticky;
top: 0; top: 0;
z-index: 50; 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);
transform: translateZ(0);
} }
.left { .left {
@@ -66,13 +66,14 @@
} }
.badge[data-status='draft'] { .badge[data-status='draft'] {
color: #fff; color: #0f172a;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
rgba(251, 191, 36, 0.55) 0%, rgba(255, 255, 255, 0.78) 0%,
rgba(245, 158, 11, 0.32) 100% 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'] { .badge[data-status='published'] {
@@ -384,6 +384,7 @@ p.footerText {
line-height: 1.75; line-height: 1.75;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 18px; margin-bottom: 18px;
text-align: justify;
} }
.noticeLink { .noticeLink {
@@ -403,6 +404,7 @@ p.footerText {
padding: 11px 16px; padding: 11px 16px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
text-align: center;
color: #fff; color: #fff;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -410,6 +412,10 @@ p.footerText {
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, opacity 0.2s;
} }
button.noticeAdmit {
text-align: center;
}
.noticeAdmit:hover:not(:disabled) { .noticeAdmit:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -108,12 +108,14 @@
} }
.badge[data-status='draft'] { .badge[data-status='draft'] {
color: #fff; color: #0f172a;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
rgba(251, 191, 36, 0.55) 0%, rgba(255, 255, 255, 0.78) 0%,
rgba(245, 158, 11, 0.32) 100% rgba(255, 255, 255, 0.42) 100%
); );
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
} }
.badge[data-status='published'] { .badge[data-status='published'] {
@@ -3,13 +3,14 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 24px; padding: 12px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border-bottom: 1px solid var(--glass-border);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; 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);
transform: translateZ(0);
} }
.left { .left {
@@ -73,3 +73,11 @@
.editor p:last-child { .editor p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.editor blockquote {
margin: 0.35em 0;
margin-inline-start: 1.4em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
@@ -1,7 +1,18 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react' import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
List,
ListOrdered,
Underline,
} from 'lucide-react'
import styles from './RichTextEditor.module.css' 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 { interface RichTextEditorProps {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
@@ -36,6 +47,51 @@ export function RichTextEditor({
syncChange() 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)
}
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
@@ -70,6 +126,23 @@ export function RichTextEditor({
> >
<ListOrdered size={14} /> <ListOrdered size={14} />
</button> </button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={14} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={14} />
</button>
</div> </div>
<div <div
ref={editorRef} ref={editorRef}
@@ -80,6 +153,7 @@ export function RichTextEditor({
aria-multiline="true" aria-multiline="true"
data-placeholder={placeholder} data-placeholder={placeholder}
onInput={syncChange} onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning suppressContentEditableWarning
/> />
</div> </div>
@@ -80,7 +80,15 @@
.roleHint { .roleHint {
margin-top: 12px; margin-top: 12px;
margin-bottom: 8px;
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.45; line-height: 1.45;
} }
.roleListNested {
margin-top: 4px;
margin-inline-start: 12px;
padding-inline-start: 10px;
border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.2);
}
+194 -102
View File
@@ -8,9 +8,9 @@ import type { RoleOption, UserListItem, UsersListResponse } from '../types/user'
import type { ListUsersParams } from '../services/userService' import type { ListUsersParams } from '../services/userService'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
addTeamMember, assignBusinessAccess,
listTeamRoles, listTeamRoles,
updateTeamMemberRole, type BusinessAccess,
} from '../services/teamService' } from '../services/teamService'
import { import {
adminResetUserPassword, adminResetUserPassword,
@@ -41,14 +41,45 @@ function displayName(user: UserListItem) {
return name || '—' return name || '—'
} }
const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const
const TEAM_ROLE_LABELS: Record<string, string> = {
admin: 'Admin',
editor: 'Editor',
viewer: 'Viewer',
}
function teamRoleLabel(slug: string | null | undefined, teamRoles: RoleOption[]) {
if (!slug) return null
return (
TEAM_ROLE_LABELS[slug] ??
teamRoles.find((role) => role.slug === slug)?.name ??
slug
)
}
function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) {
if (user.isBusinessOwner) return 'Business Owner'
if (user.teamRole) {
const teamName = teamRoleLabel(user.teamRole, teamRoles)
return teamName ? `Manager · ${teamName}` : 'Manager'
}
if (user.roleSlug === 'business_staff' && user.teamRole) { if (user.roleSlug === 'business_staff' && user.teamRole) {
const teamName = teamRoles.find((role) => role.slug === user.teamRole)?.name ?? user.teamRole const teamName = teamRoleLabel(user.teamRole, teamRoles)
return `Business Staff · ${teamName}` return teamName ? `Manager · ${teamName}` : 'Manager'
} }
return user.roles return user.roles
} }
function formatBusinessUserRoleLabel(user: UserListItem, teamRoles: RoleOption[]) {
if (user.isBusinessOwner) return 'Business Owner'
if (user.businessMemberId && user.teamRole) {
return teamRoleLabel(user.teamRole, teamRoles) ?? 'Manager'
}
if (user.businessMemberId) return 'Manager'
return 'Customer'
}
export function UsersPage() { export function UsersPage() {
const { showToast } = useToast() const { showToast } = useToast()
const navigate = useNavigate() const navigate = useNavigate()
@@ -93,7 +124,8 @@ export function UsersPage() {
const [roleOpen, setRoleOpen] = useState(false) const [roleOpen, setRoleOpen] = useState(false)
const [roleUser, setRoleUser] = useState<UserListItem | null>(null) const [roleUser, setRoleUser] = useState<UserListItem | null>(null)
const [selectedRoleSlug, setSelectedRoleSlug] = useState('') const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('') const [businessAccess, setBusinessAccess] = useState<BusinessAccess>('customer')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('admin')
const [roleSubmitting, setRoleSubmitting] = useState(false) const [roleSubmitting, setRoleSubmitting] = useState(false)
const [roleError, setRoleError] = useState('') const [roleError, setRoleError] = useState('')
@@ -259,62 +291,90 @@ export function UsersPage() {
createLastName.trim().length >= 2 && createLastName.trim().length >= 2 &&
toE164CellNumber(createCell.trim()).length > 0 toE164CellNumber(createCell.trim()).length > 0
const orderedTeamRoles = useMemo(() => {
const bySlug = new Map(teamRoles.map((role) => [role.slug, role]))
const ordered = TEAM_ROLE_ORDER.map((slug) => bySlug.get(slug)).filter(
(role): role is RoleOption => Boolean(role),
)
const extras = teamRoles.filter(
(role) => !TEAM_ROLE_ORDER.includes(role.slug as (typeof TEAM_ROLE_ORDER)[number]),
)
return [...ordered, ...extras]
}, [teamRoles])
function openRoleChange(user: UserListItem) { function openRoleChange(user: UserListItem) {
if (businessFilter && user.isBusinessOwner) return
setRoleUser(user) setRoleUser(user)
setSelectedRoleSlug(user.roleSlug ?? '') setSelectedRoleSlug(user.roleSlug ?? '')
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '') if (businessFilter) {
const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner
setBusinessAccess(isStaff ? 'staff' : 'customer')
setSelectedTeamRoleSlug(
(isStaff && user.teamRole) || orderedTeamRoles[0]?.slug || 'admin',
)
} else {
setBusinessAccess('customer')
setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin')
}
setRoleError('') setRoleError('')
setRoleOpen(true) setRoleOpen(true)
} }
const needsTeamRole = const canSaveRole = businessFilter
selectedRoleSlug === 'business_staff' && ? !roleUser?.isBusinessOwner &&
businessFilter !== null && (businessAccess === 'customer' || Boolean(selectedTeamRoleSlug))
!roleUser?.isBusinessOwner : Boolean(selectedRoleSlug)
const canSaveRole =
Boolean(selectedRoleSlug) && (!needsTeamRole || Boolean(selectedTeamRoleSlug))
async function submitRoleChange() { async function submitRoleChange() {
if (!roleUser || !selectedRoleSlug || !canSaveRole) return if (!roleUser || !canSaveRole) return
setRoleSubmitting(true) setRoleSubmitting(true)
setRoleError('') setRoleError('')
try { try {
if (businessFilter) {
const result = await assignBusinessAccess(businessFilter.businessId, {
userId: roleUser.id,
access: businessAccess,
roleSlug: businessAccess === 'staff' ? selectedTeamRoleSlug : undefined,
})
const accessLabel =
businessAccess === 'staff'
? teamRoleLabel(selectedTeamRoleSlug, teamRoles) ?? 'Manager'
: 'Customer'
showToast(`Access updated to ${accessLabel}.`, 'success')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === roleUser.id
? {
...item,
roleSlug: businessAccess === 'staff' ? 'business_staff' : 'customer',
roles: accessLabel,
teamRole: businessAccess === 'staff' ? selectedTeamRoleSlug : null,
businessMemberId:
businessAccess === 'staff'
? result.member?.id != null
? Number(result.member.id)
: item.businessMemberId
: null,
isBusinessOwner: false,
}
: item,
),
}
})
} else {
await updateUserRole(roleUser.id, selectedRoleSlug) await updateUserRole(roleUser.id, selectedRoleSlug)
let nextTeamRole = roleUser.teamRole const roleName =
let nextMemberId = roleUser.businessMemberId roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug
if (needsTeamRole && businessFilter && selectedTeamRoleSlug) { showToast(`Role updated to ${roleName}.`, 'success')
if (roleUser.businessMemberId) {
await updateTeamMemberRole(
businessFilter.businessId,
roleUser.businessMemberId,
selectedTeamRoleSlug,
)
nextTeamRole = selectedTeamRoleSlug
} else {
const result = (await addTeamMember(businessFilter.businessId, {
cellNumber: roleUser.cellNumber,
firstName: roleUser.firstName ?? 'User',
lastName: roleUser.lastName ?? 'User',
roleSlug: selectedTeamRoleSlug,
})) as { member?: { id: number; teamRole: string } }
nextTeamRole = result.member?.teamRole ?? selectedTeamRoleSlug
nextMemberId = result.member?.id ?? null
}
}
const roleName = roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug
const teamName =
selectedRoleSlug === 'business_staff' && nextTeamRole
? teamRoles.find((role) => role.slug === nextTeamRole)?.name ?? nextTeamRole
: null
showToast(
teamName ? `Role updated to ${roleName} (${teamName}).` : `Role updated to ${roleName}.`,
'success',
)
setData((prev) => { setData((prev) => {
if (!prev) return prev if (!prev) return prev
@@ -325,18 +385,17 @@ export function UsersPage() {
? { ? {
...item, ...item,
roleSlug: selectedRoleSlug, roleSlug: selectedRoleSlug,
roles: teamName ? `${roleName} · ${teamName}` : roleName, roles: roleName,
teamRole: selectedRoleSlug === 'business_staff' ? nextTeamRole : item.teamRole,
businessMemberId:
selectedRoleSlug === 'business_staff' ? nextMemberId : item.businessMemberId,
} }
: item, : item,
), ),
} }
}) })
}
setRoleOpen(false) setRoleOpen(false)
setRoleUser(null) setRoleUser(null)
setSelectedTeamRoleSlug('') setSelectedTeamRoleSlug('admin')
} catch (err) { } catch (err) {
setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.') setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
} finally { } finally {
@@ -619,13 +678,16 @@ export function UsersPage() {
</td> </td>
<td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td> <td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td>
<td className={tableStyles.td}> <td className={tableStyles.td}>
{user.roles ? ( {(() => {
<span className={styles.roleBadge}> const label = businessFilter
{formatGlobalRoleLabel(user, teamRoles)} ? formatBusinessUserRoleLabel(user, teamRoles)
</span> : formatGlobalRoleLabel(user, teamRoles)
return label ? (
<span className={styles.roleBadge}>{label}</span>
) : ( ) : (
<span className={tableStyles.subText}></span> <span className={tableStyles.subText}></span>
)} )
})()}
</td> </td>
<td className={tableStyles.td}> <td className={tableStyles.td}>
{user.businesses ?? <span className={tableStyles.subText}></span>} {user.businesses ?? <span className={tableStyles.subText}></span>}
@@ -637,9 +699,17 @@ export function UsersPage() {
type="button" type="button"
className={tableStyles.controlBtn} className={tableStyles.controlBtn}
onClick={() => openRoleChange(user)} onClick={() => openRoleChange(user)}
title="Change role" title={
aria-label="Change role" businessFilter && user.isBusinessOwner
disabled={!user.isActive} ? 'Business owner cannot be changed'
: 'Change role'
}
aria-label={
businessFilter && user.isBusinessOwner
? 'Business owner cannot be changed'
: 'Change role'
}
disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)}
> >
<Shield size={16} /> <Shield size={16} />
</button> </button>
@@ -901,7 +971,7 @@ export function UsersPage() {
onClose={() => { onClose={() => {
setRoleOpen(false) setRoleOpen(false)
setRoleUser(null) setRoleUser(null)
setSelectedTeamRoleSlug('') setSelectedTeamRoleSlug('admin')
setRoleError('') setRoleError('')
}} }}
> >
@@ -911,9 +981,70 @@ export function UsersPage() {
</p> </p>
) : null} ) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}> <p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Select a role for {roleUser ? displayName(roleUser) : 'user'}. {businessFilter
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''} ? `Choose customer or manager access for ${roleUser ? displayName(roleUser) : 'user'} on ${businessFilter.businessName}.`
: `Select a role for ${roleUser ? displayName(roleUser) : 'user'}.`}
</p> </p>
{businessFilter ? (
<>
<div className={styles.roleList}>
<label className={styles.roleOption}>
<input
type="radio"
name="business-access"
value="customer"
checked={businessAccess === 'customer'}
onChange={() => setBusinessAccess('customer')}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>Customer</span>
</label>
<label className={styles.roleOption}>
<input
type="radio"
name="business-access"
value="staff"
checked={businessAccess === 'staff'}
onChange={() => {
setBusinessAccess('staff')
if (!selectedTeamRoleSlug) {
setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin')
}
}}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>Manager</span>
</label>
</div>
{businessAccess === 'staff' ? (
<>
<p className={styles.roleHint}>
Only a super admin can assign Admin. Admins can assign Editor or Viewer. Admins
cannot change other admins.
</p>
<div className={`${styles.roleList} ${styles.roleListNested}`}>
{orderedTeamRoles.map((role) => (
<label key={role.slug} className={styles.roleOption}>
<input
type="radio"
name="team-role"
value={role.slug}
checked={selectedTeamRoleSlug === role.slug}
onChange={() => setSelectedTeamRoleSlug(role.slug)}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>
{teamRoleLabel(role.slug, teamRoles) ?? role.name}
</span>
</label>
))}
</div>
</>
) : null}
</>
) : (
<div className={styles.roleList}> <div className={styles.roleList}>
{roles.map((role) => ( {roles.map((role) => (
<label key={role.slug} className={styles.roleOption}> <label key={role.slug} className={styles.roleOption}>
@@ -922,54 +1053,15 @@ export function UsersPage() {
name="user-role" name="user-role"
value={role.slug} value={role.slug}
checked={selectedRoleSlug === role.slug} checked={selectedRoleSlug === role.slug}
onChange={() => { onChange={() => setSelectedRoleSlug(role.slug)}
setSelectedRoleSlug(role.slug)
if (role.slug === 'business_staff') {
setSelectedTeamRoleSlug(
roleUser?.teamRole ?? (selectedTeamRoleSlug || teamRoles[0]?.slug || ''),
)
}
}}
disabled={roleSubmitting} disabled={roleSubmitting}
/> />
<span className={styles.roleOptionLabel}>{role.name}</span> <span className={styles.roleOptionLabel}>{role.name}</span>
</label> </label>
))} ))}
</div> </div>
{selectedRoleSlug === 'business_staff' && businessFilter && !roleUser?.isBusinessOwner && (
<>
<div style={{ height: 12 }} />
<div className={tableStyles.field}>
<label htmlFor="team-role">Team permissions</label>
<select
id="team-role"
value={selectedTeamRoleSlug}
onChange={(e) => setSelectedTeamRoleSlug(e.target.value)}
disabled={roleSubmitting}
>
<option value="">Select team role</option>
{teamRoles.map((role) => (
<option key={role.slug} value={role.slug}>
{role.name}
</option>
))}
</select>
</div>
</>
)} )}
{selectedRoleSlug === 'business_staff' && !businessFilter && (
<p className={styles.roleHint}>
Open users from a business to assign team permissions (admin, editor, viewer).
</p>
)}
{selectedRoleSlug === 'business_staff' && roleUser?.isBusinessOwner && (
<p className={styles.roleHint}>
Business owners already have full access. Team permissions do not apply to owners.
</p>
)}
<div style={{ height: 12 }} /> <div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}> <div className={tableStyles.actionsRow}>
<button <button
@@ -33,3 +33,40 @@ export async function updateTeamMemberRole(
body: { roleSlug }, body: { roleSlug },
}) })
} }
export type BusinessAccess = 'customer' | 'staff'
export interface AssignBusinessAccessPayload {
userId: number | string
access: BusinessAccess
roleSlug?: string
}
export interface AssignBusinessAccessResult {
message: string
access: BusinessAccess
member: {
id: number
userId: number
isOwner: boolean
teamRole: string | null
} | null
}
/** Set customer vs staff (admin/editor/viewer) for a user on one business. */
export async function assignBusinessAccess(
businessId: number | string,
payload: AssignBusinessAccessPayload,
) {
return apiRequest<AssignBusinessAccessResult>(`/businesses/${businessId}/team/access`, {
method: 'PATCH',
auth: true,
body: {
userId: String(payload.userId),
access: payload.access,
...(payload.access === 'staff' && payload.roleSlug
? { roleSlug: payload.roleSlug }
: {}),
},
})
}
+2 -2
View File
@@ -78,7 +78,7 @@ npm install
```bash ```bash
cd "../MeshkeeApp Backend" cd "../MeshkeeApp Backend"
cp .env.example .env # fill DATABASE_URL, JWT secrets, S3 keys, GROQ_API_KEY (or OPENAI_API_KEY) cp .env.example .env # fill DATABASE_URL, JWT secrets, S3 keys, OPENAI_API_KEY (or GROQ_API_KEY)
npm install npm install
# Run SQL migrations in database/migrations/ (in order) # Run SQL migrations in database/migrations/ (in order)
npx prisma generate npx prisma generate
@@ -141,7 +141,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/businesses` | Businesses list (migrate-from-old + delete-data for portfolios/blogs(+news)/customers + categories; single-select). **Add domain** accepts optional Git repo URL → provisions storefront on websites VM and sets `deploy_slug` | | `/businesses` | Businesses list (migrate-from-old + delete-data for portfolios/blogs(+news)/customers + categories; single-select). **Add domain** accepts optional Git repo URL → provisions storefront on websites VM and sets `deploy_slug` |
| `/businesses/:businessId/invoices` | Business invoices list | | `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) | | `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users | | `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
| `/websites` | Websites / domains (Deploy when `deploy_slug` set) | | `/websites` | Websites / domains (Deploy when `deploy_slug` set) |
| `/settings` | Platform settings (invoice templates + item templates) | | `/settings` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template | | `/settings/invoice-templates/new` | Create invoice template |