From bd808333b36f04b05a25bd75c8de9dc914caac0f Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 19 Jul 2026 09:16:05 +0300 Subject: [PATCH] refactor: split bulk-send into a state hook and per-step components index.tsx was 732 lines, the largest file in the app by a wide margin, holding all wizard state, the CSV parsing callbacks, the send mutation and four steps of JSX in one component. State and behaviour move to use-bulk-send, matching the useOnboarding pattern the get-started folder already uses. The four steps and the success panel become presentation components. index.tsx is now 23 lines of composition. Each step destructures what it needs from the hook result at the top, which leaves the moved JSX byte-identical to what it replaced. That was a deliberate second attempt: the first pass rewrote every reference to bulk. inline, which corrupted a string literal (an input id became "message-bulk.template") and produced 22 false "cannot access refs during render" warnings in the one step that receives the ref. Warnings are back to the 21-warning baseline. Verified: typecheck clean, build clean, 0 lint errors, 155 unit tests, 78 e2e. The seven bulk-send e2e tests walk upload, mapping, compose and a full send, which is the evidence that behaviour did not move with the code. No test needed editing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(components)/bulk-send/constants.ts | 10 + .../(components)/bulk-send/index.tsx | 733 +----------------- .../(components)/bulk-send/step-compose.tsx | 136 ++++ .../(components)/bulk-send/step-map.tsx | 154 ++++ .../(components)/bulk-send/step-review.tsx | 105 +++ .../(components)/bulk-send/step-upload.tsx | 166 ++++ .../(components)/bulk-send/success-panel.tsx | 40 + .../(components)/bulk-send/use-bulk-send.ts | 282 +++++++ 8 files changed, 905 insertions(+), 721 deletions(-) create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/constants.ts create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/step-compose.tsx create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/step-map.tsx create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/step-review.tsx create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/step-upload.tsx create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/success-panel.tsx create mode 100644 web/app/(app)/dashboard/(components)/bulk-send/use-bulk-send.ts diff --git a/web/app/(app)/dashboard/(components)/bulk-send/constants.ts b/web/app/(app)/dashboard/(components)/bulk-send/constants.ts new file mode 100644 index 0000000..2b1e11c --- /dev/null +++ b/web/app/(app)/dashboard/(components)/bulk-send/constants.ts @@ -0,0 +1,10 @@ +export const MAX_FILE_SIZE = 1024 * 1024 // 1 MB +export const FALLBACK_MAX_ROWS = 50 +export const SAMPLE_CSV = '/samples/bulk-sms-sample.csv' +export const PREVIEW_ROWS = 5 + +export const REASON_LABEL: Record = { + empty: 'no phone number', + invalid: 'not a valid phone number', + duplicate: 'duplicate number', +} diff --git a/web/app/(app)/dashboard/(components)/bulk-send/index.tsx b/web/app/(app)/dashboard/(components)/bulk-send/index.tsx index e559695..20a247c 100644 --- a/web/app/(app)/dashboard/(components)/bulk-send/index.tsx +++ b/web/app/(app)/dashboard/(components)/bulk-send/index.tsx @@ -1,732 +1,23 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' -import { useDropzone, type FileRejection } from 'react-dropzone' -import Papa from 'papaparse' -import { - AlertCircle, - CheckCircle2, - ChevronLeft, - ChevronRight, - Download, - FileSpreadsheet, - Send, - Upload, - X, -} from 'lucide-react' -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 { useMutation } from '@tanstack/react-query' -import httpBrowserClient from '@/lib/httpBrowserClient' -import { ApiEndpoints } from '@/config/api' -import { formatError } from '@/lib/utils/errorHandler' -import { RateLimitError } from '@/components/shared/rate-limit-error' -import { formatDeviceName, cn } from '@/lib/utils' -import { useDevices, useSubscription } from '@/lib/api' -import { getSegmentInfo } from '@/lib/sms' -import StepShell from './step-shell' -import { - buildRecipientPlan, - detectRecipientColumn, - extractTemplateVariables, - findUnknownVariables, - formatFileSize, - renderTemplate, - type CsvRow, -} from './bulk-csv' - -const MAX_FILE_SIZE = 1024 * 1024 // 1 MB -const FALLBACK_MAX_ROWS = 50 -const SAMPLE_CSV = '/samples/bulk-sms-sample.csv' -const PREVIEW_ROWS = 5 - -const REASON_LABEL: Record = { - empty: 'no phone number', - invalid: 'not a valid phone number', - duplicate: 'duplicate number', -} +import { useBulkSend } from './use-bulk-send' +import SuccessPanel from './success-panel' +import UploadStep from './step-upload' +import MapStep from './step-map' +import ComposeStep from './step-compose' +import ReviewStep from './step-review' export default function BulkSend() { - const [rows, setRows] = useState([]) - const [columns, setColumns] = useState([]) - const [fileName, setFileName] = useState(null) - const [recipientColumn, setRecipientColumn] = useState('') - const [template, setTemplate] = useState('') - const [deviceId, setDeviceId] = useState(null) - const [simSubscriptionId, setSimSubscriptionId] = useState() - const [previewIndex, setPreviewIndex] = useState(0) - const [fileError, setFileError] = useState(null) - const [fileWarning, setFileWarning] = useState(null) - const templateRef = useRef(null) + const bulk = useBulkSend() - const { data: devices } = useDevices() - const { data: subscription, isPending: subscriptionPending } = - useSubscription() - - // Only enforce a row cap once the real limit is known. The old version - // defaulted to 50 while the subscription was still loading, so an unlimited - // plan could be told its file was too big. - const maxRows = useMemo(() => { - const limit = - subscription?.usage?.bulkSendLimit ?? subscription?.plan?.bulkSendLimit - if (subscriptionPending) return undefined - if (limit === -1) return Number.POSITIVE_INFINITY - return limit || FALLBACK_MAX_ROWS - }, [subscription, subscriptionPending]) - - const resetFile = () => { - setRows([]) - setColumns([]) - setFileName(null) - setRecipientColumn('') - setPreviewIndex(0) - setFileWarning(null) - } - - // Derived rather than checked once at drop time. The subscription can still - // be loading when a file lands, in which case maxRows is undefined and the - // old imperative check was skipped entirely and never revisited, so an - // over-cap file sailed through on a limited plan. - const rowCapExceeded = maxRows !== undefined && rows.length > maxRows - const rowCapUnknown = rows.length > 0 && maxRows === undefined - - const handleRecipientColumnChange = (value: string) => { - setRecipientColumn(value) - // plan.valid is rebuilt from the new column, so an index into the old - // list can point past the end of the new one. - setPreviewIndex(0) - } - - const onDrop = useCallback((accepted: File[], rejections: FileRejection[]) => { - const file = accepted[0] - - // react-dropzone still calls onDrop when every file was filtered out by - // `accept`, so returning silently here left a rejected drop with no - // feedback at all: nothing happened and nothing said why. - if (!file) { - const rejected = rejections[0] - if (rejected) { - setFileError( - rejections.length > 1 - ? 'Please drop a single CSV file.' - : `"${rejected.file.name}" is not a CSV. Export your spreadsheet as CSV and try again.` - ) - } - return - } - - if (file.size > MAX_FILE_SIZE) { - setFileError( - `That file is ${formatFileSize(file.size)}. The limit is ${formatFileSize(MAX_FILE_SIZE)}.` - ) - return - } - - Papa.parse(file, { - header: true, - skipEmptyLines: true, - complete: (results) => { - const parsed = (results.data ?? []).filter(Boolean) - - if (parsed.length === 0) { - setFileError('That file has no rows. Check it has a header row.') - resetFile() - return - } - - // Take the header papaparse actually parsed. Reading keys off the - // first data row instead loses every column that row happened to - // leave blank, because papaparse only assigns keys for values that - // are present. - const headers = (results.meta?.fields ?? []).filter(Boolean) - - if (headers.length === 0) { - setFileError('That file has no header row, so columns cannot be mapped.') - resetFile() - return - } - - setRows(parsed) - setColumns(headers) - setFileName(file.name) - setRecipientColumn(detectRecipientColumn(headers) ?? '') - setPreviewIndex(0) - setFileError(null) - - // Row-level problems are reported here, not through `error`, which - // only fires on a fatal stream failure. A mis-delimited file parses - // "successfully" into one mangled column, so this is worth saying. - const problems = results.errors ?? [] - setFileWarning( - problems.length > 0 - ? `${problems.length} row${problems.length === 1 ? '' : 's'} did not parse cleanly (first problem on row ${ - (problems[0].row ?? 0) + 1 - }: ${problems[0].message}). Check the delimiter and quoting if the columns below look wrong.` - : null - ) - }, - error: () => setFileError('That file could not be read as CSV.'), - }) - }, []) - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: { 'text/csv': ['.csv'] }, - multiple: false, - }) - - const plan = useMemo( - () => - recipientColumn - ? buildRecipientPlan(rows, recipientColumn) - : { valid: [], excluded: [], counts: null }, - [rows, recipientColumn] - ) - - const unknownVariables = useMemo( - () => findUnknownVariables(template, columns), - [template, columns] - ) - - const selectedDevice = devices?.find((d) => d._id === deviceId) - const availableSims = Array.isArray((selectedDevice as any)?.simInfo?.sims) - ? (selectedDevice as any).simInfo.sims - : [] - - // Clamped, so the preview can never be stranded past the end of a shorter - // list. Anything that shrinks plan.valid (changing the phone column, most - // obviously) would otherwise hide the preview and disable its own Next - // button, leaving Previous as the only way back. - const safePreviewIndex = Math.min( - previewIndex, - Math.max(0, plan.valid.length - 1) - ) - const previewRow = plan.valid[safePreviewIndex] - const previewMessage = previewRow - ? renderTemplate(template, previewRow.data) - : '' - // Counted from the rendered message, never the raw template: falling back to - // the template counts the literal "{{ name }}" placeholders and misstates - // the segment cost. - const segments = getSegmentInfo(previewMessage) - - const hasFile = rows.length > 0 - const mapped = - hasFile && - !rowCapExceeded && - !rowCapUnknown && - Boolean(recipientColumn) && - plan.valid.length > 0 - const composed = mapped && template.trim().length > 0 && Boolean(deviceId) - - const { - mutate: sendBulk, - isPending: isSending, - isSuccess, - error: sendError, - reset: resetSend, - } = useMutation({ - mutationFn: async () => { - // Only rows that survived validation are sent. The previous version - // mapped every parsed row, including blanks and duplicates. - const messages = plan.valid.map((row) => ({ - message: renderTemplate(template, row.data), - recipients: [row.raw], - ...(simSubscriptionId !== undefined && { simSubscriptionId }), - })) - - return httpBrowserClient.post( - ApiEndpoints.gateway.sendBulkSMS(deviceId!), - { messageTemplate: template, messages } - ) - }, - }) - - const insertVariable = (column: string) => { - const el = templateRef.current - const token = `{{ ${column} }}` - if (!el) { - setTemplate((t) => t + token) - return - } - const start = el.selectionStart ?? template.length - const end = el.selectionEnd ?? template.length - const next = template.slice(0, start) + token + template.slice(end) - setTemplate(next) - requestAnimationFrame(() => { - el.focus() - el.setSelectionRange(start + token.length, start + token.length) - }) - } - - if (isSuccess) { - return ( -
-
- -
-

