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:
co-authored by
Cursor
parent
2a32c46b2e
commit
4c3bf8324e
@@ -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"
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} ارسال شد',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user