From 9913b7cbe71025abe11af6128daae5ff43989b2f Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 18 Jul 2026 22:04:35 +0300 Subject: [PATCH] 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 --- .../(components)/bulk-send/bulk-csv.ts | 30 +- .../(app)/dashboard/(components)/send-sms.tsx | 282 ------------------ .../dashboard/(components)/send-sms/index.tsx | 282 ++++++++++++++++++ .../(components)/send-sms/recipient-input.tsx | 131 ++++++++ web/e2e/chrome.spec.ts | 22 +- web/e2e/send-sms.spec.ts | 168 +++++++++++ web/lib/sms.ts | 26 ++ 7 files changed, 629 insertions(+), 312 deletions(-) delete mode 100644 web/app/(app)/dashboard/(components)/send-sms.tsx create mode 100644 web/app/(app)/dashboard/(components)/send-sms/index.tsx create mode 100644 web/app/(app)/dashboard/(components)/send-sms/recipient-input.tsx create mode 100644 web/e2e/send-sms.spec.ts diff --git a/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts b/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts index ae0e4fa..91428c3 100644 --- a/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts +++ b/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts @@ -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 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 } diff --git a/web/app/(app)/dashboard/(components)/send-sms.tsx b/web/app/(app)/dashboard/(components)/send-sms.tsx deleted file mode 100644 index 83531e8..0000000 --- a/web/app/(app)/dashboard/(components)/send-sms.tsx +++ /dev/null @@ -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({ - 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 ( -
- - -
- - Send SMS -
- Send a message to any recipient(s) -
- -
handleSubmit((data) => sendSms(data))(e)} - className='space-y-4' - > -
-
- ( - - )} - /> - {errors.deviceId && ( -

- {errors.deviceId.message} -

- )} -
- - {availableSims.length > 1 && ( -
- ( - - )} - /> - {errors.simSubscriptionId && ( -

- {errors.simSubscriptionId.message} -

- )} -
- )} - -
- {fields.map((field, index) => ( -
-
- - -
- {errors.recipients?.[index] && ( -

- {errors.recipients[index].message} -

- )} -
- ))} - - - {errors.recipients && ( -

- {errors.recipients.message} -

- )} - - {errors.recipients?.root && ( -

- {errors.recipients.root.message} -

- )} -
- -
-