committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 609 additions and 16 deletions
-
13api/src/gateway/gateway.controller.ts
-
16api/src/gateway/gateway.dto.ts
-
147api/src/gateway/gateway.service.ts
-
42web/app/(app)/(auth)/verify-email.tsx/page.tsx
-
337web/app/(app)/dashboard/(components)/bulk-sms-send.tsx
-
24web/app/(app)/dashboard/(components)/messaging.tsx
-
1web/config/api.ts
-
2web/package.json
-
43web/pnpm-lock.yaml
@ -0,0 +1,42 @@ |
|||
'use client' |
|||
|
|||
import { useSearchParams } from 'next/navigation' |
|||
import ResetPasswordForm from '../(components)/reset-password-form' |
|||
|
|||
import RequestPasswordResetForm from '../(components)/request-password-reset-form' |
|||
import { useQuery } from '@tanstack/react-query' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { useMutation } from '@tanstack/react-query' |
|||
import { useSession } from 'next-auth/react' |
|||
|
|||
export default function ResetPasswordPage() { |
|||
const searchParams = useSearchParams() |
|||
const code = searchParams.get('code') |
|||
const email = searchParams.get('email') |
|||
|
|||
const session = useSession() |
|||
const { |
|||
mutate: verifyEmail, |
|||
isPending: isVerifyingEmail, |
|||
isSuccess: isVerifyingEmailSuccess, |
|||
isError: isVerifyingEmailError, |
|||
error: verifyingEmailError, |
|||
} = useMutation({ |
|||
mutationFn: () => |
|||
httpBrowserClient.post(ApiEndpoints.auth.verifyEmail(), { |
|||
email: decodeURIComponent(email), |
|||
code, |
|||
}), |
|||
}) |
|||
|
|||
if (!email) { |
|||
return <div>Email is required</div> |
|||
} |
|||
|
|||
if (code && email) { |
|||
verifyEmail() |
|||
} |
|||
|
|||
return <RequestPasswordResetForm /> |
|||
} |
|||
@ -0,0 +1,337 @@ |
|||
'use client' |
|||
|
|||
import { useState, useCallback, useMemo } from 'react' |
|||
import { useDropzone } from 'react-dropzone' |
|||
import Papa from 'papaparse' |
|||
import { Upload, Send, AlertCircle, CheckCircle } from 'lucide-react' |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardHeader, |
|||
CardTitle, |
|||
CardDescription, |
|||
} from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
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 { ApiEndpoints } from '@/config/api' |
|||
import { useMutation, useQuery } from '@tanstack/react-query' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
|
|||
const MAX_FILE_SIZE = 1024 * 1024 // 1 MB
|
|||
const MAX_ROWS = 50 |
|||
|
|||
export default function BulkSMSSend() { |
|||
const [csvData, setCsvData] = useState<any[]>([]) |
|||
const [columns, setColumns] = useState<string[]>([]) |
|||
const [selectedColumn, setSelectedColumn] = useState<string>('') |
|||
const [messageTemplate, setMessageTemplate] = useState<string>('') |
|||
const [selectedRecipient, setSelectedRecipient] = useState<string>('') |
|||
const [error, setError] = useState<string | null>(null) |
|||
|
|||
const onDrop = useCallback((acceptedFiles: File[]) => { |
|||
const file = acceptedFiles[0] |
|||
if (file.size > MAX_FILE_SIZE) { |
|||
setError('File size exceeds 1 MB limit.') |
|||
return |
|||
} |
|||
|
|||
Papa.parse(file, { |
|||
complete: (results) => { |
|||
if (results.data && results.data.length > 0) { |
|||
if (results.data.length > MAX_ROWS) { |
|||
setError(`CSV file exceeds ${MAX_ROWS} rows limit.`) |
|||
return |
|||
} |
|||
setCsvData(results.data as any[]) |
|||
const headerRow = results.data[0] as Record<string, unknown> |
|||
setColumns(Object.keys(headerRow)) |
|||
setError(null) |
|||
} else { |
|||
setError('CSV file is empty or invalid') |
|||
setCsvData([]) |
|||
setColumns([]) |
|||
} |
|||
}, |
|||
header: true, |
|||
skipEmptyLines: true, |
|||
}) |
|||
}, []) |
|||
|
|||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) |
|||
|
|||
const previewMessage = useMemo(() => { |
|||
if (!selectedRecipient || !messageTemplate) return '' |
|||
const recipient = csvData.find( |
|||
(row) => row[selectedColumn] === selectedRecipient |
|||
) |
|||
if (!recipient) return '' |
|||
|
|||
return messageTemplate.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, key) => { |
|||
return recipient[key.trim()] || '' |
|||
}) |
|||
}, [selectedRecipient, messageTemplate, csvData, selectedColumn]) |
|||
|
|||
const handleSendBulkSMS = async () => { |
|||
const messages = csvData.map((row) => ({ |
|||
message: messageTemplate.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, key) => { |
|||
return row[key.trim()] || '' |
|||
}), |
|||
recipients: [row[selectedColumn]], |
|||
})) |
|||
const payload = { |
|||
messageTemplate, |
|||
messages, |
|||
} |
|||
await httpBrowserClient.post( |
|||
ApiEndpoints.gateway.sendBulkSMS(selectedDeviceId), |
|||
payload |
|||
) |
|||
} |
|||
|
|||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null) |
|||
|
|||
const { data: devices } = useQuery({ |
|||
queryKey: ['devices'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.listDevices()) |
|||
.then((res) => res.data), |
|||
}) |
|||
|
|||
const { |
|||
mutate: sendBulkSMS, |
|||
isPending: isSendingBulkSMS, |
|||
isSuccess: isSendingBulkSMSuccess, |
|||
isError: isSendingBulkSMSError, |
|||
error: sendingBulkSMSError, |
|||
} = useMutation({ |
|||
mutationFn: handleSendBulkSMS, |
|||
}) |
|||
|
|||
const isStep2Disabled = csvData.length === 0 |
|||
const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate |
|||
|
|||
return ( |
|||
<div className='container mx-auto p-4 space-y-8'> |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Send Bulk SMS</CardTitle> |
|||
<CardDescription> |
|||
Upload a CSV, configure your message, and send bulk SMS in 3 simple |
|||
steps. |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent className='space-y-8'> |
|||
<section> |
|||
<h2 className='text-lg font-semibold mb-2'>1. Upload CSV</h2> |
|||
<p className='text-sm text-gray-500 mb-4'> |
|||
Upload a CSV file (max 1MB, {MAX_ROWS} rows) containing recipient |
|||
information. |
|||
</p> |
|||
<div |
|||
{...getRootProps()} |
|||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${ |
|||
isDragActive |
|||
? 'border-primary bg-primary/10' |
|||
: 'border-gray-300' |
|||
}`}
|
|||
> |
|||
<input {...getInputProps()} accept='.csv' /> |
|||
<Upload className='mx-auto h-12 w-12 text-gray-400' /> |
|||
<p className='mt-2'> |
|||
Drag & drop a CSV file here, or click to select one |
|||
</p> |
|||
<p className='text-sm text-gray-500 mt-1'> |
|||
Max file size: 1MB, Max rows: 50 |
|||
</p> |
|||
</div> |
|||
{error && ( |
|||
<Alert variant='destructive' className='mt-4'> |
|||
<AlertCircle className='h-4 w-4' /> |
|||
<AlertTitle>Error</AlertTitle> |
|||
<AlertDescription>{error}</AlertDescription> |
|||
</Alert> |
|||
)} |
|||
{csvData.length > 0 && ( |
|||
<p className='mt-2 text-sm text-green-600'> |
|||
CSV uploaded successfully! {csvData.length} rows found. |
|||
</p> |
|||
)} |
|||
</section> |
|||
|
|||
<section |
|||
className={isStep2Disabled ? 'opacity-50 pointer-events-none' : ''} |
|||
> |
|||
<h2 className='text-lg font-semibold mb-2'>2. Configure SMS</h2> |
|||
<p className='text-sm text-gray-500 mb-4'> |
|||
Select the recipient column and create your message template. |
|||
</p> |
|||
|
|||
{/* select device to send SMS from */} |
|||
<div> |
|||
<Label htmlFor='device-select'>Select Device</Label> |
|||
<Select |
|||
onValueChange={setSelectedDeviceId} |
|||
value={selectedDeviceId} |
|||
> |
|||
<SelectTrigger id='device-select'> |
|||
<SelectValue placeholder='Select a device' /> |
|||
</SelectTrigger> |
|||
<SelectContent> |
|||
{devices?.data?.map((device) => ( |
|||
<SelectItem |
|||
key={device._id} |
|||
value={device._id} |
|||
disabled={!device.enabled} |
|||
> |
|||
{device.brand} - {device.model}{' '} |
|||
{device.enabled ? '' : ' (disabled)'} |
|||
</SelectItem> |
|||
))} |
|||
</SelectContent> |
|||
</Select> |
|||
</div> |
|||
|
|||
<div className='space-y-4'> |
|||
<div> |
|||
<Label htmlFor='recipient-column'> |
|||
Select Recipient Column |
|||
</Label> |
|||
<Select |
|||
onValueChange={setSelectedColumn} |
|||
value={selectedColumn} |
|||
disabled={isStep2Disabled} |
|||
> |
|||
<SelectTrigger id='recipient-column'> |
|||
<SelectValue placeholder='Select a column' /> |
|||
</SelectTrigger> |
|||
<SelectContent> |
|||
{columns.map((column) => ( |
|||
<SelectItem |
|||
key={column} |
|||
value={column || 'undefined-column'} |
|||
> |
|||
{column || 'Unnamed Column'} |
|||
</SelectItem> |
|||
))} |
|||
</SelectContent> |
|||
</Select> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor='message-template'>Message Template</Label> |
|||
<Textarea |
|||
id='message-template' |
|||
placeholder='Enter your message template here. Use {{ column_name }} for dynamic content.' |
|||
value={messageTemplate} |
|||
onChange={(e) => setMessageTemplate(e.target.value)} |
|||
className='h-32' |
|||
disabled={isStep2Disabled} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<section |
|||
className={isStep3Disabled ? 'opacity-50 pointer-events-none' : ''} |
|||
> |
|||
<h2 className='text-lg font-semibold mb-2'>3. Message Preview</h2> |
|||
<p className='text-sm text-gray-500 mb-4'> |
|||
Preview your message for a selected recipient before sending. |
|||
</p> |
|||
<div className='space-y-4'> |
|||
<div> |
|||
<Label htmlFor='preview-recipient'> |
|||
Select Recipient for Preview |
|||
</Label> |
|||
<Select |
|||
onValueChange={setSelectedRecipient} |
|||
value={selectedRecipient} |
|||
disabled={isStep3Disabled} |
|||
> |
|||
<SelectTrigger id='preview-recipient'> |
|||
<SelectValue placeholder='Select a recipient' /> |
|||
</SelectTrigger> |
|||
<SelectContent> |
|||
{csvData |
|||
.map((row, index) => { |
|||
const value = row[selectedColumn] |
|||
if (value) { |
|||
return ( |
|||
<SelectItem key={`${value}-${index}`} value={value}> |
|||
{value} |
|||
</SelectItem> |
|||
) |
|||
} |
|||
return null |
|||
}) |
|||
.filter(Boolean)} |
|||
</SelectContent> |
|||
</Select> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor='recipient-number'>Recipient Number</Label> |
|||
<Input |
|||
id='recipient-number' |
|||
value={selectedRecipient} |
|||
disabled |
|||
// className='bg-gray-100'
|
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor='message-preview'>Message Preview</Label> |
|||
<Textarea |
|||
id='message-preview' |
|||
value={previewMessage} |
|||
disabled |
|||
className='p-4 rounded-md min-h-[100px] whitespace-pre-wrap' |
|||
/> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
{sendingBulkSMSError && ( |
|||
<Alert variant='destructive' className='mt-4'> |
|||
<AlertCircle className='h-4 w-4' /> |
|||
<AlertTitle>Error</AlertTitle> |
|||
<AlertDescription> |
|||
{sendingBulkSMSError?.message} |
|||
</AlertDescription> |
|||
</Alert> |
|||
)} |
|||
|
|||
{isSendingBulkSMSuccess && ( |
|||
<Alert variant='default' className='mt-4'> |
|||
<CheckCircle className='h-4 w-4' /> |
|||
<AlertTitle>Success</AlertTitle> |
|||
<AlertDescription>Bulk SMS sent successfully!</AlertDescription> |
|||
</Alert> |
|||
)} |
|||
|
|||
<Button |
|||
className='w-full' |
|||
disabled={isStep3Disabled || isSendingBulkSMS} |
|||
onClick={() => sendBulkSMS()} |
|||
> |
|||
{isSendingBulkSMS ? ( |
|||
<Spinner size='sm' className='text-white dark:text-black' /> |
|||
) : ( |
|||
<Send className='mr-2 h-4 w-4' /> |
|||
)} |
|||
{isSendingBulkSMS ? 'Sending...' : 'Send Bulk SMS'} |
|||
</Button> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue