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