Improve SMS settings layout and add send-modal variable chips.

Credit/usage divider, total row plus typed counts grid, and insertable template vars on compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 11:37:35 +03:30
co-authored by Cursor
parent dbc17b2589
commit 29a190f02a
5 changed files with 178 additions and 43 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
@@ -12,6 +12,7 @@ import {
} from '../services/customerService' } from '../services/customerService'
import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService' import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import settingsStyles from '../pages/SettingsPage.module.css'
export type SmsComposeTarget = export type SmsComposeTarget =
| { mode: 'single'; customer: BusinessCustomerListItem } | { mode: 'single'; customer: BusinessCustomerListItem }
@@ -27,6 +28,7 @@ interface SendCustomerSmsModalProps {
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const MAX_LEN = 700 const MAX_LEN = 700
const SMS_VARS = ['{{user.name}}', '{{user.lastname}}', '{{user.title}}'] as const
export function SendCustomerSmsModal({ export function SendCustomerSmsModal({
open, open,
@@ -45,6 +47,7 @@ export function SendCustomerSmsModal({
const [templatesLoading, setTemplatesLoading] = useState(false) const [templatesLoading, setTemplatesLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => { useEffect(() => {
if (open && target) { if (open && target) {
@@ -103,6 +106,21 @@ export function SendCustomerSmsModal({
if (tpl) setMessage(tpl.body.slice(0, MAX_LEN)) if (tpl) setMessage(tpl.body.slice(0, MAX_LEN))
} }
function insertVariable(token: string) {
if (selectedTemplate?.isVerified) return
const el = messageRef.current
const start = el?.selectionStart ?? message.length
const end = el?.selectionEnd ?? message.length
const next = `${message.slice(0, start)}${token}${message.slice(end)}`.slice(0, MAX_LEN)
const inserted = Math.min(token.length, MAX_LEN - message.slice(0, start).length)
const cursor = start + Math.max(0, inserted)
setMessage(next)
requestAnimationFrame(() => {
el?.focus()
el?.setSelectionRange(cursor, cursor)
})
}
async function handleSubmit() { async function handleSubmit() {
if (!target || !canSend) return if (!target || !canSend) return
setIsSubmitting(true) setIsSubmitting(true)
@@ -216,8 +234,31 @@ export function SendCustomerSmsModal({
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label> <label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<div
className={settingsStyles.smsVarChips}
role="group"
aria-label={t('settings.sms.variablesHint')}
>
{SMS_VARS.map((token) => (
<button
key={token}
type="button"
className={settingsStyles.smsVarChip}
onClick={() => insertVariable(token)}
disabled={
isSubmitting ||
Boolean(selectedTemplate?.isVerified) ||
message.length >= MAX_LEN
}
dir="ltr"
>
{token}
</button>
))}
</div>
<textarea <textarea
id="customer-sms-message" id="customer-sms-message"
ref={messageRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))} onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5} rows={5}
@@ -288,10 +288,16 @@ export function SmsSettingsSection() {
</strong> </strong>
</div> </div>
<div className={settingsStyles.smsSentStack} role="group" aria-label={t('settings.sms.sentCount')}> <div className={settingsStyles.smsUsageSection} role="group" aria-label={t('settings.sms.sentCount')}>
<div className={`${settingsStyles.smsCreditRow} ${settingsStyles.smsSentRow}`}>
<span className={settingsStyles.smsCreditLabel}>{t('settings.sms.sentTotal')}</span>
<strong className={settingsStyles.smsCreditValue}>
{loading ? '…' : sentCount.toLocaleString(isFa ? 'fa-IR' : 'en-US')}
</strong>
</div>
<div className={settingsStyles.smsSentTypesRow}>
{( {(
[ [
['settings.sms.sentTotal', sentCount],
['settings.sms.sentMass', sentByType.mass], ['settings.sms.sentMass', sentByType.mass],
['settings.sms.sentSingular', sentByType.singular], ['settings.sms.sentSingular', sentByType.singular],
['settings.sms.sentAuth', sentByType.auth], ['settings.sms.sentAuth', sentByType.auth],
@@ -299,14 +305,15 @@ export function SmsSettingsSection() {
['settings.sms.sentAdmin', sentByType.admin], ['settings.sms.sentAdmin', sentByType.admin],
] as const ] as const
).map(([labelKey, value]) => ( ).map(([labelKey, value]) => (
<div key={labelKey} className={`${settingsStyles.smsCreditRow} ${settingsStyles.smsSentRow}`}> <div key={labelKey} className={settingsStyles.smsSentTypeCell}>
<span className={settingsStyles.smsCreditLabel}>{t(labelKey)}</span> <span className={settingsStyles.smsSentTypeLabel}>{t(labelKey)}</span>
<strong className={settingsStyles.smsCreditValue}> <strong className={settingsStyles.smsSentTypeValue}>
{loading ? '…' : value.toLocaleString(isFa ? 'fa-IR' : 'en-US')} {loading ? '…' : value.toLocaleString(isFa ? 'fa-IR' : 'en-US')}
</strong> </strong>
</div> </div>
))} ))}
</div> </div>
</div>
<div className={settingsStyles.smsTemplatesSummary}> <div className={settingsStyles.smsTemplatesSummary}>
<div className={settingsStyles.smsTemplatesSummaryText}> <div className={settingsStyles.smsTemplatesSummaryText}>
@@ -249,11 +249,13 @@
font-family: var(--font-ui); font-family: var(--font-ui);
} }
.smsSentStack { .smsUsageSection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 10px; margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
} }
.smsSentRow { .smsSentRow {
@@ -272,6 +274,50 @@
font-size: 14px; font-size: 14px;
} }
.smsSentTypesRow {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.smsSentTypeCell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(15, 23, 42, 0.04);
}
.smsSentTypeLabel {
font-size: 11px;
font-weight: 500;
line-height: 1.35;
color: var(--text-secondary);
}
.smsSentTypeValue {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
font-family: var(--font-ui);
color: var(--text-primary);
}
@media (max-width: 900px) {
.smsSentTypesRow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.smsSentTypesRow {
grid-template-columns: 1fr;
}
}
.smsTemplatesSummary { .smsTemplatesSummary {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -146,6 +146,46 @@
gap: 8px; gap: 8px;
} }
.smsSentTypesRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.smsSentTypeCell {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 10px 12px;
border-radius: var(--radius-sm, 10px);
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(15, 23, 42, 0.04);
}
.smsSentTypeLabel {
font-size: 12px;
font-weight: 500;
line-height: 1.35;
color: var(--text-secondary);
}
.smsSentTypeValue {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
text-align: end;
}
@media (max-width: 720px) {
.smsSentTypesRow {
grid-template-columns: 1fr;
}
}
.entityGroupLabel { .entityGroupLabel {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -2173,6 +2173,7 @@ export function BusinessesPage() {
{(smsSettingsBusiness?.smsSentCount ?? 0).toLocaleString('en-US')} {(smsSettingsBusiness?.smsSentCount ?? 0).toLocaleString('en-US')}
</strong> </strong>
</div> </div>
<div className={styles.smsSentTypesRow}>
{( {(
[ [
['Mass send', smsSettingsBusiness?.smsSentByType?.mass], ['Mass send', smsSettingsBusiness?.smsSentByType?.mass],
@@ -2182,21 +2183,21 @@ export function BusinessesPage() {
'Auto customer', 'Auto customer',
smsSettingsBusiness?.smsSentByType?.autoCustomer, smsSettingsBusiness?.smsSentByType?.autoCustomer,
], ],
['Admin notification', smsSettingsBusiness?.smsSentByType?.admin], [
'Admin notification',
smsSettingsBusiness?.smsSentByType?.admin,
],
] as const ] as const
).map(([label, value]) => ( ).map(([label, value]) => (
<div <div key={label} className={styles.smsSentTypeCell} role="status">
key={label} <span className={styles.smsSentTypeLabel}>{label}</span>
className={`${styles.dataRow} ${styles.dataRowNeutral}`} <strong className={styles.smsSentTypeValue}>
role="status"
>
<span className={styles.dataRowLabel}>{label}</span>
<strong className={styles.dataRowValue}>
{(value ?? 0).toLocaleString('en-US')} {(value ?? 0).toLocaleString('en-US')}
</strong> </strong>
</div> </div>
))} ))}
</div> </div>
</div>
<p className={styles.helperText}> <p className={styles.helperText}>
Lifetime messages billed (one per recipient), split by type. Auto customer and Lifetime messages billed (one per recipient), split by type. Auto customer and
admin notification are reserved for upcoming flows. admin notification are reserved for upcoming flows.