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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 14:12:57 +03:30
co-authored by Cursor
parent 2a32c46b2e
commit 4c3bf8324e
30 changed files with 1597 additions and 149 deletions
@@ -62,6 +62,52 @@
letter-spacing: 0.02em;
}
.fontSizeWrap {
display: inline-flex;
align-items: center;
}
.fontSizeSelect {
appearance: none;
-webkit-appearance: none;
width: auto;
min-width: 52px;
height: 28px;
padding: 0 22px 0 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 6px;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 12px;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: var(--font-ui);
line-height: 1;
cursor: pointer;
}
.fontSizeSelect:hover,
.fontSizeSelect:focus {
border-color: var(--primary);
color: var(--primary);
outline: none;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.divider {
width: 1px;
height: 20px;
@@ -212,6 +258,20 @@
margin-bottom: 0;
}
.editor b,
.editor strong {
font-weight: 700;
}
.editor i,
.editor em {
font-style: italic;
}
.editor u {
text-decoration: underline;
}
.editor blockquote {
margin: 0.5em 0;
margin-inline-start: 1.5em;
@@ -30,6 +30,33 @@ const ALIGN_COMMAND: Record<TextAlign, string> = {
right: 'justifyRight',
justify: 'justifyFull',
}
const FONT_SIZE_OPTIONS = [
{ value: '12px', label: '12' },
{ value: '14px', label: '14' },
{ value: '16px', label: '16' },
{ value: '20px', label: '20' },
{ value: '28px', label: '28' },
] as const
type FontSizeValue = (typeof FONT_SIZE_OPTIONS)[number]['value']
const DEFAULT_FONT_SIZE: FontSizeValue = '14px'
const FONT_SIZE_VALUES = new Set<string>(FONT_SIZE_OPTIONS.map((option) => option.value))
function nearestFontSize(rawPx: number): FontSizeValue {
let best: FontSizeValue = DEFAULT_FONT_SIZE
let bestDelta = Number.POSITIVE_INFINITY
for (const option of FONT_SIZE_OPTIONS) {
const size = Number.parseFloat(option.value)
const delta = Math.abs(size - rawPx)
if (delta < bestDelta) {
best = option.value
bestDelta = delta
}
}
return best
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
@@ -91,6 +118,7 @@ export function RichTextEditor({
const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(DEFAULT_FONT_SIZE)
const syncChange = useCallback(() => {
if (editorRef.current) {
@@ -199,11 +227,18 @@ export function RichTextEditor({
}, [colorOpen])
function exec(cmd: string, arg?: string) {
const editor = editorRef.current
if (!editor) return
editor.focus()
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function onToolbarMouseDown(e: React.MouseEvent) {
// Keep the editor selection when clicking toolbar controls.
e.preventDefault()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
@@ -245,6 +280,66 @@ export function RichTextEditor({
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
setCurrentFontSize(readFontSize(editor))
}
function readFontSize(editor: HTMLElement): FontSizeValue {
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) {
return DEFAULT_FONT_SIZE
}
let node: Node | null = selection.anchorNode
if (node?.nodeType === Node.TEXT_NODE) node = node.parentNode
while (node && node !== editor) {
if (node instanceof HTMLElement) {
const inline = node.style.fontSize.trim().toLowerCase()
if (FONT_SIZE_VALUES.has(inline)) return inline as FontSizeValue
if (inline.endsWith('px')) {
const px = Number.parseFloat(inline)
if (Number.isFinite(px)) return nearestFontSize(px)
}
}
node = node.parentNode
}
const block = findIndentBlock(editor)
if (block) {
const computed = Number.parseFloat(getComputedStyle(block).fontSize)
if (Number.isFinite(computed)) return nearestFontSize(computed)
}
return DEFAULT_FONT_SIZE
}
function applyFontSize(size: FontSizeValue) {
const editor = editorRef.current
if (!editor) return
editor.focus()
document.execCommand('styleWithCSS', false, 'true')
document.execCommand('fontSize', false, '7')
editor.querySelectorAll('font[size="7"]').forEach((font) => {
const span = document.createElement('span')
span.style.fontSize = size
while (font.firstChild) span.appendChild(font.firstChild)
font.replaceWith(span)
})
editor.querySelectorAll('span').forEach((span) => {
const style = span.getAttribute('style') ?? ''
if (
/font-size:\s*(xx-large|xxx-large|-webkit-xxx-large)/i.test(style) ||
span.style.fontSize === 'xx-large' ||
span.style.fontSize === 'xxx-large'
) {
span.style.fontSize = size
}
})
setCurrentFontSize(size)
syncChange()
}
useEffect(() => {
@@ -254,6 +349,7 @@ export function RichTextEditor({
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
setCurrentFontSize(readFontSize(editor))
}
document.addEventListener('selectionchange', onSelectionChange)
@@ -438,20 +534,57 @@ export function RichTextEditor({
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('bold')}
title="Bold"
aria-label="Bold"
>
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('italic')}
title="Italic"
aria-label="Italic"
>
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('underline')}
title="Underline"
aria-label="Underline"
>
<Underline size={16} />
</button>
<span className={styles.divider} />
<label className={styles.fontSizeWrap}>
<span className={styles.srOnly}>Font size</span>
<select
className={styles.fontSizeSelect}
value={currentFontSize}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
title="Font size"
aria-label="Font size"
>
{FONT_SIZE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<span className={styles.divider} />
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
<button
type="button"
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
onMouseDown={onToolbarMouseDown}
onClick={toggleColorPicker}
title="Text color"
aria-label="Text color"
@@ -492,6 +625,7 @@ export function RichTextEditor({
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('rtl')}
title="Right to left"
aria-label="Right to left"
@@ -501,6 +635,7 @@ export function RichTextEditor({
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('ltr')}
title="Left to right"
aria-label="Left to right"
@@ -511,7 +646,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('left')}
title="Align left"
aria-label="Align left"
@@ -522,7 +657,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('center')}
title="Align center"
aria-label="Align center"
@@ -533,7 +668,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('right')}
title="Align right"
aria-label="Align right"
@@ -544,7 +679,7 @@ export function RichTextEditor({
<button
type="button"
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('justify')}
title="Justify"
aria-label="Justify"
@@ -553,15 +688,16 @@ export function RichTextEditor({
<AlignJustify size={16} />
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
@@ -570,6 +706,7 @@ export function RichTextEditor({
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"