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:
isra el
2026-07-18 22:04:35 +03:00
parent 8c818351d6
commit 9913b7cbe7
7 changed files with 629 additions and 312 deletions

View File

@@ -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 }

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
View 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')
})
})

View File

@@ -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())
}