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:
co-authored by
Cursor
parent
dbc17b2589
commit
29a190f02a
@@ -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,24 +288,31 @@ 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>
|
||||||
['settings.sms.sentTotal', sentCount],
|
<strong className={settingsStyles.smsCreditValue}>
|
||||||
['settings.sms.sentMass', sentByType.mass],
|
{loading ? '…' : sentCount.toLocaleString(isFa ? 'fa-IR' : 'en-US')}
|
||||||
['settings.sms.sentSingular', sentByType.singular],
|
</strong>
|
||||||
['settings.sms.sentAuth', sentByType.auth],
|
</div>
|
||||||
['settings.sms.sentAutoCustomer', sentByType.autoCustomer],
|
<div className={settingsStyles.smsSentTypesRow}>
|
||||||
['settings.sms.sentAdmin', sentByType.admin],
|
{(
|
||||||
] as const
|
[
|
||||||
).map(([labelKey, value]) => (
|
['settings.sms.sentMass', sentByType.mass],
|
||||||
<div key={labelKey} className={`${settingsStyles.smsCreditRow} ${settingsStyles.smsSentRow}`}>
|
['settings.sms.sentSingular', sentByType.singular],
|
||||||
<span className={settingsStyles.smsCreditLabel}>{t(labelKey)}</span>
|
['settings.sms.sentAuth', sentByType.auth],
|
||||||
<strong className={settingsStyles.smsCreditValue}>
|
['settings.sms.sentAutoCustomer', sentByType.autoCustomer],
|
||||||
{loading ? '…' : value.toLocaleString(isFa ? 'fa-IR' : 'en-US')}
|
['settings.sms.sentAdmin', sentByType.admin],
|
||||||
</strong>
|
] as const
|
||||||
</div>
|
).map(([labelKey, value]) => (
|
||||||
))}
|
<div key={labelKey} className={settingsStyles.smsSentTypeCell}>
|
||||||
|
<span className={settingsStyles.smsSentTypeLabel}>{t(labelKey)}</span>
|
||||||
|
<strong className={settingsStyles.smsSentTypeValue}>
|
||||||
|
{loading ? '…' : value.toLocaleString(isFa ? 'fa-IR' : 'en-US')}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={settingsStyles.smsTemplatesSummary}>
|
<div className={settingsStyles.smsTemplatesSummary}>
|
||||||
|
|||||||
@@ -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,29 +2173,30 @@ 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],
|
|
||||||
['Singular', smsSettingsBusiness?.smsSentByType?.singular],
|
|
||||||
['Auth service', smsSettingsBusiness?.smsSentByType?.auth],
|
|
||||||
[
|
[
|
||||||
'Auto customer',
|
['Mass send', smsSettingsBusiness?.smsSentByType?.mass],
|
||||||
smsSettingsBusiness?.smsSentByType?.autoCustomer,
|
['Singular', smsSettingsBusiness?.smsSentByType?.singular],
|
||||||
],
|
['Auth service', smsSettingsBusiness?.smsSentByType?.auth],
|
||||||
['Admin notification', smsSettingsBusiness?.smsSentByType?.admin],
|
[
|
||||||
] as const
|
'Auto customer',
|
||||||
).map(([label, value]) => (
|
smsSettingsBusiness?.smsSentByType?.autoCustomer,
|
||||||
<div
|
],
|
||||||
key={label}
|
[
|
||||||
className={`${styles.dataRow} ${styles.dataRowNeutral}`}
|
'Admin notification',
|
||||||
role="status"
|
smsSettingsBusiness?.smsSentByType?.admin,
|
||||||
>
|
],
|
||||||
<span className={styles.dataRowLabel}>{label}</span>
|
] as const
|
||||||
<strong className={styles.dataRowValue}>
|
).map(([label, value]) => (
|
||||||
{(value ?? 0).toLocaleString('en-US')}
|
<div key={label} className={styles.smsSentTypeCell} role="status">
|
||||||
</strong>
|
<span className={styles.smsSentTypeLabel}>{label}</span>
|
||||||
</div>
|
<strong className={styles.smsSentTypeValue}>
|
||||||
))}
|
{(value ?? 0).toLocaleString('en-US')}
|
||||||
|
</strong>
|
||||||
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user