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"