mirror of
https://github.com/vernu/textbee.git
synced 2026-08-01 18:08:26 -04:00
feat: rebuild the send page with recipient chips and segment counting
Brings the send screen up to the standard of the rebuilt bulk send flow. Fixes real defects: - The single-device auto-select never worked. It was computed in defaultValues from devices?.data?.length === 1, but react-hook-form reads defaultValues once on mount, while the devices query is still pending, so the condition was always false. It now preselects once devices resolve. - Submitting before devices loaded showed "Required" on the device field, which then auto-filled a moment later, leaving a stale error beside a populated field. Submit is disabled until devices load. - Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared cache key with a different unwrapped shape that caused the devices?.filter crash previously. - Every field used a placeholder as its label. Placeholders vanish on focus and are not reliably announced, so all three now have real labels. Recipients are chips instead of a growing stack of inputs. They commit on Enter, comma or blur, so a number typed and left uncommitted is not silently dropped at send time. Pasting a list adds several at once. Splitting a pasted list needed care, and an e2e caught it: splitting on whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous separators split first; whitespace splitting is a fallback for tokens that cannot be a single number. Also adds a segment counter, inline alerts for success and failure instead of bare text, and a form reset after sending that keeps the device selected. Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send page and bulk send share one tested implementation. One test correction: the desktop footer assertion required links to sit on exactly one row, but the row layout wraps by design, so it failed whenever font metrics pushed a link to a second line. It now asserts the property the design actually guarantees, that links are not one-per-row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// Pure CSV / template logic for bulk send. No React and no network, so the
|
||||
// rules that decide who actually receives a message are directly testable.
|
||||
|
||||
import { isPlausiblePhone, normalizePhone } from '@/lib/sms'
|
||||
|
||||
export type CsvRow = Record<string, string>
|
||||
|
||||
export type RecipientRow = {
|
||||
@@ -80,32 +82,6 @@ export function detectRecipientColumn(columns: string[]): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip formatting so "+1 (415) 555-0101" and "+14155550101" are recognised as
|
||||
* the same recipient for duplicate detection.
|
||||
*/
|
||||
export function normalizePhone(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const hasPlus = trimmed.startsWith('+')
|
||||
const digits = trimmed.replace(/\D/g, '')
|
||||
return hasPlus ? `+${digits}` : digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive validity check. The gateway is the real authority on whether a
|
||||
* number can be reached, so this only rejects input that cannot possibly be a
|
||||
* phone number, rather than enforcing a strict E.164 shape that would block
|
||||
* legitimate local formats.
|
||||
*/
|
||||
export function isPlausiblePhone(value: string): boolean {
|
||||
const normalized = normalizePhone(value)
|
||||
const digits = normalized.replace(/\D/g, '')
|
||||
if (digits.length < 7 || digits.length > 15) return false
|
||||
// Reject anything carrying characters that are neither digits, separators,
|
||||
// nor a leading plus.
|
||||
return /^\+?[\d\s()./-]+$/.test(value.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide who actually receives a message.
|
||||
*
|
||||
@@ -202,3 +178,5 @@ export function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export { isPlausiblePhone, normalizePhone }
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { sendSmsSchema } from '@/lib/schemas'
|
||||
import type { SendSmsFormData } from '@/lib/schemas'
|
||||
import { MessageSquare, Send, Plus, X, UserCircle, Check } from 'lucide-react'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { RateLimitError } from '@/components/shared/rate-limit-error'
|
||||
import { formatDeviceName } from '@/lib/utils'
|
||||
|
||||
export default function SendSms() {
|
||||
const { data: devices, isLoading: isLoadingDevices } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () =>
|
||||
httpBrowserClient
|
||||
.get(ApiEndpoints.gateway.listDevices())
|
||||
.then((res) => res.data),
|
||||
})
|
||||
|
||||
const {
|
||||
mutate: sendSms,
|
||||
isPending: isSendingSms,
|
||||
error: sendSmsError,
|
||||
isSuccess: isSendSmsSuccess,
|
||||
} = useMutation({
|
||||
mutationKey: ['send-sms'],
|
||||
mutationFn: (data: SendSmsFormData) =>
|
||||
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
|
||||
})
|
||||
|
||||
const { toast } = useToast()
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<SendSmsFormData>({
|
||||
resolver: zodResolver(sendSmsSchema),
|
||||
defaultValues: {
|
||||
deviceId:
|
||||
devices?.data?.length === 1 ? devices?.data?.[0]?._id : undefined,
|
||||
recipients: [''],
|
||||
message: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
// @ts-expect-error
|
||||
name: 'recipients',
|
||||
})
|
||||
|
||||
const selectedDeviceId = useWatch({
|
||||
control,
|
||||
name: 'deviceId',
|
||||
})
|
||||
|
||||
const selectedDevice = devices?.data?.find(
|
||||
(device) => device._id === selectedDeviceId
|
||||
)
|
||||
|
||||
const availableSims =
|
||||
selectedDevice?.simInfo?.sims &&
|
||||
Array.isArray(selectedDevice.simInfo.sims) &&
|
||||
selectedDevice.simInfo.sims.length > 0
|
||||
? selectedDevice.simInfo.sims
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId) {
|
||||
setValue('simSubscriptionId', undefined)
|
||||
}
|
||||
}, [selectedDeviceId, setValue])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MessageSquare className='h-5 w-5' />
|
||||
<CardTitle>Send SMS</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Send a message to any recipient(s)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Controller
|
||||
name='deviceId'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a device' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices?.data?.map((device) => (
|
||||
<SelectItem
|
||||
key={device._id}
|
||||
value={device._id}
|
||||
disabled={!device.enabled}
|
||||
>
|
||||
{formatDeviceName(device)}{' '}
|
||||
{device.enabled ? '' : '(disabled)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.deviceId && (
|
||||
<p className='text-sm text-destructive mt-1'>
|
||||
{errors.deviceId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableSims.length > 1 && (
|
||||
<div>
|
||||
<Controller
|
||||
name='simSubscriptionId'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select SIM (optional)' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSims.map((sim) => (
|
||||
<SelectItem
|
||||
key={sim.subscriptionId}
|
||||
value={sim.subscriptionId.toString()}
|
||||
>
|
||||
{sim.displayName || 'SIM'} ({sim.subscriptionId})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.simSubscriptionId && (
|
||||
<p className='text-sm text-destructive mt-1'>
|
||||
{errors.simSubscriptionId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='tel'
|
||||
placeholder='Phone Number'
|
||||
{...register(`recipients.${index}`)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => remove(index)}
|
||||
disabled={index === 0 && fields?.length === 1}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.recipients?.[index] && (
|
||||
<p className='text-sm text-destructive'>
|
||||
{errors.recipients[index].message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => append('')}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Add Recipient
|
||||
</Button>
|
||||
|
||||
{errors.recipients && (
|
||||
<p className='text-sm text-destructive'>
|
||||
{errors.recipients.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errors.recipients?.root && (
|
||||
<p className='text-sm text-destructive'>
|
||||
{errors.recipients.root.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder='Message'
|
||||
{...register('message')}
|
||||
rows={4}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className='text-sm text-destructive mt-1'>
|
||||
{errors.message.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sendSmsError && (() => {
|
||||
const formattedError = formatError(sendSmsError)
|
||||
if (formattedError.isRateLimit) {
|
||||
return (
|
||||
<RateLimitError
|
||||
errorData={formattedError.rateLimitData}
|
||||
variant="inline"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='flex items-center gap-2 text-destructive'>
|
||||
<p>Error sending SMS: {formattedError.message}</p>
|
||||
<X className='h-5 w-5' />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isSendSmsSuccess && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<p>SMS sent successfully!</p>
|
||||
<Check className='h-5 w-5' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type='submit' disabled={isSendingSms} className='w-full'>
|
||||
{isSendingSms && (
|
||||
<Spinner size='sm' className='mr-2' color='white' />
|
||||
)}
|
||||
{isSendingSms ? 'Sending...' : 'Send Message'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
web/app/(app)/dashboard/(components)/send-sms/index.tsx
Normal file
282
web/app/(app)/dashboard/(components)/send-sms/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useForm, Controller, useWatch } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { sendSmsSchema, type SendSmsFormData } from '@/lib/schemas'
|
||||
import { AlertCircle, CheckCircle2, MessageSquare, Send } from 'lucide-react'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { RateLimitError } from '@/components/shared/rate-limit-error'
|
||||
import { formatDeviceName } from '@/lib/utils'
|
||||
import { useDevices, useSendSms } from '@/lib/api'
|
||||
import { getSegmentInfo } from '@/lib/sms'
|
||||
import RecipientInput from './recipient-input'
|
||||
|
||||
export default function SendSms() {
|
||||
// Typed hook rather than a raw useQuery(['devices']) reading devices.data:
|
||||
// that shared cache key with a different unwrapped shape is what caused the
|
||||
// devices?.filter crash previously.
|
||||
const { data: devices, isPending: devicesPending } = useDevices()
|
||||
const {
|
||||
mutate: sendSms,
|
||||
isPending: isSendingSms,
|
||||
error: sendSmsError,
|
||||
isSuccess,
|
||||
reset: resetSend,
|
||||
} = useSendSms()
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<SendSmsFormData>({
|
||||
resolver: zodResolver(sendSmsSchema),
|
||||
defaultValues: { deviceId: undefined, recipients: [], message: '' },
|
||||
})
|
||||
|
||||
const recipients = useWatch({ control, name: 'recipients' }) ?? []
|
||||
const message = useWatch({ control, name: 'message' }) ?? ''
|
||||
const selectedDeviceId = useWatch({ control, name: 'deviceId' })
|
||||
|
||||
const enabledDevices = devices?.filter((device) => device.enabled) ?? []
|
||||
// A primitive, so the effect below does not re-run on every render just
|
||||
// because filter() returns a new array.
|
||||
const soleDeviceId =
|
||||
enabledDevices.length === 1 ? enabledDevices[0]._id : undefined
|
||||
|
||||
// Preselect the only usable device once devices have loaded. This used to be
|
||||
// computed in defaultValues, which react-hook-form reads once on mount while
|
||||
// the query is still pending, so the auto-select never actually happened.
|
||||
useEffect(() => {
|
||||
if (!selectedDeviceId && soleDeviceId) {
|
||||
setValue('deviceId', soleDeviceId)
|
||||
}
|
||||
}, [selectedDeviceId, soleDeviceId, setValue])
|
||||
|
||||
const selectedDevice = devices?.find((d) => d._id === selectedDeviceId)
|
||||
const availableSims = Array.isArray((selectedDevice as any)?.simInfo?.sims)
|
||||
? (selectedDevice as any).simInfo.sims
|
||||
: []
|
||||
|
||||
const segments = getSegmentInfo(message)
|
||||
|
||||
const onSubmit = (data: SendSmsFormData) =>
|
||||
sendSms(data, {
|
||||
onSuccess: () => {
|
||||
// Reset so the next message starts clean, keeping the chosen device.
|
||||
reset({
|
||||
deviceId: data.deviceId,
|
||||
recipients: [],
|
||||
message: '',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MessageSquare className='h-5 w-5 text-primary' />
|
||||
<CardTitle>Send SMS</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Send a message from one of your connected devices.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='space-y-5'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='sms-device'>Send from</Label>
|
||||
<Controller
|
||||
name='deviceId'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||
<SelectTrigger id='sms-device'>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
devicesPending ? 'Loading devices...' : 'Select a device'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices?.map((device) => (
|
||||
<SelectItem
|
||||
key={device._id}
|
||||
value={device._id}
|
||||
disabled={!device.enabled}
|
||||
>
|
||||
{formatDeviceName(device)}
|
||||
{device.enabled ? '' : ' (disabled)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.deviceId && (
|
||||
<p className='text-sm text-destructive'>
|
||||
{errors.deviceId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableSims.length > 1 && (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='sms-sim'>SIM (optional)</Label>
|
||||
<Controller
|
||||
name='simSubscriptionId'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
value={field.value?.toString() ?? ''}
|
||||
>
|
||||
<SelectTrigger id='sms-sim'>
|
||||
<SelectValue placeholder='Default SIM' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSims.map((sim: any) => (
|
||||
<SelectItem
|
||||
key={sim.subscriptionId}
|
||||
value={String(sim.subscriptionId)}
|
||||
>
|
||||
{sim.displayName || 'SIM'} ({sim.subscriptionId})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name='recipients'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RecipientInput
|
||||
recipients={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
error={
|
||||
errors.recipients?.message ??
|
||||
errors.recipients?.root?.message ??
|
||||
(Array.isArray(errors.recipients)
|
||||
? errors.recipients.find(Boolean)?.message
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='sms-message'>Message</Label>
|
||||
<Textarea
|
||||
id='sms-message'
|
||||
placeholder='Type your message'
|
||||
rows={5}
|
||||
{...register('message')}
|
||||
/>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground'>
|
||||
<span>
|
||||
{segments.length} characters, {segments.segments} segment
|
||||
{segments.segments === 1 ? '' : 's'} ({segments.encoding})
|
||||
</span>
|
||||
{segments.segments > 1 && (
|
||||
<span className='text-amber-600 dark:text-amber-500'>
|
||||
Over {segments.perSegment} characters counts as multiple
|
||||
messages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.message && (
|
||||
<p className='text-sm text-destructive'>
|
||||
{errors.message.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sendSmsError &&
|
||||
(() => {
|
||||
const formatted = formatError(sendSmsError)
|
||||
if (formatted.isRateLimit) {
|
||||
return (
|
||||
<RateLimitError
|
||||
errorData={formatted.rateLimitData}
|
||||
variant='alert'
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Could not send</AlertTitle>
|
||||
<AlertDescription>{formatted.message}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isSuccess && (
|
||||
<Alert>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
<AlertTitle>Message sent</AlertTitle>
|
||||
<AlertDescription>
|
||||
Delivery status appears in your message history.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='w-full'
|
||||
// Also disabled while devices load: submitting first showed
|
||||
// "Required" on the device field, which then auto-filled a moment
|
||||
// later, leaving a stale error next to a populated field.
|
||||
disabled={isSendingSms || devicesPending}
|
||||
onClick={() => {
|
||||
// Clear a previous result so the alert reflects this attempt.
|
||||
if (isSuccess || sendSmsError) resetSend()
|
||||
}}
|
||||
>
|
||||
{isSendingSms ? (
|
||||
<>
|
||||
<Spinner size='sm' className='mr-2' color='white' />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className='h-4 w-4' />
|
||||
{recipients.length > 1
|
||||
? `Send to ${recipients.length} recipients`
|
||||
: 'Send message'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { isPlausiblePhone, normalizePhone } from '@/lib/sms'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Numbers commit on Enter, comma or blur, and paste splits on separators, so a
|
||||
// list copied from a spreadsheet becomes chips in one action. Replaces a
|
||||
// growing stack of full-width inputs, each with its own remove button.
|
||||
export default function RecipientInput({
|
||||
recipients,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
recipients: string[]
|
||||
onChange: (next: string[]) => void
|
||||
error?: string
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
const commit = (raw: string): boolean => {
|
||||
// Split only on unambiguous separators first. Splitting on whitespace up
|
||||
// front would shred "+1 (415) 555-0101" into three fragments, since spaces
|
||||
// are also formatting inside a single number.
|
||||
const candidates = raw
|
||||
.split(/[,;\n]+/)
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((token) =>
|
||||
// Only a token that cannot be one number gets split on whitespace,
|
||||
// which is how "+14155550101 +16475550187" becomes two chips.
|
||||
isPlausiblePhone(token) ? [token] : token.split(/\s+/).filter(Boolean)
|
||||
)
|
||||
|
||||
if (candidates.length === 0) return true
|
||||
|
||||
const accepted: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (!isPlausiblePhone(candidate)) {
|
||||
setLocalError(`"${candidate}" does not look like a phone number`)
|
||||
return false
|
||||
}
|
||||
// The API schema accepts digits with an optional leading plus, so
|
||||
// formatting is stripped before the value reaches the form.
|
||||
const normalized = normalizePhone(candidate)
|
||||
if (!recipients.includes(normalized) && !accepted.includes(normalized)) {
|
||||
accepted.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
setLocalError(null)
|
||||
if (accepted.length) onChange([...recipients, ...accepted])
|
||||
return true
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
if (commit(draft)) setDraft('')
|
||||
return
|
||||
}
|
||||
// Backspace on an empty field removes the last chip, as in every other
|
||||
// token input people have used.
|
||||
if (event.key === 'Backspace' && draft === '' && recipients.length > 0) {
|
||||
onChange(recipients.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (value: string) =>
|
||||
onChange(recipients.filter((r) => r !== value))
|
||||
|
||||
const shownError = localError ?? error
|
||||
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='sms-recipient'>To</Label>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<ul className='flex flex-wrap gap-1.5'>
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient}>
|
||||
<span className='inline-flex items-center gap-1 rounded-full bg-muted py-1 pl-2.5 pr-1 text-sm'>
|
||||
{recipient}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => remove(recipient)}
|
||||
aria-label={`Remove ${recipient}`}
|
||||
className='rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id='sms-recipient'
|
||||
type='tel'
|
||||
inputMode='tel'
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
if (localError) setLocalError(null)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
// Commit on blur too: people type a number then click Send, and losing
|
||||
// it silently would be the worst possible outcome.
|
||||
onBlur={() => {
|
||||
if (commit(draft)) setDraft('')
|
||||
}}
|
||||
placeholder={
|
||||
recipients.length ? 'Add another number' : '+14155550101'
|
||||
}
|
||||
aria-describedby='sms-recipient-hint'
|
||||
className={cn(shownError && 'border-destructive')}
|
||||
/>
|
||||
|
||||
<p id='sms-recipient-hint' className='text-xs text-muted-foreground'>
|
||||
Press Enter or comma to add. Paste a list to add several at once.
|
||||
</p>
|
||||
|
||||
{shownError && <p className='text-sm text-destructive'>{shownError}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -83,16 +83,30 @@ test.describe('app chrome (mocked API, no real backend)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('desktop: footer links sit on one row', async ({ page, context }) => {
|
||||
test('desktop: footer links share rows instead of stacking', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard')
|
||||
// Text metrics decide wrapping, so wait for fonts before measuring.
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
const links = page.locator('footer nav a')
|
||||
const first = await links.first().boundingBox()
|
||||
const last = await links.last().boundingBox()
|
||||
expect(Math.abs(first!.y - last!.y)).toBeLessThan(8)
|
||||
const count = await links.count()
|
||||
|
||||
const ys: number[] = []
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const box = await links.nth(i).boundingBox()
|
||||
ys.push(Math.round(box!.y))
|
||||
}
|
||||
|
||||
// The row layout wraps by design, so the guarantee is "not one per row",
|
||||
// not "exactly one row": asserting a single row made this flaky whenever
|
||||
// font metrics pushed a link onto a second line.
|
||||
expect(new Set(ys).size).toBeLessThan(count)
|
||||
})
|
||||
|
||||
test('top bar is reduced to brand and account', async ({ page, context }) => {
|
||||
|
||||
168
web/e2e/send-sms.spec.ts
Normal file
168
web/e2e/send-sms.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate } from './session'
|
||||
import { mockApi } from './mock-api'
|
||||
|
||||
async function captureSend(page: import('@playwright/test').Page) {
|
||||
const payloads: any[] = []
|
||||
await page.route('**/api/v1/gateway/devices/*/send-sms', (route) => {
|
||||
payloads.push(route.request().postDataJSON())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: { success: true } }),
|
||||
})
|
||||
})
|
||||
return payloads
|
||||
}
|
||||
|
||||
test.describe('send sms (mocked API, no real backend)', () => {
|
||||
test('commits recipients as chips and posts exactly those numbers', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
const payloads = await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('+1 (415) 555-0101')
|
||||
await to.press('Enter')
|
||||
await to.fill('+16475550187')
|
||||
await to.press('Enter')
|
||||
|
||||
// Both are chips now, each removable.
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove +14155550101' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove +16475550187' })
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByLabel('Message').fill('Hello from the send page')
|
||||
await page.getByRole('button', { name: /Send to 2 recipients/ }).click()
|
||||
|
||||
await expect(page.getByText('Message sent')).toBeVisible()
|
||||
expect(payloads).toHaveLength(1)
|
||||
// Formatting is stripped, because the API schema accepts digits with an
|
||||
// optional leading plus.
|
||||
expect(payloads[0].recipients).toEqual(['+14155550101', '+16475550187'])
|
||||
expect(payloads[0].message).toBe('Hello from the send page')
|
||||
})
|
||||
|
||||
test('splits a pasted list into separate chips', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
await page
|
||||
.getByLabel('To', { exact: true })
|
||||
.fill('+14155550101, +16475550187 +12065550142')
|
||||
await page.getByLabel('To', { exact: true }).press('Enter')
|
||||
|
||||
for (const number of ['+14155550101', '+16475550187', '+12065550142']) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: `Remove ${number}` })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps a spaced number whole instead of shredding it', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
// Spaces are formatting inside one number here, not separators.
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('+1 415 555 0101')
|
||||
await to.press('Enter')
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove +14155550101' })
|
||||
).toBeVisible()
|
||||
// Exactly one chip, not four fragments.
|
||||
await expect(page.getByRole('button', { name: /^Remove/ })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('rejects a number that cannot be dialled', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('not a number')
|
||||
await to.press('Enter')
|
||||
|
||||
await expect(
|
||||
page.getByText(/does not look like a phone number/)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not silently drop a number typed but not committed', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
const payloads = await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
// Type a number then go straight to the message without pressing Enter.
|
||||
await page.getByLabel('To', { exact: true }).fill('+14155550101')
|
||||
await page.getByLabel('Message').fill('Committed on blur')
|
||||
await page.getByRole('button', { name: /Send message/ }).click()
|
||||
|
||||
await expect(page.getByText('Message sent')).toBeVisible()
|
||||
expect(payloads[0].recipients).toEqual(['+14155550101'])
|
||||
})
|
||||
|
||||
test('preselects the only enabled device', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
// Fixtures have one enabled device (Pixel 8) and one disabled. The old
|
||||
// implementation computed this in defaultValues before devices loaded, so
|
||||
// it never preselected anything.
|
||||
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
|
||||
})
|
||||
|
||||
test('counts SMS segments', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
|
||||
await page.getByLabel('Message').fill('a'.repeat(161))
|
||||
await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears the form after a successful send', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('+14155550101')
|
||||
await to.press('Enter')
|
||||
await page.getByLabel('Message').fill('One off')
|
||||
await page.getByRole('button', { name: /Send message/ }).click()
|
||||
|
||||
await expect(page.getByText('Message sent')).toBeVisible()
|
||||
await expect(page.getByLabel('Message')).toHaveValue('')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove +14155550101' })
|
||||
).toHaveCount(0)
|
||||
// The device stays selected, since the next message usually goes out the
|
||||
// same way.
|
||||
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
|
||||
})
|
||||
})
|
||||
@@ -46,3 +46,29 @@ export function getSegmentInfo(text: string): SegmentInfo {
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip formatting so "+1 (415) 555-0101" and "+14155550101" are recognised as
|
||||
* the same recipient for duplicate detection.
|
||||
*/
|
||||
export function normalizePhone(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const hasPlus = trimmed.startsWith('+')
|
||||
const digits = trimmed.replace(/\D/g, '')
|
||||
return hasPlus ? `+${digits}` : digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive validity check. The gateway is the real authority on whether a
|
||||
* number can be reached, so this only rejects input that cannot possibly be a
|
||||
* phone number, rather than enforcing a strict E.164 shape that would block
|
||||
* legitimate local formats.
|
||||
*/
|
||||
export function isPlausiblePhone(value: string): boolean {
|
||||
const normalized = normalizePhone(value)
|
||||
const digits = normalized.replace(/\D/g, '')
|
||||
if (digits.length < 7 || digits.length > 15) return false
|
||||
// Reject anything carrying characters that are neither digits, separators,
|
||||
// nor a leading plus.
|
||||
return /^\+?[\d\s()./-]+$/.test(value.trim())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user