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'
|
} from '../services/blogService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import cardStyles from './BlogCard.module.css'
|
import cardStyles from './BlogCard.module.css'
|
||||||
import controlStyles from './ProductCard.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.
|
// In FA UI, keep cards RTL even when title/abstract are English.
|
||||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('blog', blog)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -120,6 +123,7 @@ export function BlogCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
<Tooltip label={verifyLabel}>
|
<Tooltip label={verifyLabel}>
|
||||||
<button
|
<button
|
||||||
type="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'
|
} from '../services/instructionService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import cardStyles from './BlogCard.module.css'
|
import cardStyles from './BlogCard.module.css'
|
||||||
import controlStyles from './ProductCard.module.css'
|
import controlStyles from './ProductCard.module.css'
|
||||||
@@ -49,6 +51,7 @@ export function InstructionCard({
|
|||||||
: video.videoProvider === 'aparat'
|
: video.videoProvider === 'aparat'
|
||||||
? 'Aparat'
|
? 'Aparat'
|
||||||
: null
|
: null
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('instruction', video)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -118,6 +121,7 @@ export function InstructionCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
<Tooltip label={t('instruction.card.comments')}>
|
<Tooltip label={t('instruction.card.comments')}>
|
||||||
<button
|
<button
|
||||||
type="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'
|
} from '../services/portfolioService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import cardStyles from './PortfolioCard.module.css'
|
import cardStyles from './PortfolioCard.module.css'
|
||||||
import controlStyles from './ProductCard.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.
|
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||||
const externalUrl = resolvePortfolioExternalUrl(portfolio.projectUrl)
|
const externalUrl = resolvePortfolioExternalUrl(portfolio.projectUrl)
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('portfolio', portfolio)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -133,6 +136,7 @@ export function PortfolioCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
{externalUrl ? (
|
{externalUrl ? (
|
||||||
<Tooltip label={externalUrl}>
|
<Tooltip label={externalUrl}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { Tooltip } from './Tooltip'
|
|||||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { hasBusinessModule } from '../utils/businessModules'
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import { formatProductCategoryChip } from '../services/productService'
|
import { formatProductCategoryChip } from '../services/productService'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import styles from './ProductCard.module.css'
|
import styles from './ProductCard.module.css'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -57,6 +59,7 @@ export function ProductCard({
|
|||||||
: ''
|
: ''
|
||||||
: product.nameFa
|
: product.nameFa
|
||||||
const categoryLabel = formatProductCategoryChip(product, isFa)
|
const categoryLabel = formatProductCategoryChip(product, isFa)
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('product', product)
|
||||||
const imageSrc = product.image?.trim() || ''
|
const imageSrc = product.image?.trim() || ''
|
||||||
const [imageFailed, setImageFailed] = useState(false)
|
const [imageFailed, setImageFailed] = useState(false)
|
||||||
const showImage = Boolean(imageSrc) && !imageFailed
|
const showImage = Boolean(imageSrc) && !imageFailed
|
||||||
@@ -111,6 +114,7 @@ export function ProductCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
{showTranslationControls ? (
|
{showTranslationControls ? (
|
||||||
<Tooltip label={t('products.card.translate')}>
|
<Tooltip label={t('products.card.translate')}>
|
||||||
<button
|
<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));
|
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 {
|
.toastIn {
|
||||||
animation: toastIn 0.22s ease forwards;
|
animation: toastIn 0.22s ease forwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '../services/videoService'
|
} from '../services/videoService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import cardStyles from './BlogCard.module.css'
|
import cardStyles from './BlogCard.module.css'
|
||||||
import controlStyles from './ProductCard.module.css'
|
import controlStyles from './ProductCard.module.css'
|
||||||
@@ -41,6 +43,7 @@ export function VideoCard({
|
|||||||
const hasAbstract = Boolean(video.abstract?.trim())
|
const hasAbstract = Boolean(video.abstract?.trim())
|
||||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('video', video)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -108,6 +111,7 @@ export function VideoCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
<Tooltip label={t('video.card.comments')}>
|
<Tooltip label={t('video.card.comments')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '../services/workshopService'
|
} from '../services/workshopService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import cardStyles from './BlogCard.module.css'
|
import cardStyles from './BlogCard.module.css'
|
||||||
import controlStyles from './ProductCard.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 statusLabel = published ? t('workshop.card.unpublish') : t('workshop.card.publish')
|
||||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||||
|
const websiteUrl = buildWebsiteContentUrl('workshop', workshop)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -124,6 +127,7 @@ export function WorkshopCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
<Tooltip label={statusLabel}>
|
<Tooltip label={statusLabel}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,18 +9,28 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
import styles from '../components/Toast.module.css'
|
import styles from '../components/Toast.module.css'
|
||||||
|
|
||||||
export type ToastVariant = 'success' | 'error' | 'info'
|
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 {
|
interface ToastItem {
|
||||||
id: number
|
id: number
|
||||||
message: string
|
message: string
|
||||||
variant: ToastVariant
|
variant: ToastVariant
|
||||||
|
persistent: boolean
|
||||||
|
size: 'default' | 'large'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastContextValue {
|
interface ToastContextValue {
|
||||||
showToast: (message: string, variant?: ToastVariant) => void
|
showToast: (message: string, variant?: ToastVariant, options?: ToastOptions) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||||
@@ -55,15 +65,25 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const showToast = useCallback(
|
const showToast = useCallback(
|
||||||
(message: string, variant: ToastVariant = 'info') => {
|
(message: string, variant: ToastVariant = 'info', options?: ToastOptions) => {
|
||||||
clearTimers()
|
clearTimers()
|
||||||
idRef.current += 1
|
idRef.current += 1
|
||||||
setClosing(false)
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!persistent) {
|
||||||
dismissTimerRef.current = setTimeout(() => {
|
dismissTimerRef.current = setTimeout(() => {
|
||||||
dismissToast()
|
dismissToast()
|
||||||
}, TOAST_DURATION_MS)
|
}, TOAST_DURATION_MS)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[clearTimers, dismissToast],
|
[clearTimers, dismissToast],
|
||||||
)
|
)
|
||||||
@@ -80,10 +100,28 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
className={[
|
||||||
role="status"
|
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>
|
||||||
)}
|
)}
|
||||||
</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-face {
|
||||||
font-family: 'IRANYekan';
|
font-family: 'IRANYekan';
|
||||||
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
|
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
|
||||||
@@ -14,27 +15,3 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
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.filter.access.managers': 'Managers',
|
||||||
'customers.search': 'Search',
|
'customers.search': 'Search',
|
||||||
'customers.clearFilters': 'Clear filters',
|
'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.listTitle': 'User list',
|
||||||
'customers.showing': 'Showing {from} - {to} of {total}',
|
'customers.showing': 'Showing {from} - {to} of {total}',
|
||||||
'customers.none': 'No customers',
|
'customers.none': 'No customers',
|
||||||
@@ -1105,6 +1112,26 @@ const en = {
|
|||||||
'customers.editModal.save': 'Save',
|
'customers.editModal.save': 'Save',
|
||||||
'customers.editModal.error': 'Unable to update customer.',
|
'customers.editModal.error': 'Unable to update customer.',
|
||||||
'customers.sms': 'Send SMS',
|
'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.tickets': 'Tickets',
|
||||||
'customers.invoices': 'View invoices',
|
'customers.invoices': 'View invoices',
|
||||||
'customers.remove': 'Remove customer',
|
'customers.remove': 'Remove customer',
|
||||||
@@ -1118,6 +1145,8 @@ const en = {
|
|||||||
'customers.toast.updated': '"{name}" has been updated.',
|
'customers.toast.updated': '"{name}" has been updated.',
|
||||||
'customers.toast.added': '"{name}" has been added.',
|
'customers.toast.added': '"{name}" has been added.',
|
||||||
'customers.toast.smsSoon': 'SMS to {phone} is not available yet.',
|
'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.toast.ticketsSoon': 'Tickets for "{name}" are not available yet.',
|
||||||
'customers.deleteTitle': 'Remove customer?',
|
'customers.deleteTitle': 'Remove customer?',
|
||||||
'customers.deleteMessage':
|
'customers.deleteMessage':
|
||||||
@@ -2355,6 +2384,7 @@ const en = {
|
|||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.delete': 'Delete',
|
'common.delete': 'Delete',
|
||||||
|
'common.viewOnWebsite': 'View on website',
|
||||||
'common.resendIn': 'Resend code in {seconds}s',
|
'common.resendIn': 'Resend code in {seconds}s',
|
||||||
'common.resend': 'Resend SMS code',
|
'common.resend': 'Resend SMS code',
|
||||||
'common.codeSent': 'Verification code sent to {phone}',
|
'common.codeSent': 'Verification code sent to {phone}',
|
||||||
@@ -3461,6 +3491,13 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'customers.filter.access.managers': 'مدیران سیستم',
|
'customers.filter.access.managers': 'مدیران سیستم',
|
||||||
'customers.search': 'جستجو',
|
'customers.search': 'جستجو',
|
||||||
'customers.clearFilters': 'پاک کردن فیلترها',
|
'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.listTitle': 'فهرست کاربران',
|
||||||
'customers.showing': 'نمایش {from} تا {to} از {total}',
|
'customers.showing': 'نمایش {from} تا {to} از {total}',
|
||||||
'customers.none': 'بدون مشتری',
|
'customers.none': 'بدون مشتری',
|
||||||
@@ -3487,6 +3524,26 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'customers.editModal.save': 'ذخیره',
|
'customers.editModal.save': 'ذخیره',
|
||||||
'customers.editModal.error': 'بهروزرسانی مشتری ممکن نشد.',
|
'customers.editModal.error': 'بهروزرسانی مشتری ممکن نشد.',
|
||||||
'customers.sms': 'ارسال پیامک',
|
'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.tickets': 'تیکتها',
|
||||||
'customers.invoices': 'مشاهده فاکتورها',
|
'customers.invoices': 'مشاهده فاکتورها',
|
||||||
'customers.remove': 'حذف مشتری',
|
'customers.remove': 'حذف مشتری',
|
||||||
@@ -3500,6 +3557,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'customers.toast.updated': '«{name}» بهروزرسانی شد.',
|
'customers.toast.updated': '«{name}» بهروزرسانی شد.',
|
||||||
'customers.toast.added': '«{name}» اضافه شد.',
|
'customers.toast.added': '«{name}» اضافه شد.',
|
||||||
'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.',
|
'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.',
|
||||||
|
'customers.toast.smsSent': 'پیامک به {phone} ارسال شد.',
|
||||||
|
'customers.toast.smsSentBulk': 'پیامک به {count} گیرنده ارسال شد.',
|
||||||
'customers.toast.ticketsSoon': 'تیکتهای «{name}» هنوز در دسترس نیست.',
|
'customers.toast.ticketsSoon': 'تیکتهای «{name}» هنوز در دسترس نیست.',
|
||||||
'customers.deleteTitle': 'حذف مشتری؟',
|
'customers.deleteTitle': 'حذف مشتری؟',
|
||||||
'customers.deleteMessage':
|
'customers.deleteMessage':
|
||||||
@@ -4739,6 +4798,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'common.close': 'بستن',
|
'common.close': 'بستن',
|
||||||
'common.cancel': 'انصراف',
|
'common.cancel': 'انصراف',
|
||||||
'common.delete': 'حذف',
|
'common.delete': 'حذف',
|
||||||
|
'common.viewOnWebsite': 'مشاهده در وبسایت',
|
||||||
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
|
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
|
||||||
'common.resend': 'ارسال مجدد کد پیامکی',
|
'common.resend': 'ارسال مجدد کد پیامکی',
|
||||||
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
|
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
|
||||||
|
|||||||
@@ -6,6 +6,76 @@
|
|||||||
overflow: hidden;
|
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 {
|
.tableWrap {
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -40,8 +110,12 @@
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colCheck {
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.colName {
|
.colName {
|
||||||
width: 24%;
|
width: 22%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colCell {
|
.colCell {
|
||||||
@@ -61,12 +135,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.colActions {
|
.colActions {
|
||||||
width: 240px;
|
width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th,
|
.th,
|
||||||
.td {
|
.td {
|
||||||
padding: 10px 12px;
|
padding: 14px 14px;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
@@ -121,38 +195,54 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.tdActions {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-inline-end: 10px;
|
padding-inline-end: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActions {
|
.rowActions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FA: عملیات column is on the physical left. */
|
/* FA: عملیات column is on the physical left. */
|
||||||
.tableFa .thActions,
|
.tableFa .thActions,
|
||||||
.tableFa .tdActions {
|
.tableFa .tdActions {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 10px;
|
padding-left: 14px;
|
||||||
padding-right: 10px;
|
padding-right: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleInActions {
|
.toggleInActions {
|
||||||
margin-inline-end: 10px;
|
margin-inline-end: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -278,6 +368,16 @@
|
|||||||
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.fab {
|
.fab {
|
||||||
inset-inline-end: 20px;
|
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 { 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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { AddCustomerModal } from '../components/AddCustomerModal'
|
import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
|
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
|
||||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
|
import { CustomerActionsOverflowMenu } from '../components/CustomerActionsOverflowMenu'
|
||||||
import { EditCustomerModal } from '../components/EditCustomerModal'
|
import { EditCustomerModal } from '../components/EditCustomerModal'
|
||||||
import { EmptyTableRow } from '../components/EmptyState'
|
import { EmptyTableRow } from '../components/EmptyState'
|
||||||
import { PageTitle } from '../components/PageTitle'
|
import { PageTitle } from '../components/PageTitle'
|
||||||
import { Pagination } from '../components/Pagination'
|
import { Pagination } from '../components/Pagination'
|
||||||
|
import { SendCustomerSmsModal, type SmsComposeTarget } from '../components/SendCustomerSmsModal'
|
||||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||||
import { Tooltip } from '../components/Tooltip'
|
import { Tooltip } from '../components/Tooltip'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
@@ -21,6 +34,8 @@ import { ApiError, isAbortError } from '../lib/api'
|
|||||||
import { getActiveBusinessId } from '../lib/businessContext'
|
import { getActiveBusinessId } from '../lib/businessContext'
|
||||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||||
import {
|
import {
|
||||||
|
downloadCustomersExcel,
|
||||||
|
importCustomersExcel,
|
||||||
listCustomers,
|
listCustomers,
|
||||||
removeCustomer,
|
removeCustomer,
|
||||||
updateCustomerEnabled,
|
updateCustomerEnabled,
|
||||||
@@ -37,7 +52,7 @@ import accessStyles from '../components/ChangeUserAccessModal.module.css'
|
|||||||
import styles from './CustomersPage.module.css'
|
import styles from './CustomersPage.module.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
const COLUMN_COUNT = 6
|
const COLUMN_COUNT = 7
|
||||||
|
|
||||||
function formatDate(value: string, locale: 'en' | 'fa') {
|
function formatDate(value: string, locale: 'en' | 'fa') {
|
||||||
const d = new Date(value)
|
const d = new Date(value)
|
||||||
@@ -51,6 +66,11 @@ function displayName(customer: BusinessCustomerListItem) {
|
|||||||
return name || '—'
|
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) {
|
function formatTransactionTotal(total: number | null | undefined) {
|
||||||
if (total == null || total === 0) {
|
if (total == null || total === 0) {
|
||||||
return <span className={styles.subText}>—</span>
|
return <span className={styles.subText}>—</span>
|
||||||
@@ -101,6 +121,13 @@ export function CustomersPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
|
||||||
const [removing, setRemoving] = useState(false)
|
const [removing, setRemoving] = useState(false)
|
||||||
|
const [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 membership = useMemo(() => {
|
||||||
const activeId = getActiveBusinessId()
|
const activeId = getActiveBusinessId()
|
||||||
@@ -156,7 +183,7 @@ export function CustomersPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
|
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, reloadToken, t])
|
||||||
|
|
||||||
const totalPages = useMemo(() => {
|
const totalPages = useMemo(() => {
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
@@ -173,6 +200,106 @@ export function CustomersPage() {
|
|||||||
return Math.min(data.total, page * PAGE_SIZE)
|
return Math.min(data.total, page * PAGE_SIZE)
|
||||||
}, [data, page])
|
}, [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() {
|
function applyFilters() {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setAppliedFilters({
|
setAppliedFilters({
|
||||||
@@ -233,6 +360,12 @@ export function CustomersPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await removeCustomer(removeTarget.id)
|
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) => {
|
setData((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return {
|
return {
|
||||||
@@ -255,10 +388,19 @@ export function CustomersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSendSms(customer: BusinessCustomerListItem) {
|
function handleSendSms(customer: BusinessCustomerListItem) {
|
||||||
showToast(
|
setSmsTarget({ mode: 'single', customer })
|
||||||
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
|
}
|
||||||
'info',
|
|
||||||
)
|
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) {
|
function handleTickets(customer: BusinessCustomerListItem) {
|
||||||
@@ -316,6 +458,59 @@ export function CustomersPage() {
|
|||||||
showToast(t('customers.toast.added', { name: displayName(customer) }), 'success')
|
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 (
|
return (
|
||||||
<main className={pageStyles.content}>
|
<main className={pageStyles.content}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
@@ -364,29 +559,113 @@ export function CustomersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
|
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
|
||||||
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
disabled={loading}
|
disabled={loading || !!excelBusy}
|
||||||
aria-label={t('customers.search')}
|
aria-label={t('customers.search')}
|
||||||
title={t('customers.search')}
|
|
||||||
>
|
>
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('customers.clearFilters')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
disabled={loading}
|
disabled={loading || !!excelBusy}
|
||||||
aria-label={t('customers.clearFilters')}
|
aria-label={t('customers.clearFilters')}
|
||||||
title={t('customers.clearFilters')}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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.tablePanel}>
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
@@ -414,6 +693,7 @@ export function CustomersPage() {
|
|||||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
<col className={styles.colCheck} />
|
||||||
<col className={styles.colName} />
|
<col className={styles.colName} />
|
||||||
<col className={styles.colCell} />
|
<col className={styles.colCell} />
|
||||||
<col className={styles.colOrders} />
|
<col className={styles.colOrders} />
|
||||||
@@ -423,6 +703,19 @@ export function CustomersPage() {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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.name')}</th>
|
||||||
<th className={styles.th}>{t('customers.col.cell')}</th>
|
<th className={styles.th}>{t('customers.col.cell')}</th>
|
||||||
<th className={styles.th}>{t('customers.col.orders')}</th>
|
<th className={styles.th}>{t('customers.col.orders')}</th>
|
||||||
@@ -461,6 +754,15 @@ export function CustomersPage() {
|
|||||||
key={customer.id}
|
key={customer.id}
|
||||||
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
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}>
|
<td className={styles.td}>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@@ -521,19 +823,6 @@ export function CustomersPage() {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canManageAccess ? (
|
|
||||||
<Tooltip label={accessLocked ?? t('customers.access.change')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.actionBtn}
|
|
||||||
onClick={() => setAccessTarget(customer)}
|
|
||||||
aria-label={accessLocked ?? t('customers.access.change')}
|
|
||||||
disabled={Boolean(accessLocked) || removing}
|
|
||||||
>
|
|
||||||
<Shield size={15} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
<Tooltip label={t('customers.edit')}>
|
<Tooltip label={t('customers.edit')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -544,18 +833,6 @@ export function CustomersPage() {
|
|||||||
<Pencil size={15} />
|
<Pencil size={15} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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')}>
|
<Tooltip label={t('customers.sms')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -566,16 +843,6 @@ export function CustomersPage() {
|
|||||||
<MessageSquare size={15} />
|
<MessageSquare size={15} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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')}>
|
<Tooltip label={t('customers.remove')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -587,6 +854,16 @@ export function CustomersPage() {
|
|||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<CustomerActionsOverflowMenu
|
||||||
|
customerName={name}
|
||||||
|
canManageAccess={canManageAccess}
|
||||||
|
accessLocked={accessLocked}
|
||||||
|
financeEnabled={financeEnabled}
|
||||||
|
disabled={removing}
|
||||||
|
onChangeAccess={() => setAccessTarget(customer)}
|
||||||
|
onViewInvoices={() => handleViewInvoices(customer)}
|
||||||
|
onTickets={() => handleTickets(customer)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -628,6 +905,22 @@ export function CustomersPage() {
|
|||||||
onSaved={handleCustomerSaved}
|
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
|
<ChangeUserAccessModal
|
||||||
open={accessTarget !== null}
|
open={accessTarget !== null}
|
||||||
user={accessTarget}
|
user={accessTarget}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ function StoreItemsPageContent() {
|
|||||||
unchanged: result.unchanged,
|
unchanged: result.unchanged,
|
||||||
}),
|
}),
|
||||||
'success',
|
'success',
|
||||||
|
{ persistent: true, size: 'large' },
|
||||||
)
|
)
|
||||||
await loadItems()
|
await loadItems()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiRequest } from '../lib/api'
|
import { apiRequest, apiRequestBlob, apiRequestForm } from '../lib/api'
|
||||||
import { getActiveBusinessId } from '../lib/businessContext'
|
import { getActiveBusinessId } from '../lib/businessContext'
|
||||||
|
|
||||||
export interface BusinessCustomer {
|
export interface BusinessCustomer {
|
||||||
@@ -131,3 +131,84 @@ export async function searchCustomers(
|
|||||||
{ auth: true, signal },
|
{ 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 WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' | 'workshop'
|
||||||
|
|
||||||
|
export type WebsiteContentKind =
|
||||||
|
| 'product'
|
||||||
|
| 'blog'
|
||||||
|
| 'portfolio'
|
||||||
|
| 'video'
|
||||||
|
| 'instruction'
|
||||||
|
| 'workshop'
|
||||||
|
|
||||||
export type WebsiteLinkFilter = 'category' | 'tag'
|
export type WebsiteLinkFilter = 'category' | 'tag'
|
||||||
|
|
||||||
export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
|
export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
|
||||||
@@ -22,12 +30,84 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
|||||||
workshop: '/workshops',
|
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 {
|
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
|
||||||
const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||||
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
|
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
|
||||||
return `${protocol}//${host}`
|
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(
|
export function buildWebsiteCategoryUrl(
|
||||||
kind: WebsiteLinkKind,
|
kind: WebsiteLinkKind,
|
||||||
category: { id: string; slug?: string | null },
|
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-face {
|
||||||
font-family: 'IRANYekan';
|
font-family: 'IRANYekan';
|
||||||
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
|
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
|
||||||
@@ -14,27 +15,3 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
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 {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.fieldCol3 {
|
||||||
@@ -52,11 +61,13 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.helperText {
|
.helperText {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export function BusinessesPage() {
|
|||||||
const [editNameFa, setEditNameFa] = useState('')
|
const [editNameFa, setEditNameFa] = useState('')
|
||||||
const [editAbout, setEditAbout] = useState('')
|
const [editAbout, setEditAbout] = useState('')
|
||||||
const [editAnnualRenewalAt, setEditAnnualRenewalAt] = useState('')
|
const [editAnnualRenewalAt, setEditAnnualRenewalAt] = useState('')
|
||||||
|
const [editSmsSenderNumber, setEditSmsSenderNumber] = useState('')
|
||||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
const [editError, setEditError] = useState('')
|
const [editError, setEditError] = useState('')
|
||||||
|
|
||||||
@@ -520,6 +521,7 @@ export function BusinessesPage() {
|
|||||||
setEditAnnualRenewalAt(
|
setEditAnnualRenewalAt(
|
||||||
toDateInputValue(b.annualRenewalAt || b.createdAt),
|
toDateInputValue(b.annualRenewalAt || b.createdAt),
|
||||||
)
|
)
|
||||||
|
setEditSmsSenderNumber(b.smsSenderNumber ?? '')
|
||||||
setEditError('')
|
setEditError('')
|
||||||
setEditOpen(true)
|
setEditOpen(true)
|
||||||
}
|
}
|
||||||
@@ -862,12 +864,14 @@ export function BusinessesPage() {
|
|||||||
: editBusiness.createdAt
|
: editBusiness.createdAt
|
||||||
? new Date(editBusiness.createdAt).toISOString()
|
? new Date(editBusiness.createdAt).toISOString()
|
||||||
: null
|
: null
|
||||||
|
const smsSenderNumber = editSmsSenderNumber.replace(/\D/g, '')
|
||||||
try {
|
try {
|
||||||
await updateBusiness(editBusiness.id, {
|
await updateBusiness(editBusiness.id, {
|
||||||
name,
|
name,
|
||||||
nameFa,
|
nameFa,
|
||||||
about: editAbout.trim(),
|
about: editAbout.trim(),
|
||||||
annualRenewalAt,
|
annualRenewalAt,
|
||||||
|
smsSenderNumber: smsSenderNumber || null,
|
||||||
})
|
})
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
@@ -881,6 +885,7 @@ export function BusinessesPage() {
|
|||||||
nameFa,
|
nameFa,
|
||||||
about: editAbout.trim() || null,
|
about: editAbout.trim() || null,
|
||||||
annualRenewalAt,
|
annualRenewalAt,
|
||||||
|
smsSenderNumber: smsSenderNumber || null,
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
@@ -1961,6 +1966,22 @@ export function BusinessesPage() {
|
|||||||
placeholder="Select renewal date"
|
placeholder="Select renewal date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="edit-about">About business</label>
|
<label htmlFor="edit-about">About business</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
.promptArea {
|
.promptArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 220px;
|
min-height: min(520px, 58vh);
|
||||||
padding: var(--field-padding-y) var(--field-padding-x);
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function TemplatesPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
title={editing ? 'Edit template' : 'Add template'}
|
title={editing ? 'Edit template' : 'Add template'}
|
||||||
wide
|
xl
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (submitting) return
|
if (submitting) return
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
@@ -230,7 +230,7 @@ export function TemplatesPage() {
|
|||||||
onChange={(e) => setDraftBody(e.target.value)}
|
onChange={(e) => setDraftBody(e.target.value)}
|
||||||
placeholder="Paste the AI prompt here…"
|
placeholder="Paste the AI prompt here…"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
rows={14}
|
rows={28}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 12 }} />
|
<div style={{ height: 12 }} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UpdateBusinessPayload {
|
|||||||
slug?: string
|
slug?: string
|
||||||
oldBusinessId?: number | null
|
oldBusinessId?: number | null
|
||||||
annualRenewalAt?: string | null
|
annualRenewalAt?: string | null
|
||||||
|
smsSenderNumber?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddDomainPayload {
|
export interface AddDomainPayload {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface BusinessListItem {
|
|||||||
moduleCount: number
|
moduleCount: number
|
||||||
homeCharts: [HomeChartId, HomeChartId]
|
homeCharts: [HomeChartId, HomeChartId]
|
||||||
oldBusinessId: string | null
|
oldBusinessId: string | null
|
||||||
|
/** Gama shortcode for customer SMS; null → Meshkee advertising number. */
|
||||||
|
smsSenderNumber: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MigrateFromOldEntity =
|
export type MigrateFromOldEntity =
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
> **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.
|
> 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
|
### Customers
|
||||||
- `GET/POST/PATCH/DELETE /businesses/:businessId/customers`
|
- `GET/POST/PATCH/DELETE /businesses/:businessId/customers`
|
||||||
- `GET /businesses/:businessId/customers/search` — autocomplete for order cart
|
- `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
|
### Orders
|
||||||
- `GET/POST/PATCH/DELETE /businesses/:businessId/orders`
|
- `GET/POST/PATCH/DELETE /businesses/:businessId/orders`
|
||||||
|
|||||||
@@ -62,6 +62,52 @@
|
|||||||
letter-spacing: 0.02em;
|
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 {
|
.divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -212,6 +258,20 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor b,
|
||||||
|
.editor strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor i,
|
||||||
|
.editor em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor u {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.editor blockquote {
|
.editor blockquote {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
margin-inline-start: 1.5em;
|
margin-inline-start: 1.5em;
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ const ALIGN_COMMAND: Record<TextAlign, string> = {
|
|||||||
right: 'justifyRight',
|
right: 'justifyRight',
|
||||||
justify: 'justifyFull',
|
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 SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
||||||
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
|
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 [colorOpen, setColorOpen] = useState(false)
|
||||||
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
||||||
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
||||||
|
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(DEFAULT_FONT_SIZE)
|
||||||
|
|
||||||
const syncChange = useCallback(() => {
|
const syncChange = useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@@ -199,11 +227,18 @@ export function RichTextEditor({
|
|||||||
}, [colorOpen])
|
}, [colorOpen])
|
||||||
|
|
||||||
function exec(cmd: string, arg?: string) {
|
function exec(cmd: string, arg?: string) {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
editor.focus()
|
||||||
document.execCommand(cmd, false, arg)
|
document.execCommand(cmd, false, arg)
|
||||||
editorRef.current?.focus()
|
|
||||||
syncChange()
|
syncChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onToolbarMouseDown(e: React.MouseEvent) {
|
||||||
|
// Keep the editor selection when clicking toolbar controls.
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
|
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) return null
|
if (!selection || selection.rangeCount === 0) return null
|
||||||
@@ -245,6 +280,66 @@ export function RichTextEditor({
|
|||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
|
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
|
||||||
setCurrentAlign(readBlockAlign(editor))
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -254,6 +349,7 @@ export function RichTextEditor({
|
|||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
|
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
|
||||||
setCurrentAlign(readBlockAlign(editor))
|
setCurrentAlign(readBlockAlign(editor))
|
||||||
|
setCurrentFontSize(readFontSize(editor))
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('selectionchange', onSelectionChange)
|
document.addEventListener('selectionchange', onSelectionChange)
|
||||||
@@ -438,20 +534,57 @@ export function RichTextEditor({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} dir={dir} lang={locale}>
|
<div className={styles.wrapper} dir={dir} lang={locale}>
|
||||||
<div className={styles.toolbar} dir="ltr">
|
<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} />
|
<Bold size={16} />
|
||||||
</button>
|
</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} />
|
<Italic size={16} />
|
||||||
</button>
|
</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} />
|
<Underline size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<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}>
|
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
|
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
|
||||||
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={toggleColorPicker}
|
onClick={toggleColorPicker}
|
||||||
title="Text color"
|
title="Text color"
|
||||||
aria-label="Text color"
|
aria-label="Text color"
|
||||||
@@ -492,6 +625,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.dirBtn}
|
className={styles.dirBtn}
|
||||||
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockDirection('rtl')}
|
onClick={() => setBlockDirection('rtl')}
|
||||||
title="Right to left"
|
title="Right to left"
|
||||||
aria-label="Right to left"
|
aria-label="Right to left"
|
||||||
@@ -501,6 +635,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.dirBtn}
|
className={styles.dirBtn}
|
||||||
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockDirection('ltr')}
|
onClick={() => setBlockDirection('ltr')}
|
||||||
title="Left to right"
|
title="Left to right"
|
||||||
aria-label="Left to right"
|
aria-label="Left to right"
|
||||||
@@ -511,7 +646,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('left')}
|
onClick={() => setBlockAlign('left')}
|
||||||
title="Align left"
|
title="Align left"
|
||||||
aria-label="Align left"
|
aria-label="Align left"
|
||||||
@@ -522,7 +657,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('center')}
|
onClick={() => setBlockAlign('center')}
|
||||||
title="Align center"
|
title="Align center"
|
||||||
aria-label="Align center"
|
aria-label="Align center"
|
||||||
@@ -533,7 +668,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('right')}
|
onClick={() => setBlockAlign('right')}
|
||||||
title="Align right"
|
title="Align right"
|
||||||
aria-label="Align right"
|
aria-label="Align right"
|
||||||
@@ -544,7 +679,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('justify')}
|
onClick={() => setBlockAlign('justify')}
|
||||||
title="Justify"
|
title="Justify"
|
||||||
aria-label="Justify"
|
aria-label="Justify"
|
||||||
@@ -553,15 +688,16 @@ export function RichTextEditor({
|
|||||||
<AlignJustify size={16} />
|
<AlignJustify size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<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} />
|
<List size={16} />
|
||||||
</button>
|
</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} />
|
<ListOrdered size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<span className={styles.divider} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => changeIndent(-1)}
|
onClick={() => changeIndent(-1)}
|
||||||
title="Decrease indent"
|
title="Decrease indent"
|
||||||
aria-label="Decrease indent"
|
aria-label="Decrease indent"
|
||||||
@@ -570,6 +706,7 @@ export function RichTextEditor({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => changeIndent(1)}
|
onClick={() => changeIndent(1)}
|
||||||
title="Increase indent"
|
title="Increase indent"
|
||||||
aria-label="Increase indent"
|
aria-label="Increase indent"
|
||||||
|
|||||||
Reference in New Issue
Block a user