- {plan.valid.length.toLocaleString()} message - {plan.valid.length === 1 ? '' : 's'} queued -

-

- Delivery status appears in your message history as each one is sent. -

-
- - -
-
- ) - } + if (bulk.isSuccess) return return (
- {/* 1. Upload */} - - {hasFile ? ( -
-
- -
-

{fileName}

-

- {rows.length.toLocaleString()} rows, {columns.length} columns -

-
- -
- - {/* Show what was actually parsed, so a mis-delimited file is - obvious before anything is sent. */} -
- - - - {columns.map((c) => ( - - ))} - - - - {rows.slice(0, PREVIEW_ROWS).map((row, i) => ( - - {columns.map((c) => ( - - ))} - - ))} - -
- {c} -
- {row[c] || '-'} -
-
- {rows.length > PREVIEW_ROWS && ( -

- Showing the first {PREVIEW_ROWS} of{' '} - {rows.length.toLocaleString()} rows. -

- )} -
- ) : ( - <> -
- - -

- Drop a CSV here, or tap to choose one -

-

- Up to {formatFileSize(MAX_FILE_SIZE)} - {maxRows !== undefined && maxRows !== Number.POSITIVE_INFINITY - ? ` and ${maxRows.toLocaleString()} rows on your plan` - : ''} -

-
- - - - Download a sample CSV - - - )} - - {fileError && ( - - - That file could not be used - {fileError} - - )} - - {rowCapExceeded && ( - - - That file is over your plan limit - - It has {rows.length.toLocaleString()} rows and your plan allows{' '} - {maxRows!.toLocaleString()} per bulk send. Remove the extra rows - or upgrade your plan. - - - )} - - {fileWarning && !fileError && ( - - - Some rows did not parse cleanly - {fileWarning} - - )} -
- - {/* 2. Map */} - -
-
- - -
- -
- - -
- - {availableSims.length > 1 && ( -
- - -
- )} -
- - {plan.counts && ( -
- - {plan.counts.valid.toLocaleString()} will receive a message - - {plan.counts.empty > 0 && ( - - {plan.counts.empty} with no number - - )} - {plan.counts.invalid > 0 && ( - - {plan.counts.invalid} invalid - - )} - {plan.counts.duplicate > 0 && ( - - {plan.counts.duplicate} duplicate - - )} -
- )} - - {recipientColumn && plan.valid.length === 0 && ( - - - No usable phone numbers - - No row in "{recipientColumn}" holds a valid phone number. Pick a - different column. - - - )} -
- - {/* 3. Compose */} - - {columns.length > 0 && ( -
- {columns.map((c) => ( - - ))} -
- )} - -
- -