Add customer SMS/Excel tools, selection bar, and sticky import toasts.

Bulk and single SMS from Users, row selection with overflow summary, persistent large import toasts, and business SMS sender in Super Admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 14:12:57 +03:30
co-authored by Cursor
parent 2a32c46b2e
commit 4c3bf8324e
30 changed files with 1597 additions and 149 deletions
@@ -9,6 +9,8 @@ import {
} from '../services/blogService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -47,6 +49,7 @@ export function BlogCard({
// In FA UI, keep cards RTL even when title/abstract are English.
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
const websiteUrl = buildWebsiteContentUrl('blog', blog)
return (
<article className={cardStyles.card}>
@@ -120,6 +123,7 @@ export function BlogCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={verifyLabel}>
<button
type="button"
@@ -0,0 +1,90 @@
.wrap {
position: relative;
display: inline-flex;
}
.trigger {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.trigger:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.triggerOpen {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: fixed;
z-index: 1200;
isolation: isolate;
min-width: 200px;
padding: 6px;
border-radius: 14px;
border: 1px solid var(--glass-border);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
box-shadow:
0 18px 48px rgba(15, 23, 42, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
animation: menuIn 0.16s ease;
}
.menu::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
-webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
}
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 11px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-align: start;
transition: background 0.15s, color 0.15s;
}
.item:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes menuIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,193 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MoreHorizontal, Receipt, Shield, Ticket } from 'lucide-react'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import { fitFixedPopover } from '../utils/fitFixedPopover'
import styles from './CustomerActionsOverflowMenu.module.css'
type PopoverPosition = {
top: number
left: number
}
export interface CustomerActionsOverflowMenuProps {
customerName: string
canManageAccess: boolean
accessLocked: string | null
financeEnabled: boolean
disabled?: boolean
onChangeAccess: () => void
onViewInvoices: () => void
onTickets: () => void
}
export function CustomerActionsOverflowMenu({
customerName,
canManageAccess,
accessLocked,
financeEnabled,
disabled = false,
onChangeAccess,
onViewInvoices,
onTickets,
}: CustomerActionsOverflowMenuProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const items = [
canManageAccess
? {
key: 'access',
label: accessLocked ?? t('customers.access.change'),
icon: Shield,
disabled: Boolean(accessLocked) || disabled,
onClick: onChangeAccess,
}
: null,
financeEnabled
? {
key: 'invoices',
label: t('customers.invoices'),
icon: Receipt,
disabled,
onClick: onViewInvoices,
}
: null,
{
key: 'tickets',
label: t('customers.tickets'),
icon: Ticket,
disabled,
onClick: onTickets,
},
].filter(Boolean) as Array<{
key: string
label: string
icon: typeof Shield
disabled: boolean
onClick: () => void
}>
function updatePopoverPosition() {
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 200, height: 44 * Math.max(items.length, 1) + 12 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
function toggleOpen() {
if (disabled || items.length === 0) return
if (open) {
setOpen(false)
return
}
updatePopoverPosition()
setOpen(true)
}
useLayoutEffect(() => {
if (!open) return
updatePopoverPosition()
}, [open])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (wrapRef.current?.contains(target)) return
if (popoverRef.current?.contains(target)) return
setOpen(false)
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
function handleReposition() {
updatePopoverPosition()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
window.addEventListener('resize', handleReposition)
window.addEventListener('scroll', handleReposition, true)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
window.removeEventListener('resize', handleReposition)
window.removeEventListener('scroll', handleReposition, true)
}
}, [open])
if (items.length === 0) return null
const moreLabel = t('customers.moreActions')
return (
<div className={styles.wrap} ref={wrapRef}>
<Tooltip label={moreLabel}>
<button
ref={triggerRef}
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleOpen()
}}
aria-label={`${moreLabel}: ${customerName}`}
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
>
<MoreHorizontal size={16} />
</button>
</Tooltip>
{open &&
popoverPos &&
createPortal(
<div
ref={popoverRef}
className={styles.menu}
role="menu"
aria-label={`${moreLabel}: ${customerName}`}
style={{
top: popoverPos.top,
left: popoverPos.left,
}}
>
{items.map(({ key, label, icon: Icon, disabled: itemDisabled, onClick }) => (
<button
key={key}
type="button"
className={styles.item}
role="menuitem"
disabled={itemDisabled}
onClick={() => {
if (itemDisabled) return
setOpen(false)
onClick()
}}
>
<Icon size={16} />
<span>{label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
)
}
@@ -9,6 +9,8 @@ import {
} from '../services/instructionService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -49,6 +51,7 @@ export function InstructionCard({
: video.videoProvider === 'aparat'
? 'Aparat'
: null
const websiteUrl = buildWebsiteContentUrl('instruction', video)
return (
<article className={cardStyles.card}>
@@ -118,6 +121,7 @@ export function InstructionCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={t('instruction.card.comments')}>
<button
type="button"
@@ -0,0 +1,24 @@
import { ExternalLink } from 'lucide-react'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
interface OpenOnWebsiteControlProps {
href: string | null | undefined
}
/** Icon control that opens the public storefront page for this content. */
export function OpenOnWebsiteControl({ href }: OpenOnWebsiteControlProps) {
const t = useT()
const url = href?.trim()
if (!url) return null
const label = t('common.viewOnWebsite')
return (
<Tooltip label={label}>
<a href={url} target="_blank" rel="noopener noreferrer" aria-label={label}>
<ExternalLink size={16} />
</a>
</Tooltip>
)
}
@@ -8,6 +8,8 @@ import {
} from '../services/portfolioService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { Tooltip } from './Tooltip'
import cardStyles from './PortfolioCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -49,6 +51,7 @@ export function PortfolioCard({
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
const externalUrl = resolvePortfolioExternalUrl(portfolio.projectUrl)
const websiteUrl = buildWebsiteContentUrl('portfolio', portfolio)
return (
<article className={cardStyles.card}>
@@ -133,6 +136,7 @@ export function PortfolioCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
{externalUrl ? (
<Tooltip label={externalUrl}>
<a
@@ -17,6 +17,8 @@ import { Tooltip } from './Tooltip'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { hasBusinessModule } from '../utils/businessModules'
import { formatProductCategoryChip } from '../services/productService'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import styles from './ProductCard.module.css'
interface ProductCardProps {
@@ -57,6 +59,7 @@ export function ProductCard({
: ''
: product.nameFa
const categoryLabel = formatProductCategoryChip(product, isFa)
const websiteUrl = buildWebsiteContentUrl('product', product)
const imageSrc = product.image?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
@@ -111,6 +114,7 @@ export function ProductCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
{showTranslationControls ? (
<Tooltip label={t('products.card.translate')}>
<button
@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
sendCustomerSms,
sendCustomersBulkSms,
type BusinessCustomerListItem,
} from '../services/customerService'
import modalStyles from './VariationsModal.module.css'
export type SmsComposeTarget =
| { mode: 'single'; customer: BusinessCustomerListItem }
| { mode: 'selected'; userIds: string[]; count: number }
| { mode: 'all'; totalHint?: number }
interface SendCustomerSmsModalProps {
open: boolean
target: SmsComposeTarget | null
onClose: () => void
onSent: (info: { mode: SmsComposeTarget['mode']; count: number; phone?: string }) => void
}
const ANIMATION_MS = 220
const MAX_LEN = 700
export function SendCustomerSmsModal({
open,
target,
onClose,
onSent,
}: SendCustomerSmsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open && target) {
setMounted(true)
setClosing(false)
setMessage('')
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, target, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting])
if (!mounted || !target) return null
const trimmed = message.trim()
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
async function handleSubmit() {
if (!target || !canSend) return
setIsSubmitting(true)
setError('')
try {
if (target.mode === 'single') {
const result = await sendCustomerSms(target.customer.id, trimmed)
if (!result.enabled) {
setError(result.message || t('customers.smsModal.errorDisabled'))
return
}
onSent({
mode: 'single',
count: 1,
phone: formatCellForDisplay(target.customer.cellNumber),
})
} else {
const userIds = target.mode === 'selected' ? target.userIds : undefined
const result = await sendCustomersBulkSms(trimmed, userIds)
if (!result.enabled) {
setError(result.message || t('customers.smsModal.errorDisabled'))
return
}
onSent({ mode: target.mode, count: result.recipientCount })
}
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : t('customers.smsModal.error'))
} finally {
setIsSubmitting(false)
}
}
let subtitle: string
if (target.mode === 'single') {
const phone = formatCellForDisplay(target.customer.cellNumber)
const name = [target.customer.firstName, target.customer.lastName]
.filter(Boolean)
.join(' ')
.trim()
subtitle = t('customers.smsModal.subtitle', { name: name || phone, phone })
} else if (target.mode === 'selected') {
subtitle = t('customers.smsModal.subtitleSelected', { count: target.count })
} else {
subtitle = t('customers.smsModal.subtitleAll')
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="send-customer-sms-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<div>
<h3 id="send-customer-sms-title" className={modalStyles.title}>
{t('customers.smsModal.title')}
</h3>
<p className={modalStyles.subtitle}>{subtitle}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<div className={modalStyles.body}>
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<textarea
id="customer-sms-message"
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5}
disabled={isSubmitting}
placeholder={t('customers.smsModal.placeholder')}
dir={isFa ? 'rtl' : 'auto'}
/>
<p className={modalStyles.modalHint}>
{message.length} / {MAX_LEN}
</p>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('customers.smsModal.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -27,6 +27,48 @@
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastLarge {
min-width: 300px;
max-width: min(520px, calc(100vw - 48px));
padding: 16px 18px;
font-size: 14px;
line-height: 1.55;
}
.toastPersistent {
display: flex;
align-items: flex-start;
gap: 12px;
}
.toastBody {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
}
.closeBtn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: -4px -6px -4px 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.closeBtn:hover {
background: rgba(148, 163, 184, 0.18);
color: var(--text-primary);
}
.toastIn {
animation: toastIn 0.22s ease forwards;
}
@@ -9,6 +9,8 @@ import {
} from '../services/videoService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -41,6 +43,7 @@ export function VideoCard({
const hasAbstract = Boolean(video.abstract?.trim())
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
const websiteUrl = buildWebsiteContentUrl('video', video)
return (
<article className={cardStyles.card}>
@@ -108,6 +111,7 @@ export function VideoCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={t('video.card.comments')}>
<button
type="button"
@@ -9,6 +9,8 @@ import {
} from '../services/workshopService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -45,6 +47,7 @@ export function WorkshopCard({
const statusLabel = published ? t('workshop.card.unpublish') : t('workshop.card.publish')
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
const websiteUrl = buildWebsiteContentUrl('workshop', workshop)
return (
<article className={cardStyles.card}>
@@ -124,6 +127,7 @@ export function WorkshopCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={statusLabel}>
<button
type="button"
+47 -9
View File
@@ -9,18 +9,28 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
export type ToastOptions = {
/** Stay until the user clicks close (no auto-dismiss). */
persistent?: boolean
/** Wider toast for longer summaries (e.g. Excel import). */
size?: 'default' | 'large'
}
interface ToastItem {
id: number
message: string
variant: ToastVariant
persistent: boolean
size: 'default' | 'large'
}
interface ToastContextValue {
showToast: (message: string, variant?: ToastVariant) => void
showToast: (message: string, variant?: ToastVariant, options?: ToastOptions) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
@@ -55,15 +65,25 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}, [])
const showToast = useCallback(
(message: string, variant: ToastVariant = 'info') => {
(message: string, variant: ToastVariant = 'info', options?: ToastOptions) => {
clearTimers()
idRef.current += 1
setClosing(false)
setToast({ id: idRef.current, message, variant })
const persistent = Boolean(options?.persistent)
const size = options?.size ?? 'default'
setToast({
id: idRef.current,
message,
variant,
persistent,
size,
})
dismissTimerRef.current = setTimeout(() => {
dismissToast()
}, TOAST_DURATION_MS)
if (!persistent) {
dismissTimerRef.current = setTimeout(() => {
dismissToast()
}, TOAST_DURATION_MS)
}
},
[clearTimers, dismissToast],
)
@@ -80,10 +100,28 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
className={[
styles.toast,
styles[toast.variant],
toast.size === 'large' ? styles.toastLarge : '',
toast.persistent ? styles.toastPersistent : '',
closing ? styles.toastOut : styles.toastIn,
]
.filter(Boolean)
.join(' ')}
role={toast.persistent ? 'alertdialog' : 'status'}
>
{toast.message}
<div className={styles.toastBody}>{toast.message}</div>
{toast.persistent ? (
<button
type="button"
className={styles.closeBtn}
onClick={dismissToast}
aria-label="Close"
>
<X size={16} />
</button>
) : null}
</div>
)}
</div>,
+2 -25
View File
@@ -1,4 +1,5 @@
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install. */
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install.
Only real faces are registered. Mapping 500–700 to Regular blocks browser synthetic bold. */
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
@@ -14,27 +15,3 @@
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+60
View File
@@ -1079,6 +1079,13 @@ const en = {
'customers.filter.access.managers': 'Managers',
'customers.search': 'Search',
'customers.clearFilters': 'Clear filters',
'customers.excel.export': 'Export Excel',
'customers.excel.import': 'Import Excel',
'customers.excel.exportError': 'Unable to export Excel.',
'customers.excel.importError': 'Unable to import Excel.',
'customers.excel.importEmpty': 'Choose an .xlsx file with a phone column.',
'customers.excel.importSuccess':
'Imported {created} new\nLinked {linked} existing\nSkipped: {skipped}',
'customers.listTitle': 'User list',
'customers.showing': 'Showing {from} - {to} of {total}',
'customers.none': 'No customers',
@@ -1105,6 +1112,26 @@ const en = {
'customers.editModal.save': 'Save',
'customers.editModal.error': 'Unable to update customer.',
'customers.sms': 'Send SMS',
'customers.smsBulk': 'Send SMS',
'customers.selectAll': 'Select all on this page',
'customers.selectRow': 'Select {name}',
'customers.selection.label': 'Selected',
'customers.selection.andOthers': 'and {count} others',
'customers.selection.total': '{count} selected',
'customers.moreActions': 'More actions',
'customers.smsModal.title': 'Send SMS',
'customers.smsModal.subtitle': 'Send a message to {name} ({phone}).',
'customers.smsModal.subtitleSelected':
'Send one message to {count} selected recipients.',
'customers.smsModal.subtitleAll':
'No recipients selected — send to all enabled customers.',
'customers.smsModal.message': 'Message',
'customers.smsModal.placeholder': 'Write your SMS…',
'customers.smsModal.cancel': 'Cancel',
'customers.smsModal.send': 'Send',
'customers.smsModal.sending': 'Sending…',
'customers.smsModal.error': 'Unable to send SMS.',
'customers.smsModal.errorDisabled': 'SMS is disabled. Message was not sent.',
'customers.tickets': 'Tickets',
'customers.invoices': 'View invoices',
'customers.remove': 'Remove customer',
@@ -1118,6 +1145,8 @@ const en = {
'customers.toast.updated': '"{name}" has been updated.',
'customers.toast.added': '"{name}" has been added.',
'customers.toast.smsSoon': 'SMS to {phone} is not available yet.',
'customers.toast.smsSent': 'SMS sent to {phone}.',
'customers.toast.smsSentBulk': 'SMS sent to {count} recipients.',
'customers.toast.ticketsSoon': 'Tickets for "{name}" are not available yet.',
'customers.deleteTitle': 'Remove customer?',
'customers.deleteMessage':
@@ -2355,6 +2384,7 @@ const en = {
'common.close': 'Close',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.viewOnWebsite': 'View on website',
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
'common.codeSent': 'Verification code sent to {phone}',
@@ -3461,6 +3491,13 @@ const fa: Record<MessageKey, string> = {
'customers.filter.access.managers': 'مدیران سیستم',
'customers.search': 'جستجو',
'customers.clearFilters': 'پاک کردن فیلترها',
'customers.excel.export': 'خروجی اکسل',
'customers.excel.import': 'ورود اکسل',
'customers.excel.exportError': 'خروجی اکسل ممکن نشد.',
'customers.excel.importError': 'ورود اکسل ممکن نشد.',
'customers.excel.importEmpty': 'یک فایل xlsx با ستون phone انتخاب کنید.',
'customers.excel.importSuccess':
'{created} جدید\n{linked} موجود وصل شد\nنادیده: {skipped}',
'customers.listTitle': 'فهرست کاربران',
'customers.showing': 'نمایش {from} تا {to} از {total}',
'customers.none': 'بدون مشتری',
@@ -3487,6 +3524,26 @@ const fa: Record<MessageKey, string> = {
'customers.editModal.save': 'ذخیره',
'customers.editModal.error': 'به‌روزرسانی مشتری ممکن نشد.',
'customers.sms': 'ارسال پیامک',
'customers.smsBulk': 'ارسال پیامک',
'customers.selectAll': 'انتخاب همه در این صفحه',
'customers.selectRow': 'انتخاب {name}',
'customers.selection.label': 'انتخاب‌شده‌ها',
'customers.selection.andOthers': 'و {count} نفر دیگر',
'customers.selection.total': '{count} انتخاب',
'customers.moreActions': 'عملیات بیشتر',
'customers.smsModal.title': 'ارسال پیامک',
'customers.smsModal.subtitle': 'پیام به {name} ({phone}) ارسال شود.',
'customers.smsModal.subtitleSelected':
'یک پیام به {count} گیرنده انتخاب‌شده ارسال شود.',
'customers.smsModal.subtitleAll':
'گیرنده‌ای انتخاب نشده — پیام به همه مشتریان فعال ارسال می‌شود.',
'customers.smsModal.message': 'متن پیام',
'customers.smsModal.placeholder': 'متن پیامک را بنویسید…',
'customers.smsModal.cancel': 'انصراف',
'customers.smsModal.send': 'ارسال',
'customers.smsModal.sending': 'در حال ارسال…',
'customers.smsModal.error': 'ارسال پیامک ممکن نشد.',
'customers.smsModal.errorDisabled': 'پیامک غیرفعال است. پیام ارسال نشد.',
'customers.tickets': 'تیکت‌ها',
'customers.invoices': 'مشاهده فاکتورها',
'customers.remove': 'حذف مشتری',
@@ -3500,6 +3557,8 @@ const fa: Record<MessageKey, string> = {
'customers.toast.updated': '«{name}» به‌روزرسانی شد.',
'customers.toast.added': '«{name}» اضافه شد.',
'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.',
'customers.toast.smsSent': 'پیامک به {phone} ارسال شد.',
'customers.toast.smsSentBulk': 'پیامک به {count} گیرنده ارسال شد.',
'customers.toast.ticketsSoon': 'تیکت‌های «{name}» هنوز در دسترس نیست.',
'customers.deleteTitle': 'حذف مشتری؟',
'customers.deleteMessage':
@@ -4739,6 +4798,7 @@ const fa: Record<MessageKey, string> = {
'common.close': 'بستن',
'common.cancel': 'انصراف',
'common.delete': 'حذف',
'common.viewOnWebsite': 'مشاهده در وبسایت',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
+111 -11
View File
@@ -6,6 +6,76 @@
overflow: hidden;
}
.selectionBar {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 8px 14px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.selectionLabel {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
}
.selectionNames {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
font-family: var(--font-ui);
}
.selectionNameItem {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.selectionName,
.selectionMore {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.selectionMore {
color: var(--text-secondary);
font-weight: 500;
}
.selectionSep {
display: inline-block;
width: 1px;
height: 12px;
margin-inline: 8px;
background: rgba(148, 163, 184, 0.45);
flex-shrink: 0;
}
.selectionTotal {
flex-shrink: 0;
margin-inline-start: auto;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
@@ -40,8 +110,12 @@
table-layout: fixed;
}
.colCheck {
width: 44px;
}
.colName {
width: 24%;
width: 22%;
}
.colCell {
@@ -61,12 +135,12 @@
}
.colActions {
width: 240px;
width: 220px;
}
.th,
.td {
padding: 10px 12px;
padding: 14px 14px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
@@ -121,38 +195,54 @@
white-space: nowrap;
}
.thCheck,
.tdCheck {
width: 44px;
text-align: center;
padding-inline: 8px;
vertical-align: middle;
}
.rowCheck {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
.tdActions {
text-align: right;
white-space: nowrap;
padding-inline-end: 10px;
padding-inline-end: 14px;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
gap: 4px;
}
/* FA: عملیات column is on the physical left. */
.tableFa .thActions,
.tableFa .tdActions {
text-align: left;
padding-left: 10px;
padding-right: 10px;
padding-left: 14px;
padding-right: 14px;
}
.toggleInActions {
margin-inline-end: 10px;
margin-inline-end: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
height: 28px;
height: 30px;
}
.actionBtn {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -278,6 +368,16 @@
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.spin {
animation: customersSpin 0.8s linear infinite;
}
@keyframes customersSpin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.fab {
inset-inline-end: 20px;
+355 -62
View File
@@ -1,15 +1,28 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2, Users } from 'lucide-react'
import {
Download,
Loader2,
MessageSquare,
Pencil,
Plus,
RotateCcw,
Search,
Trash2,
Upload,
Users,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { CustomerActionsOverflowMenu } from '../components/CustomerActionsOverflowMenu'
import { EditCustomerModal } from '../components/EditCustomerModal'
import { EmptyTableRow } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { Pagination } from '../components/Pagination'
import { SendCustomerSmsModal, type SmsComposeTarget } from '../components/SendCustomerSmsModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip'
import { useAuth } from '../context/AuthContext'
@@ -21,6 +34,8 @@ import { ApiError, isAbortError } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
downloadCustomersExcel,
importCustomersExcel,
listCustomers,
removeCustomer,
updateCustomerEnabled,
@@ -37,7 +52,7 @@ import accessStyles from '../components/ChangeUserAccessModal.module.css'
import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 6
const COLUMN_COUNT = 7
function formatDate(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
@@ -51,6 +66,11 @@ function displayName(customer: BusinessCustomerListItem) {
return name || '—'
}
function selectionLabel(customer: BusinessCustomerListItem) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
return name || formatCellForDisplay(customer.cellNumber)
}
function formatTransactionTotal(total: number | null | undefined) {
if (total == null || total === 0) {
return <span className={styles.subText}>—</span>
@@ -101,6 +121,13 @@ export function CustomersPage() {
const [createOpen, setCreateOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
const [removing, setRemoving] = useState(false)
const [smsTarget, setSmsTarget] = useState<SmsComposeTarget | null>(null)
const [selectedPeople, setSelectedPeople] = useState<Map<string, string>>(() => new Map())
const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null)
const [reloadToken, setReloadToken] = useState(0)
const importInputRef = useRef<HTMLInputElement>(null)
const selectionNamesRef = useRef<HTMLDivElement>(null)
const [visibleSelectedCount, setVisibleSelectedCount] = useState(0)
const membership = useMemo(() => {
const activeId = getActiveBusinessId()
@@ -156,7 +183,7 @@ export function CustomersPage() {
return () => {
controller.abort()
}
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, reloadToken, t])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
@@ -173,6 +200,106 @@ export function CustomersPage() {
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
const pageIds = useMemo(() => data?.items.map((item) => item.id) ?? [], [data?.items])
const selectedIds = useMemo(() => new Set(selectedPeople.keys()), [selectedPeople])
const selectedNames = useMemo(() => Array.from(selectedPeople.values()), [selectedPeople])
const allPageSelected =
pageIds.length > 0 && pageIds.every((id) => selectedPeople.has(id))
const somePageSelected = pageIds.some((id) => selectedPeople.has(id))
useLayoutEffect(() => {
const el = selectionNamesRef.current
if (!el || selectedNames.length === 0) {
setVisibleSelectedCount(0)
return
}
const probe = document.createElement('span')
const style = getComputedStyle(el)
probe.style.cssText = [
'position:absolute',
'visibility:hidden',
'pointer-events:none',
'white-space:nowrap',
`font:${style.font}`,
`letter-spacing:${style.letterSpacing}`,
`font-weight:${style.fontWeight}`,
].join(';')
document.body.appendChild(probe)
/** Matches `.selectionSep` (1px line + horizontal padding). */
const SEP_WIDTH = 17
const textWidth = (text: string) => {
probe.textContent = text
return probe.offsetWidth
}
const measure = () => {
const maxWidth = el.clientWidth
if (maxWidth <= 0) {
setVisibleSelectedCount(selectedNames.length)
return
}
const nameWidths = selectedNames.map((name) => textWidth(name))
const totalNamesWidth =
nameWidths.reduce((sum, w) => sum + w, 0) +
Math.max(0, selectedNames.length - 1) * SEP_WIDTH
if (totalNamesWidth <= maxWidth) {
setVisibleSelectedCount(selectedNames.length)
return
}
let fit = 1
for (let count = selectedNames.length - 1; count >= 1; count -= 1) {
const rest = selectedNames.length - count
const moreLabel = t('customers.selection.andOthers', { count: rest })
const width =
nameWidths.slice(0, count).reduce((sum, w) => sum + w, 0) +
textWidth(moreLabel) +
count * SEP_WIDTH
if (width <= maxWidth) {
fit = count
break
}
}
setVisibleSelectedCount(fit)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => {
ro.disconnect()
probe.remove()
}
}, [selectedNames, locale, t])
function toggleSelectAllPage() {
setSelectedPeople((prev) => {
const next = new Map(prev)
if (allPageSelected) {
for (const id of pageIds) next.delete(id)
} else {
for (const customer of data?.items ?? []) {
next.set(customer.id, selectionLabel(customer))
}
}
return next
})
}
function toggleSelectRow(customer: BusinessCustomerListItem) {
setSelectedPeople((prev) => {
const next = new Map(prev)
if (next.has(customer.id)) next.delete(customer.id)
else next.set(customer.id, selectionLabel(customer))
return next
})
}
function applyFilters() {
setPage(1)
setAppliedFilters({
@@ -233,6 +360,12 @@ export function CustomersPage() {
setError('')
try {
await removeCustomer(removeTarget.id)
setSelectedPeople((prev) => {
if (!prev.has(removeTarget.id)) return prev
const next = new Map(prev)
next.delete(removeTarget.id)
return next
})
setData((prev) => {
if (!prev) return prev
return {
@@ -255,10 +388,19 @@ export function CustomersPage() {
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
'info',
)
setSmsTarget({ mode: 'single', customer })
}
function handleBulkSms() {
if (selectedIds.size > 0) {
setSmsTarget({
mode: 'selected',
userIds: Array.from(selectedIds),
count: selectedIds.size,
})
return
}
setSmsTarget({ mode: 'all', totalHint: data?.total })
}
function handleTickets(customer: BusinessCustomerListItem) {
@@ -316,6 +458,59 @@ export function CustomersPage() {
showToast(t('customers.toast.added', { name: displayName(customer) }), 'success')
}
async function handleExportExcel() {
if (excelBusy) return
setExcelBusy('export')
setError('')
try {
await downloadCustomersExcel()
} catch (err) {
setError(err instanceof ApiError ? err.message : t('customers.excel.exportError'))
} finally {
setExcelBusy(null)
}
}
function openImportPicker() {
if (excelBusy) return
importInputRef.current?.click()
}
async function handleImportExcel(fileList: FileList | null) {
const file = fileList?.[0]
if (importInputRef.current) {
importInputRef.current.value = ''
}
if (!file) return
const name = file.name.toLowerCase()
if (!name.endsWith('.xlsx')) {
setError(t('customers.excel.importEmpty'))
return
}
setExcelBusy('import')
setError('')
try {
const result = await importCustomersExcel(file)
showToast(
t('customers.excel.importSuccess', {
created: result.created,
linked: result.linked,
skipped: result.skipped,
}),
'success',
{ persistent: true, size: 'large' },
)
setPage(1)
setReloadToken((token) => token + 1)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('customers.excel.importError'))
} finally {
setExcelBusy(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
@@ -364,29 +559,113 @@ export function CustomersPage() {
</select>
</div>
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={loading}
aria-label={t('customers.search')}
title={t('customers.search')}
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label={t('customers.clearFilters')}
title={t('customers.clearFilters')}
>
<RotateCcw size={18} />
</button>
<input
ref={importInputRef}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
onChange={(e) => void handleImportExcel(e.target.files)}
/>
<Tooltip label={t('customers.excel.export')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={() => void handleExportExcel()}
disabled={loading || !!excelBusy}
aria-label={t('customers.excel.export')}
>
{excelBusy === 'export' ? (
<Loader2 size={18} className={styles.spin} />
) : (
<Download size={18} />
)}
</button>
</Tooltip>
<Tooltip label={t('customers.excel.import')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={openImportPicker}
disabled={loading || !!excelBusy}
aria-label={t('customers.excel.import')}
>
{excelBusy === 'import' ? (
<Loader2 size={18} className={styles.spin} />
) : (
<Upload size={18} />
)}
</button>
</Tooltip>
<Tooltip label={t('customers.smsBulk')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={handleBulkSms}
disabled={loading || !!excelBusy || (!data?.total && selectedIds.size === 0)}
aria-label={t('customers.smsBulk')}
>
<MessageSquare size={18} />
</button>
</Tooltip>
<Tooltip label={t('customers.search')}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={loading || !!excelBusy}
aria-label={t('customers.search')}
>
<Search size={18} />
</button>
</Tooltip>
<Tooltip label={t('customers.clearFilters')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading || !!excelBusy}
aria-label={t('customers.clearFilters')}
>
<RotateCcw size={18} />
</button>
</Tooltip>
</div>
</form>
</div>
{selectedNames.length > 0 ? (
<div
className={styles.selectionBar}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
aria-live="polite"
>
<span className={styles.selectionLabel}>{t('customers.selection.label')}</span>
<div ref={selectionNamesRef} className={styles.selectionNames}>
{(visibleSelectedCount > 0
? selectedNames.slice(0, visibleSelectedCount)
: selectedNames
).map((name, index) => (
<span key={`${name}-${index}`} className={styles.selectionNameItem}>
{index > 0 ? <span className={styles.selectionSep} aria-hidden /> : null}
<span className={styles.selectionName}>{name}</span>
</span>
))}
{visibleSelectedCount > 0 && selectedNames.length > visibleSelectedCount ? (
<span className={styles.selectionNameItem}>
<span className={styles.selectionSep} aria-hidden />
<span className={styles.selectionMore}>
{t('customers.selection.andOthers', {
count: selectedNames.length - visibleSelectedCount,
})}
</span>
</span>
) : null}
</div>
<span className={styles.selectionTotal}>
{t('customers.selection.total', { count: selectedNames.length })}
</span>
</div>
) : null}
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
@@ -414,6 +693,7 @@ export function CustomersPage() {
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
<colgroup>
<col className={styles.colCheck} />
<col className={styles.colName} />
<col className={styles.colCell} />
<col className={styles.colOrders} />
@@ -423,6 +703,19 @@ export function CustomersPage() {
</colgroup>
<thead>
<tr>
<th className={`${styles.th} ${styles.thCheck}`}>
<input
type="checkbox"
className={styles.rowCheck}
checked={allPageSelected}
ref={(el) => {
if (el) el.indeterminate = somePageSelected && !allPageSelected
}}
onChange={toggleSelectAllPage}
disabled={loading || pageIds.length === 0}
aria-label={t('customers.selectAll')}
/>
</th>
<th className={styles.th}>{t('customers.col.name')}</th>
<th className={styles.th}>{t('customers.col.cell')}</th>
<th className={styles.th}>{t('customers.col.orders')}</th>
@@ -461,6 +754,15 @@ export function CustomersPage() {
key={customer.id}
className={!customer.isEnabled ? styles.inactiveRow : undefined}
>
<td className={`${styles.td} ${styles.tdCheck}`}>
<input
type="checkbox"
className={styles.rowCheck}
checked={selectedIds.has(customer.id)}
onChange={() => toggleSelectRow(customer)}
aria-label={t('customers.selectRow', { name })}
/>
</td>
<td className={styles.td}>
<div
className={[
@@ -521,19 +823,6 @@ export function CustomersPage() {
/>
</span>
</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')}>
<button
type="button"
@@ -544,18 +833,6 @@ export function CustomersPage() {
<Pencil size={15} />
</button>
</Tooltip>
{financeEnabled ? (
<Tooltip label={t('customers.invoices')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleViewInvoices(customer)}
aria-label={t('customers.invoices')}
>
<Receipt size={15} />
</button>
</Tooltip>
) : null}
<Tooltip label={t('customers.sms')}>
<button
type="button"
@@ -566,16 +843,6 @@ export function CustomersPage() {
<MessageSquare size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.tickets')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleTickets(customer)}
aria-label={t('customers.tickets')}
>
<Ticket size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.remove')}>
<button
type="button"
@@ -587,6 +854,16 @@ export function CustomersPage() {
<Trash2 size={15} />
</button>
</Tooltip>
<CustomerActionsOverflowMenu
customerName={name}
canManageAccess={canManageAccess}
accessLocked={accessLocked}
financeEnabled={financeEnabled}
disabled={removing}
onChangeAccess={() => setAccessTarget(customer)}
onViewInvoices={() => handleViewInvoices(customer)}
onTickets={() => handleTickets(customer)}
/>
</div>
</td>
</tr>
@@ -628,6 +905,22 @@ export function CustomersPage() {
onSaved={handleCustomerSaved}
/>
<SendCustomerSmsModal
open={smsTarget !== null}
target={smsTarget}
onClose={() => setSmsTarget(null)}
onSent={(info) => {
if (info.mode === 'single' && info.phone) {
showToast(t('customers.toast.smsSent', { phone: info.phone }), 'success')
} else {
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
if (info.mode === 'selected') {
setSelectedPeople(new Map())
}
}
}}
/>
<ChangeUserAccessModal
open={accessTarget !== null}
user={accessTarget}
@@ -304,6 +304,7 @@ function StoreItemsPageContent() {
unchanged: result.unchanged,
}),
'success',
{ persistent: true, size: 'large' },
)
await loadItems()
} catch (err) {
+82 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '../lib/api'
import { apiRequest, apiRequestBlob, apiRequestForm } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface BusinessCustomer {
@@ -131,3 +131,84 @@ export async function searchCustomers(
{ auth: true, signal },
)
}
export async function downloadCustomersExcel() {
const { blob } = await apiRequestBlob(businessPath('/export'), { auth: true })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = customersExcelFilename()
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function customersExcelFilename(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? ''
return `customers - ${pick('year')}-${pick('month')}-${pick('day')} - ${pick('hour')}-${pick('minute')}.xlsx`
}
export interface CustomersExcelImportResult {
message: string
total: number
created: number
linked: number
skipped: number
skipSamples: string[]
}
export async function importCustomersExcel(file: File) {
const formData = new FormData()
formData.append('file', file)
return apiRequestForm<CustomersExcelImportResult>(businessPath('/import'), formData, {
auth: true,
})
}
export interface SendCustomerSmsResult {
enabled: boolean
message: string
serverId?: string
source?: string
}
export interface SendCustomersBulkSmsResult {
enabled: boolean
message: string
recipientCount: number
serverIds?: string[]
source?: string
}
export async function sendCustomerSms(userId: string, message: string) {
return apiRequest<SendCustomerSmsResult>(businessPath(`/${userId}/sms`), {
method: 'POST',
auth: true,
body: { message },
})
}
/** Bulk SMS via Gama SendBulk. Omit userIds (or pass []) to send to all enabled customers. */
export async function sendCustomersBulkSms(message: string, userIds?: string[]) {
return apiRequest<SendCustomersBulkSmsResult>(businessPath('/sms'), {
method: 'POST',
auth: true,
body: {
message,
...(userIds && userIds.length > 0 ? { userIds } : {}),
},
})
}
@@ -0,0 +1,35 @@
const GAP = 8
const VIEWPORT_PAD = 8
export type FixedPopoverCoords = {
top: number
left: number
}
export function fitFixedPopover(
trigger: DOMRect,
size: { width: number; height: number },
align: 'end' | 'center' = 'end',
): FixedPopoverCoords {
const vw = window.innerWidth
const vh = window.innerHeight
const width = Math.max(1, size.width)
const height = Math.max(1, size.height)
const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD
const spaceAbove = trigger.top - VIEWPORT_PAD
const placeBelow = spaceBelow >= height + GAP || spaceBelow >= spaceAbove
let top = placeBelow ? trigger.bottom + GAP : trigger.top - GAP - height
top = Math.min(Math.max(top, VIEWPORT_PAD), Math.max(VIEWPORT_PAD, vh - VIEWPORT_PAD - height))
let left =
align === 'center'
? trigger.left + trigger.width / 2 - width / 2
: trigger.right - width
const maxLeft = Math.max(VIEWPORT_PAD, vw - VIEWPORT_PAD - width)
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft)
return { top, left }
}
+80
View File
@@ -2,6 +2,14 @@ import { getBusinessDomain } from '../lib/config'
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' | 'workshop'
export type WebsiteContentKind =
| 'product'
| 'blog'
| 'portfolio'
| 'video'
| 'instruction'
| 'workshop'
export type WebsiteLinkFilter = 'category' | 'tag'
export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
@@ -22,12 +30,84 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
workshop: '/workshops',
}
/** Matches backend `slugifyForUrl` used by website sitemaps / SEO paths. */
export function slugifyForWebsitePath(value: string, fallback = 'item'): string {
const slug = value
.trim()
.normalize('NFC')
.replace(/\s+/g, '-')
.replace(
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
'',
)
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return slug || fallback
}
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
return `${protocol}//${host}`
}
/**
* Public storefront detail URL for a content item (canonical Meshkee website paths).
* Returns '' when a required slug/title is missing.
*/
export function buildWebsiteContentUrl(
kind: WebsiteContentKind,
item: {
id: string
slug?: string | null
title?: string | null
titleFa?: string | null
nameFa?: string | null
nameEn?: string | null
},
domain = getBusinessDomain(),
): string {
const origin = getWebsiteOrigin(domain)
const id = item.id?.trim()
if (!id) return ''
switch (kind) {
case 'product': {
const pathSlug = slugifyForWebsitePath(
item.nameFa?.trim() || item.nameEn?.trim() || item.title?.trim() || '',
'product',
)
return `${origin}/products/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
}
case 'blog': {
const pathSlug = slugifyForWebsitePath(item.title?.trim() || '', 'blog')
return `${origin}/blog/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
}
case 'portfolio': {
const pathSlug = slugifyForWebsitePath(
item.titleFa?.trim() || item.title?.trim() || '',
'portfolio',
)
return `${origin}/portfolios/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
}
case 'video': {
const slug = item.slug?.trim()
return slug ? `${origin}/videos/${encodeURIComponent(slug)}` : ''
}
case 'instruction': {
const slug = item.slug?.trim()
return slug ? `${origin}/instructions/${encodeURIComponent(slug)}` : ''
}
case 'workshop': {
const slug = item.slug?.trim()
return slug ? `${origin}/workshops/${encodeURIComponent(slug)}` : ''
}
default:
return ''
}
}
export function buildWebsiteCategoryUrl(
kind: WebsiteLinkKind,
category: { id: string; slug?: string | null },
+2 -25
View File
@@ -1,4 +1,5 @@
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install. */
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install.
Only real faces are registered. Mapping 500–700 to Regular blocks browser synthetic bold. */
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
@@ -14,27 +15,3 @@
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@@ -41,7 +41,16 @@
.field {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
}
/* Stacked fields in modals: keep label glued to its input, space from the field above */
.field + .field {
margin-top: 14px;
}
.formGrid > .field + .field {
margin-top: 0;
}
.fieldCol3 {
@@ -52,11 +61,13 @@
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
}
.helperText {
margin: 0;
font-size: 13px;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
@@ -339,6 +339,7 @@ export function BusinessesPage() {
const [editNameFa, setEditNameFa] = useState('')
const [editAbout, setEditAbout] = useState('')
const [editAnnualRenewalAt, setEditAnnualRenewalAt] = useState('')
const [editSmsSenderNumber, setEditSmsSenderNumber] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
@@ -520,6 +521,7 @@ export function BusinessesPage() {
setEditAnnualRenewalAt(
toDateInputValue(b.annualRenewalAt || b.createdAt),
)
setEditSmsSenderNumber(b.smsSenderNumber ?? '')
setEditError('')
setEditOpen(true)
}
@@ -862,12 +864,14 @@ export function BusinessesPage() {
: editBusiness.createdAt
? new Date(editBusiness.createdAt).toISOString()
: null
const smsSenderNumber = editSmsSenderNumber.replace(/\D/g, '')
try {
await updateBusiness(editBusiness.id, {
name,
nameFa,
about: editAbout.trim(),
annualRenewalAt,
smsSenderNumber: smsSenderNumber || null,
})
setData((prev) => {
if (!prev) return prev
@@ -881,6 +885,7 @@ export function BusinessesPage() {
nameFa,
about: editAbout.trim() || null,
annualRenewalAt,
smsSenderNumber: smsSenderNumber || null,
}
: item,
),
@@ -1961,6 +1966,22 @@ export function BusinessesPage() {
placeholder="Select renewal date"
/>
</div>
<div className={styles.field}>
<label htmlFor="edit-sms-sender">SMS sender number</label>
<input
id="edit-sms-sender"
inputMode="numeric"
value={editSmsSenderNumber}
onChange={(e) => setEditSmsSenderNumber(e.target.value.replace(/\D/g, '').slice(0, 16))}
placeholder="e.g. 5000123456 (empty = 5000990009)"
disabled={editSubmitting}
autoComplete="off"
/>
<p className={styles.helperText}>
Used for customer SMS from this business. Leave empty to use Meshkee advertising line
5000990009.
</p>
</div>
<div className={styles.field}>
<label htmlFor="edit-about">About business</label>
<textarea
@@ -94,7 +94,7 @@
.promptArea {
width: 100%;
min-height: 220px;
min-height: min(520px, 58vh);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
+2 -2
View File
@@ -200,7 +200,7 @@ export function TemplatesPage() {
<Modal
open={editorOpen}
title={editing ? 'Edit template' : 'Add template'}
wide
xl
onClose={() => {
if (submitting) return
setEditorOpen(false)
@@ -230,7 +230,7 @@ export function TemplatesPage() {
onChange={(e) => setDraftBody(e.target.value)}
placeholder="Paste the AI prompt here…"
disabled={submitting}
rows={14}
rows={28}
/>
</div>
<div style={{ height: 12 }} />
@@ -24,6 +24,7 @@ export interface UpdateBusinessPayload {
slug?: string
oldBusinessId?: number | null
annualRenewalAt?: string | null
smsSenderNumber?: string | null
}
export interface AddDomainPayload {
+2
View File
@@ -38,6 +38,8 @@ export interface BusinessListItem {
moduleCount: number
homeCharts: [HomeChartId, HomeChartId]
oldBusinessId: string | null
/** Gama shortcode for customer SMS; null → Meshkee advertising number. */
smsSenderNumber: string | null
}
export type MigrateFromOldEntity =
+6 -1
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
Last updated: September 3, 2026
Last updated: September 8, 2026
---
@@ -313,6 +313,11 @@ Products accept optional `brandId` on create/update.
### Customers
- `GET/POST/PATCH/DELETE /businesses/:businessId/customers`
- `GET /businesses/:businessId/customers/search` — autocomplete for order cart
- `GET /businesses/:businessId/customers/export` — Excel (.xlsx) of business customers (`phone`, `first_name`, `last_name`)
- `POST /businesses/:businessId/customers/import` — Excel import for SMS lists; only `phone` required; new users get a random password hash (OTP login still works); existing users are linked; already-linked / staff rows skipped
- `POST /businesses/:businessId/customers/:userId/sms` — single SMS via Gama **SendQuick**; uses `settings.sms.senderNumber` or advertising shortcode `5000990009`
- `POST /businesses/:businessId/customers/sms` — bulk SMS via Gama **SendBulk** (`destinations[]`); body `{ message, userIds? }` — empty/omitted `userIds` → all enabled customers; selected ids → those recipients
- Super Admin business edit: optional **SMS sender number** stored in `businesses.settings.sms.senderNumber`
### Orders
- `GET/POST/PATCH/DELETE /businesses/:businessId/orders`
@@ -62,6 +62,52 @@
letter-spacing: 0.02em;
}
.fontSizeWrap {
display: inline-flex;
align-items: center;
}
.fontSizeSelect {
appearance: none;
-webkit-appearance: none;
width: auto;
min-width: 52px;
height: 28px;
padding: 0 22px 0 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 6px;
background-color: transparent;
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 4px center;
background-size: 12px;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: var(--font-ui);
line-height: 1;
cursor: pointer;
}
.fontSizeSelect:hover,
.fontSizeSelect:focus {
border-color: var(--primary);
color: var(--primary);
outline: none;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.divider {
width: 1px;
height: 20px;
@@ -212,6 +258,20 @@
margin-bottom: 0;
}
.editor b,
.editor strong {
font-weight: 700;
}
.editor i,
.editor em {
font-style: italic;
}
.editor u {
text-decoration: underline;
}
.editor blockquote {
margin: 0.5em 0;
margin-inline-start: 1.5em;
@@ -30,6 +30,33 @@ const ALIGN_COMMAND: Record<TextAlign, string> = {
right: 'justifyRight',
justify: 'justifyFull',
}
const FONT_SIZE_OPTIONS = [
{ value: '12px', label: '12' },
{ value: '14px', label: '14' },
{ value: '16px', label: '16' },
{ value: '20px', label: '20' },
{ value: '28px', label: '28' },
] as const
type FontSizeValue = (typeof FONT_SIZE_OPTIONS)[number]['value']
const DEFAULT_FONT_SIZE: FontSizeValue = '14px'
const FONT_SIZE_VALUES = new Set<string>(FONT_SIZE_OPTIONS.map((option) => option.value))
function nearestFontSize(rawPx: number): FontSizeValue {
let best: FontSizeValue = DEFAULT_FONT_SIZE
let bestDelta = Number.POSITIVE_INFINITY
for (const option of FONT_SIZE_OPTIONS) {
const size = Number.parseFloat(option.value)
const delta = Math.abs(size - rawPx)
if (delta < bestDelta) {
best = option.value
bestDelta = delta
}
}
return best
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
@@ -91,6 +118,7 @@ export function RichTextEditor({
const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(DEFAULT_FONT_SIZE)
const syncChange = useCallback(() => {
if (editorRef.current) {
@@ -199,11 +227,18 @@ export function RichTextEditor({
}, [colorOpen])
function exec(cmd: string, arg?: string) {
const editor = editorRef.current
if (!editor) return
editor.focus()
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function onToolbarMouseDown(e: React.MouseEvent) {
// Keep the editor selection when clicking toolbar controls.
e.preventDefault()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
@@ -245,6 +280,66 @@ export function RichTextEditor({
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
setCurrentFontSize(readFontSize(editor))
}
function readFontSize(editor: HTMLElement): FontSizeValue {
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) {
return DEFAULT_FONT_SIZE
}
let node: Node | null = selection.anchorNode
if (node?.nodeType === Node.TEXT_NODE) node = node.parentNode
while (node && node !== editor) {
if (node instanceof HTMLElement) {
const inline = node.style.fontSize.trim().toLowerCase()
if (FONT_SIZE_VALUES.has(inline)) return inline as FontSizeValue
if (inline.endsWith('px')) {
const px = Number.parseFloat(inline)
if (Number.isFinite(px)) return nearestFontSize(px)
}
}
node = node.parentNode
}
const block = findIndentBlock(editor)
if (block) {
const computed = Number.parseFloat(getComputedStyle(block).fontSize)
if (Number.isFinite(computed)) return nearestFontSize(computed)
}
return DEFAULT_FONT_SIZE
}
function applyFontSize(size: FontSizeValue) {
const editor = editorRef.current
if (!editor) return
editor.focus()
document.execCommand('styleWithCSS', false, 'true')
document.execCommand('fontSize', false, '7')
editor.querySelectorAll('font[size="7"]').forEach((font) => {
const span = document.createElement('span')
span.style.fontSize = size
while (font.firstChild) span.appendChild(font.firstChild)
font.replaceWith(span)
})
editor.querySelectorAll('span').forEach((span) => {
const style = span.getAttribute('style') ?? ''
if (
/font-size:\s*(xx-large|xxx-large|-webkit-xxx-large)/i.test(style) ||
span.style.fontSize === 'xx-large' ||
span.style.fontSize === 'xxx-large'
) {
span.style.fontSize = size
}
})
setCurrentFontSize(size)
syncChange()
}
useEffect(() => {
@@ -254,6 +349,7 @@ export function RichTextEditor({
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
setCurrentFontSize(readFontSize(editor))
}
document.addEventListener('selectionchange', onSelectionChange)
@@ -438,20 +534,57 @@ export function RichTextEditor({
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('bold')}
title="Bold"
aria-label="Bold"
>
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('italic')}
title="Italic"
aria-label="Italic"
>
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('underline')}
title="Underline"
aria-label="Underline"
>
<Underline size={16} />
</button>
<span className={styles.divider} />
<label className={styles.fontSizeWrap}>
<span className={styles.srOnly}>Font size</span>
<select
className={styles.fontSizeSelect}
value={currentFontSize}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
title="Font size"
aria-label="Font size"
>
{FONT_SIZE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<span className={styles.divider} />
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
<button
type="button"
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
onMouseDown={onToolbarMouseDown}
onClick={toggleColorPicker}
title="Text color"
aria-label="Text color"
@@ -492,6 +625,7 @@ export function RichTextEditor({
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('rtl')}
title="Right to left"
aria-label="Right to left"
@@ -501,6 +635,7 @@ export function RichTextEditor({
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('ltr')}
title="Left to right"
aria-label="Left to right"
@@ -511,7 +646,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('left')}
title="Align left"
aria-label="Align left"
@@ -522,7 +657,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('center')}
title="Align center"
aria-label="Align center"
@@ -533,7 +668,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('right')}
title="Align right"
aria-label="Align right"
@@ -544,7 +679,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('justify')}
title="Justify"
aria-label="Justify"
@@ -553,15 +688,16 @@ export function RichTextEditor({
<AlignJustify size={16} />
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
@@ -570,6 +706,7 @@ export function RichTextEditor({
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"