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.<name> 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) <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-19 09:16:05 +03:00
parent 9c0826016a
commit bd808333b3
8 changed files with 905 additions and 721 deletions

View File

@@ -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<string, string> = {
empty: 'no phone number',
invalid: 'not a valid phone number',
duplicate: 'duplicate number',
}

View File

@@ -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<string, string> = {
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<CsvRow[]>([])
const [columns, setColumns] = useState<string[]>([])
const [fileName, setFileName] = useState<string | null>(null)
const [recipientColumn, setRecipientColumn] = useState('')
const [template, setTemplate] = useState('')
const [deviceId, setDeviceId] = useState<string | null>(null)
const [simSubscriptionId, setSimSubscriptionId] = useState<number>()
const [previewIndex, setPreviewIndex] = useState(0)
const [fileError, setFileError] = useState<string | null>(null)
const [fileWarning, setFileWarning] = useState<string | null>(null)
const templateRef = useRef<HTMLTextAreaElement>(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<CsvRow>(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 (
<div className='rounded-xl border border-border bg-card p-8 text-center'>
<div className='mx-auto mb-3 w-fit rounded-full bg-green-100 p-3 dark:bg-green-900/30'>
<CheckCircle2 className='h-6 w-6 text-green-600 dark:text-green-400' />
</div>
<h3 className='text-lg font-semibold'>
{plan.valid.length.toLocaleString()} message
{plan.valid.length === 1 ? '' : 's'} queued
</h3>
<p className='mt-1 text-sm text-muted-foreground'>
Delivery status appears in your message history as each one is sent.
</p>
<div className='mt-5 flex flex-wrap items-center justify-center gap-2'>
<Button asChild size='sm'>
<a href='/dashboard/messaging/history'>View message history</a>
</Button>
<Button
variant='outline'
size='sm'
onClick={() => {
resetSend()
resetFile()
setTemplate('')
}}
>
Send another batch
</Button>
</div>
</div>
)
}
if (bulk.isSuccess) return <SuccessPanel bulk={bulk} />
return (
<div className='space-y-4'>
{/* 1. Upload */}
<StepShell
step={1}
title='Upload your CSV'
description='One row per recipient, with a header row'
complete={hasFile}
>
{hasFile ? (
<div className='space-y-3'>
<div className='flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2'>
<FileSpreadsheet className='h-4 w-4 shrink-0 text-primary' />
<div className='min-w-0 flex-1'>
<p className='truncate text-sm font-medium'>{fileName}</p>
<p className='text-xs text-muted-foreground'>
{rows.length.toLocaleString()} rows, {columns.length} columns
</p>
</div>
<Button
variant='ghost'
size='icon'
onClick={resetFile}
aria-label='Remove file'
>
<X className='h-4 w-4' />
</Button>
</div>
{/* Show what was actually parsed, so a mis-delimited file is
obvious before anything is sent. */}
<div className='overflow-x-auto rounded-lg border border-border'>
<table className='w-full text-left text-xs'>
<thead className='bg-muted/50'>
<tr>
{columns.map((c) => (
<th key={c} className='whitespace-nowrap px-3 py-2 font-medium'>
{c}
</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-border'>
{rows.slice(0, PREVIEW_ROWS).map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td
key={c}
className='whitespace-nowrap px-3 py-1.5 text-muted-foreground'
>
{row[c] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length > PREVIEW_ROWS && (
<p className='text-xs text-muted-foreground'>
Showing the first {PREVIEW_ROWS} of{' '}
{rows.length.toLocaleString()} rows.
</p>
)}
</div>
) : (
<>
<div
{...getRootProps()}
className={cn(
'cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-8',
isDragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30'
)}
>
<input {...getInputProps()} />
<Upload className='mx-auto h-8 w-8 text-muted-foreground' />
<p className='mt-2 text-sm font-medium'>
Drop a CSV here, or tap to choose one
</p>
<p className='mt-1 text-xs text-muted-foreground'>
Up to {formatFileSize(MAX_FILE_SIZE)}
{maxRows !== undefined && maxRows !== Number.POSITIVE_INFINITY
? ` and ${maxRows.toLocaleString()} rows on your plan`
: ''}
</p>
</div>
<a
href={SAMPLE_CSV}
download
className='inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline'
>
<Download className='h-3.5 w-3.5' />
Download a sample CSV
</a>
</>
)}
{fileError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>That file could not be used</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
{rowCapExceeded && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>That file is over your plan limit</AlertTitle>
<AlertDescription>
It has {rows.length.toLocaleString()} rows and your plan allows{' '}
{maxRows!.toLocaleString()} per bulk send. Remove the extra rows
or upgrade your plan.
</AlertDescription>
</Alert>
)}
{fileWarning && !fileError && (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Some rows did not parse cleanly</AlertTitle>
<AlertDescription>{fileWarning}</AlertDescription>
</Alert>
)}
</StepShell>
{/* 2. Map */}
<StepShell
step={2}
title='Choose the phone column and device'
description='We guess the phone column from your headers'
locked={!hasFile}
complete={mapped && Boolean(deviceId)}
>
<div className='grid gap-4 sm:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='recipient-column'>Phone number column</Label>
<Select
value={recipientColumn}
onValueChange={handleRecipientColumnChange}
>
<SelectTrigger id='recipient-column'>
<SelectValue placeholder='Select a column' />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1.5'>
<Label htmlFor='device-select'>Send from</Label>
<Select
value={deviceId ?? ''}
onValueChange={(v) => {
setDeviceId(v)
setSimSubscriptionId(undefined)
}}
>
<SelectTrigger id='device-select'>
<SelectValue placeholder='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>
</div>
{availableSims.length > 1 && (
<div className='space-y-1.5'>
<Label htmlFor='sim-select'>SIM (optional)</Label>
<Select
value={simSubscriptionId?.toString() ?? ''}
onValueChange={(v) =>
setSimSubscriptionId(v ? Number(v) : undefined)
}
>
<SelectTrigger id='sim-select'>
<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>
)}
</div>
{plan.counts && (
<div className='flex flex-wrap gap-2 text-xs'>
<span className='rounded-full bg-green-100 px-2.5 py-1 font-medium text-green-800 dark:bg-green-900/30 dark:text-green-300'>
{plan.counts.valid.toLocaleString()} will receive a message
</span>
{plan.counts.empty > 0 && (
<span className='rounded-full bg-muted px-2.5 py-1 font-medium text-muted-foreground'>
{plan.counts.empty} with no number
</span>
)}
{plan.counts.invalid > 0 && (
<span className='rounded-full bg-amber-100 px-2.5 py-1 font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'>
{plan.counts.invalid} invalid
</span>
)}
{plan.counts.duplicate > 0 && (
<span className='rounded-full bg-amber-100 px-2.5 py-1 font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'>
{plan.counts.duplicate} duplicate
</span>
)}
</div>
)}
{recipientColumn && plan.valid.length === 0 && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>No usable phone numbers</AlertTitle>
<AlertDescription>
No row in "{recipientColumn}" holds a valid phone number. Pick a
different column.
</AlertDescription>
</Alert>
)}
</StepShell>
{/* 3. Compose */}
<StepShell
step={3}
title='Write your message'
description='Insert a column to personalise each message'
locked={!mapped || !deviceId}
complete={composed}
>
{columns.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{columns.map((c) => (
<button
key={c}
type='button'
onClick={() => insertVariable(c)}
className='rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs transition-colors hover:border-primary/50 hover:text-primary'
>
{`{{ ${c} }}`}
</button>
))}
</div>
)}
<div className='space-y-1.5'>
<Label htmlFor='message-template'>Message template</Label>
<Textarea
id='message-template'
ref={templateRef}
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder='Hi {{ name }}, your order {{ order_id }} is confirmed. Total: ${{ total }}. Thanks for shopping with us!'
className='min-h-28 font-mono text-sm'
/>
<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>
</div>
{unknownVariables.length > 0 && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Unknown column reference</AlertTitle>
<AlertDescription>
Your CSV has no column named{' '}
{unknownVariables.map((v) => `"${v}"`).join(', ')}. Those
placeholders will render as empty text.
</AlertDescription>
</Alert>
)}
{previewRow && template && (
<div className='space-y-2 rounded-lg border border-border bg-muted/30 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-xs font-medium text-muted-foreground'>
Preview for {previewRow.raw}
</p>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
aria-label='Previous recipient'
disabled={safePreviewIndex === 0}
onClick={() =>
setPreviewIndex(Math.max(0, safePreviewIndex - 1))
}
>
<ChevronLeft className='h-3.5 w-3.5' />
</Button>
<span className='text-xs tabular-nums text-muted-foreground'>
{safePreviewIndex + 1} / {plan.valid.length}
</span>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
aria-label='Next recipient'
disabled={safePreviewIndex >= plan.valid.length - 1}
onClick={() =>
setPreviewIndex(
Math.min(plan.valid.length - 1, safePreviewIndex + 1)
)
}
>
<ChevronRight className='h-3.5 w-3.5' />
</Button>
</div>
</div>
<p className='whitespace-pre-wrap text-sm'>{previewMessage}</p>
</div>
)}
</StepShell>
{/* 4. Review and send */}
<StepShell
step={4}
title='Review and send'
description='Nothing is sent until you confirm'
locked={!composed}
>
<div className='rounded-lg border border-border bg-muted/30 p-3 text-sm'>
<p>
Sending{' '}
<strong>{plan.valid.length.toLocaleString()} messages</strong> from{' '}
<strong>
{selectedDevice ? formatDeviceName(selectedDevice) : 'your device'}
</strong>
.
</p>
{plan.excluded.length > 0 && (
<details className='mt-2'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
{plan.excluded.length} row
{plan.excluded.length === 1 ? '' : 's'} skipped
</summary>
<ul className='mt-2 space-y-1 text-xs text-muted-foreground'>
{plan.excluded.slice(0, 10).map((row) => (
<li key={`${row.rowNumber}-${row.reason}`}>
Row {row.rowNumber}
{row.raw ? ` ("${row.raw}")` : ''}:{' '}
{REASON_LABEL[row.reason]}
</li>
))}
{plan.excluded.length > 10 && (
<li>and {plan.excluded.length - 10} more</li>
)}
</ul>
</details>
)}
</div>
{sendError &&
(() => {
const formatted = formatError(sendError)
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>
)
})()}
<Button
className='w-full'
disabled={!composed || isSending || plan.valid.length === 0}
onClick={() => sendBulk()}
>
{isSending ? (
<>
<Spinner size='sm' className='mr-2 text-white dark:text-black' />
Sending...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Send {plan.valid.length.toLocaleString()} message
{plan.valid.length === 1 ? '' : 's'}
</>
)}
</Button>
</StepShell>
<UploadStep bulk={bulk} />
<MapStep bulk={bulk} />
<ComposeStep bulk={bulk} />
<ReviewStep bulk={bulk} />
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import StepShell from './step-shell'
import type { BulkSendState } from './use-bulk-send'
export default function ComposeStep({ bulk }: { bulk: BulkSendState }) {
const {
columns,
template,
setTemplate,
deviceId,
setPreviewIndex,
templateRef,
plan,
unknownVariables,
safePreviewIndex,
previewRow,
previewMessage,
segments,
mapped,
composed,
insertVariable,
} = bulk
return (
<>
{/* 3. Compose */}
<StepShell
step={3}
title='Write your message'
description='Insert a column to personalise each message'
locked={!mapped || !deviceId}
complete={composed}
>
{columns.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{columns.map((c) => (
<button
key={c}
type='button'
onClick={() => insertVariable(c)}
className='rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs transition-colors hover:border-primary/50 hover:text-primary'
>
{`{{ ${c} }}`}
</button>
))}
</div>
)}
<div className='space-y-1.5'>
<Label htmlFor='message-template'>Message template</Label>
<Textarea
id='message-template'
ref={templateRef}
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder='Hi {{ name }}, your order {{ order_id }} is confirmed. Total: ${{ total }}. Thanks for shopping with us!'
className='min-h-28 font-mono text-sm'
/>
<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>
</div>
{unknownVariables.length > 0 && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Unknown column reference</AlertTitle>
<AlertDescription>
Your CSV has no column named{' '}
{unknownVariables.map((v) => `"${v}"`).join(', ')}. Those
placeholders will render as empty text.
</AlertDescription>
</Alert>
)}
{previewRow && template && (
<div className='space-y-2 rounded-lg border border-border bg-muted/30 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-xs font-medium text-muted-foreground'>
Preview for {previewRow.raw}
</p>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
aria-label='Previous recipient'
disabled={safePreviewIndex === 0}
onClick={() =>
setPreviewIndex(Math.max(0, safePreviewIndex - 1))
}
>
<ChevronLeft className='h-3.5 w-3.5' />
</Button>
<span className='text-xs tabular-nums text-muted-foreground'>
{safePreviewIndex + 1} / {plan.valid.length}
</span>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
aria-label='Next recipient'
disabled={safePreviewIndex >= plan.valid.length - 1}
onClick={() =>
setPreviewIndex(
Math.min(plan.valid.length - 1, safePreviewIndex + 1)
)
}
>
<ChevronRight className='h-3.5 w-3.5' />
</Button>
</div>
</div>
<p className='whitespace-pre-wrap text-sm'>{previewMessage}</p>
</div>
)}
</StepShell>
</>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { formatDeviceName } from '@/lib/utils'
import StepShell from './step-shell'
import type { BulkSendState } from './use-bulk-send'
export default function MapStep({ bulk }: { bulk: BulkSendState }) {
const {
columns,
recipientColumn,
deviceId,
setDeviceId,
simSubscriptionId,
setSimSubscriptionId,
devices,
handleRecipientColumnChange,
plan,
availableSims,
hasFile,
mapped,
} = bulk
return (
<>
{/* 2. Map */}
<StepShell
step={2}
title='Choose the phone column and device'
description='We guess the phone column from your headers'
locked={!hasFile}
complete={mapped && Boolean(deviceId)}
>
<div className='grid gap-4 sm:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='recipient-column'>Phone number column</Label>
<Select
value={recipientColumn}
onValueChange={handleRecipientColumnChange}
>
<SelectTrigger id='recipient-column'>
<SelectValue placeholder='Select a column' />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1.5'>
<Label htmlFor='device-select'>Send from</Label>
<Select
value={deviceId ?? ''}
onValueChange={(v) => {
setDeviceId(v)
setSimSubscriptionId(undefined)
}}
>
<SelectTrigger id='device-select'>
<SelectValue placeholder='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>
</div>
{availableSims.length > 1 && (
<div className='space-y-1.5'>
<Label htmlFor='sim-select'>SIM (optional)</Label>
<Select
value={simSubscriptionId?.toString() ?? ''}
onValueChange={(v) =>
setSimSubscriptionId(v ? Number(v) : undefined)
}
>
<SelectTrigger id='sim-select'>
<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>
)}
</div>
{plan.counts && (
<div className='flex flex-wrap gap-2 text-xs'>
<span className='rounded-full bg-green-100 px-2.5 py-1 font-medium text-green-800 dark:bg-green-900/30 dark:text-green-300'>
{plan.counts.valid.toLocaleString()} will receive a message
</span>
{plan.counts.empty > 0 && (
<span className='rounded-full bg-muted px-2.5 py-1 font-medium text-muted-foreground'>
{plan.counts.empty} with no number
</span>
)}
{plan.counts.invalid > 0 && (
<span className='rounded-full bg-amber-100 px-2.5 py-1 font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'>
{plan.counts.invalid} invalid
</span>
)}
{plan.counts.duplicate > 0 && (
<span className='rounded-full bg-amber-100 px-2.5 py-1 font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'>
{plan.counts.duplicate} duplicate
</span>
)}
</div>
)}
{recipientColumn && plan.valid.length === 0 && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>No usable phone numbers</AlertTitle>
<AlertDescription>
No row in "{recipientColumn}" holds a valid phone number. Pick a
different column.
</AlertDescription>
</Alert>
)}
</StepShell>
</>
)
}

View File

@@ -0,0 +1,105 @@
'use client'
import { AlertCircle, Send } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { RateLimitError } from '@/components/shared/rate-limit-error'
import { formatError } from '@/lib/utils/errorHandler'
import { formatDeviceName } from '@/lib/utils'
import StepShell from './step-shell'
import { REASON_LABEL } from './constants'
import type { BulkSendState } from './use-bulk-send'
export default function ReviewStep({ bulk }: { bulk: BulkSendState }) {
const {
plan,
selectedDevice,
composed,
sendBulk,
isSending,
sendError,
} = bulk
return (
<>
{/* 4. Review and send */}
<StepShell
step={4}
title='Review and send'
description='Nothing is sent until you confirm'
locked={!composed}
>
<div className='rounded-lg border border-border bg-muted/30 p-3 text-sm'>
<p>
Sending{' '}
<strong>{plan.valid.length.toLocaleString()} messages</strong> from{' '}
<strong>
{selectedDevice ? formatDeviceName(selectedDevice) : 'your device'}
</strong>
.
</p>
{plan.excluded.length > 0 && (
<details className='mt-2'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
{plan.excluded.length} row
{plan.excluded.length === 1 ? '' : 's'} skipped
</summary>
<ul className='mt-2 space-y-1 text-xs text-muted-foreground'>
{plan.excluded.slice(0, 10).map((row) => (
<li key={`${row.rowNumber}-${row.reason}`}>
Row {row.rowNumber}
{row.raw ? ` ("${row.raw}")` : ''}:{' '}
{REASON_LABEL[row.reason]}
</li>
))}
{plan.excluded.length > 10 && (
<li>and {plan.excluded.length - 10} more</li>
)}
</ul>
</details>
)}
</div>
{sendError &&
(() => {
const formatted = formatError(sendError)
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>
)
})()}
<Button
className='w-full'
disabled={!composed || isSending || plan.valid.length === 0}
onClick={() => sendBulk()}
>
{isSending ? (
<>
<Spinner size='sm' className='mr-2 text-white dark:text-black' />
Sending...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Send {plan.valid.length.toLocaleString()} message
{plan.valid.length === 1 ? '' : 's'}
</>
)}
</Button>
</StepShell>
</>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import {
AlertCircle,
Download,
FileSpreadsheet,
Upload,
X,
} from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import StepShell from './step-shell'
import { formatFileSize } from './bulk-csv'
import { MAX_FILE_SIZE, PREVIEW_ROWS, SAMPLE_CSV } from './constants'
import type { BulkSendState } from './use-bulk-send'
export default function UploadStep({ bulk }: { bulk: BulkSendState }) {
const {
getRootProps,
getInputProps,
rows,
columns,
fileName,
fileError,
fileWarning,
maxRows,
resetFile,
rowCapExceeded,
isDragActive,
plan,
hasFile,
} = bulk
return (
<>
{/* 1. Upload */}
<StepShell
step={1}
title='Upload your CSV'
description='One row per recipient, with a header row'
complete={hasFile}
>
{hasFile ? (
<div className='space-y-3'>
<div className='flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2'>
<FileSpreadsheet className='h-4 w-4 shrink-0 text-primary' />
<div className='min-w-0 flex-1'>
<p className='truncate text-sm font-medium'>{fileName}</p>
<p className='text-xs text-muted-foreground'>
{rows.length.toLocaleString()} rows, {columns.length} columns
</p>
</div>
<Button
variant='ghost'
size='icon'
onClick={resetFile}
aria-label='Remove file'
>
<X className='h-4 w-4' />
</Button>
</div>
{/* Show what was actually parsed, so a mis-delimited file is
obvious before anything is sent. */}
<div className='overflow-x-auto rounded-lg border border-border'>
<table className='w-full text-left text-xs'>
<thead className='bg-muted/50'>
<tr>
{columns.map((c) => (
<th key={c} className='whitespace-nowrap px-3 py-2 font-medium'>
{c}
</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-border'>
{rows.slice(0, PREVIEW_ROWS).map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td
key={c}
className='whitespace-nowrap px-3 py-1.5 text-muted-foreground'
>
{row[c] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length > PREVIEW_ROWS && (
<p className='text-xs text-muted-foreground'>
Showing the first {PREVIEW_ROWS} of{' '}
{rows.length.toLocaleString()} rows.
</p>
)}
</div>
) : (
<>
<div
{...getRootProps()}
className={cn(
'cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-8',
isDragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30'
)}
>
<input {...getInputProps()} />
<Upload className='mx-auto h-8 w-8 text-muted-foreground' />
<p className='mt-2 text-sm font-medium'>
Drop a CSV here, or tap to choose one
</p>
<p className='mt-1 text-xs text-muted-foreground'>
Up to {formatFileSize(MAX_FILE_SIZE)}
{maxRows !== undefined && maxRows !== Number.POSITIVE_INFINITY
? ` and ${maxRows.toLocaleString()} rows on your plan`
: ''}
</p>
</div>
<a
href={SAMPLE_CSV}
download
className='inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline'
>
<Download className='h-3.5 w-3.5' />
Download a sample CSV
</a>
</>
)}
{fileError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>That file could not be used</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
{rowCapExceeded && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>That file is over your plan limit</AlertTitle>
<AlertDescription>
It has {rows.length.toLocaleString()} rows and your plan allows{' '}
{maxRows!.toLocaleString()} per bulk send. Remove the extra rows
or upgrade your plan.
</AlertDescription>
</Alert>
)}
{fileWarning && !fileError && (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Some rows did not parse cleanly</AlertTitle>
<AlertDescription>{fileWarning}</AlertDescription>
</Alert>
)}
</StepShell>
</>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { BulkSendState } from './use-bulk-send'
export default function SuccessPanel({ bulk }: { bulk: BulkSendState }) {
const { plan, resetSend, resetFile, setTemplate } = bulk
return (
<div className='rounded-xl border border-border bg-card p-8 text-center'>
<div className='mx-auto mb-3 w-fit rounded-full bg-green-100 p-3 dark:bg-green-900/30'>
<CheckCircle2 className='h-6 w-6 text-green-600 dark:text-green-400' />
</div>
<h3 className='text-lg font-semibold'>
{plan.valid.length.toLocaleString()} message
{plan.valid.length === 1 ? '' : 's'} queued
</h3>
<p className='mt-1 text-sm text-muted-foreground'>
Delivery status appears in your message history as each one is sent.
</p>
<div className='mt-5 flex flex-wrap items-center justify-center gap-2'>
<Button asChild size='sm'>
<a href='/dashboard/messaging/history'>View message history</a>
</Button>
<Button
variant='outline'
size='sm'
onClick={() => {
resetSend()
resetFile()
setTemplate('')
}}
>
Send another batch
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,282 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useDropzone, type FileRejection } from 'react-dropzone'
import Papa from 'papaparse'
import { useMutation } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useDevices, useSubscription } from '@/lib/api'
import { getSegmentInfo } from '@/lib/sms'
import {
buildRecipientPlan,
detectRecipientColumn,
extractTemplateVariables,
formatFileSize,
findUnknownVariables,
renderTemplate,
type CsvRow,
} from './bulk-csv'
import { FALLBACK_MAX_ROWS, MAX_FILE_SIZE } from './constants'
// All bulk-send state in one place, mirroring get-started/use-onboarding.
// The steps are presentation only and read this through a single prop.
export function useBulkSend() {
const [rows, setRows] = useState<CsvRow[]>([])
const [columns, setColumns] = useState<string[]>([])
const [fileName, setFileName] = useState<string | null>(null)
const [recipientColumn, setRecipientColumn] = useState('')
const [template, setTemplate] = useState('')
const [deviceId, setDeviceId] = useState<string | null>(null)
const [simSubscriptionId, setSimSubscriptionId] = useState<number>()
const [previewIndex, setPreviewIndex] = useState(0)
const [fileError, setFileError] = useState<string | null>(null)
const [fileWarning, setFileWarning] = useState<string | null>(null)
const templateRef = useRef<HTMLTextAreaElement>(null)
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<CsvRow>(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)
})
}
return {
rows,
columns,
fileName,
recipientColumn,
template,
setTemplate,
deviceId,
setDeviceId,
simSubscriptionId,
setSimSubscriptionId,
previewIndex,
setPreviewIndex,
fileError,
fileWarning,
templateRef,
devices,
maxRows,
resetFile,
rowCapExceeded,
rowCapUnknown,
handleRecipientColumnChange,
getRootProps,
getInputProps,
isDragActive,
plan,
unknownVariables,
selectedDevice,
availableSims,
safePreviewIndex,
previewRow,
previewMessage,
segments,
hasFile,
mapped,
composed,
sendBulk,
isSending,
isSuccess,
sendError,
resetSend,
insertVariable,
}
}
export type BulkSendState = ReturnType<typeof useBulkSend>