Browse Source

Merge pull request #55 from vernu/dev

show sent messages in dashboard
pull/56/head
Israel Abebe 1 year ago
committed by GitHub
parent
commit
2ac20c8915
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      api/src/gateway/gateway.controller.ts
  2. 60
      api/src/gateway/gateway.service.ts
  3. 752
      web/app/(app)/dashboard/(components)/message-history.tsx
  4. 11
      web/app/(app)/dashboard/(components)/messaging.tsx
  5. 498
      web/app/(app)/dashboard/(components)/received-sms.tsx
  6. 51
      web/app/(app)/dashboard/(components)/upgrade-to-pro-alert.tsx
  7. 1
      web/config/api.ts

20
api/src/gateway/gateway.controller.ts

@ -129,4 +129,24 @@ export class GatewayController {
const result = await this.gatewayService.getReceivedSMS(deviceId, page, limit)
return result;
}
@ApiOperation({ summary: 'Get message history (sent and received) from a device' })
@ApiResponse({ status: 200, type: RetrieveSMSResponseDTO })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items per page (default: 50, max: 100)' })
@ApiQuery({ name: 'type', required: false, type: String, description: 'Filter by message type: all, sent, or received (default: all)' })
@UseGuards(AuthGuard, CanModifyDevice)
@Get('/devices/:id/messages')
async getMessages(
@Param('id') deviceId: string,
@Request() req,
): Promise<RetrieveSMSResponseDTO> {
// Extract page and limit from query params, with defaults and max values
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
const limit = req.query.limit ? Math.min(parseInt(req.query.limit, 10), 100) : 50;
const type = req.query.type || '';
const result = await this.gatewayService.getMessages(deviceId, type, page, limit);
return result;
}
}

60
api/src/gateway/gateway.service.ts

@ -493,6 +493,66 @@ export class GatewayService {
};
}
async getMessages(deviceId: string, type = '', page = 1, limit = 50): Promise<{ data: any[], meta: any }> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device does not exist',
},
HttpStatus.BAD_REQUEST,
)
}
// Calculate skip value for pagination
const skip = (page - 1) * limit;
// Build query based on type filter
const query: any = { device: device._id };
if (type === 'sent') {
query.type = SMSType.SENT;
} else if (type === 'received') {
query.type = SMSType.RECEIVED;
}
// Get total count for pagination metadata
const total = await this.smsModel.countDocuments(query);
// @ts-ignore
const data = await this.smsModel
.find(
query,
null,
{
// Sort by the most recent timestamp (receivedAt for received, sentAt for sent)
sort: { createdAt: -1 },
limit: limit,
skip: skip
},
)
.populate({
path: 'device',
select: '_id brand model buildId enabled',
})
.lean() // Use lean() to return plain JavaScript objects instead of Mongoose documents
// Calculate pagination metadata
const totalPages = Math.ceil(total / limit);
return {
meta: {
page,
limit,
total,
totalPages,
},
data,
};
}
async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user)

752
web/app/(app)/dashboard/(components)/message-history.tsx

@ -0,0 +1,752 @@
'use client'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Clock, Reply, ArrowUpRight, ArrowDownLeft, MessageSquare, Check, X, Smartphone } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { sendSmsSchema } from '@/lib/schemas'
import type { SendSmsFormData } from '@/lib/schemas'
import { useMutation } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from "@/components/ui/badge"
// You might need to add this CSS to your global styles or component
const styles = {
deviceSelectorContainer: `
mb-6 mt-4 p-4
bg-gradient-to-r from-indigo-50/50 to-purple-50/50
rounded-xl shadow-sm
border border-indigo-100
`
}
function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
const [open, setOpen] = useState(false)
const {
mutate: sendSms,
isPending: isSendingSms,
error: sendSmsError,
isSuccess: isSendSmsSuccess,
} = useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsFormData) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
onSuccess: () => {
setTimeout(() => {
setOpen(false)
if (onClose) onClose()
}, 1500)
},
})
const {
register,
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<SendSmsFormData>({
resolver: zodResolver(sendSmsSchema),
defaultValues: {
deviceId: sms?.device?._id,
recipients: [sms.sender],
message: '',
},
})
const { data: devices, isLoading: isLoadingDevices } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
useEffect(() => {
if (open) {
reset({
deviceId: sms?.device?._id,
recipients: [sms.sender],
message: '',
})
}
}, [open, sms, reset])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='ghost' size='sm' className='gap-1'>
<Reply className='h-3.5 w-3.5' />
Reply
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<MessageSquare className='h-5 w-5' />
Reply to {sms.sender}
</DialogTitle>
<DialogDescription>
Send a reply message to this sender
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
className='space-y-4 mt-4'
>
<div className='space-y-4'>
<div>
<Controller
name='deviceId'
control={control}
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={sms?.device?._id}
>
<SelectTrigger>
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.deviceId && (
<p className='text-sm text-destructive mt-1'>
{errors.deviceId.message}
</p>
)}
</div>
<div>
<Input
type='tel'
placeholder='Phone Number'
{...register('recipients.0')}
/>
{errors.recipients?.[0] && (
<p className='text-sm text-destructive mt-1'>
{errors.recipients[0].message}
</p>
)}
</div>
<div>
<Textarea
placeholder='Message'
{...register('message')}
rows={4}
/>
{errors.message && (
<p className='text-sm text-destructive mt-1'>
{errors.message.message}
</p>
)}
</div>
</div>
{sendSmsError && (
<div className='flex items-center gap-2 text-destructive'>
<p>Error sending SMS: {sendSmsError.message}</p>
<X className='h-5 w-5' />
</div>
)}
{isSendSmsSuccess && (
<div className='flex items-center gap-2 text-green-600'>
<p>SMS sent successfully!</p>
<Check className='h-5 w-5' />
</div>
)}
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type='submit' disabled={isSendingSms}>
{isSendingSms && (
<Spinner size='sm' className='mr-2' color='white' />
)}
{isSendingSms ? 'Sending...' : 'Send Reply'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
function FollowUpDialog({ message, onClose }: { message: any; onClose?: () => void }) {
const [open, setOpen] = useState(false)
const {
mutate: sendSms,
isPending: isSendingSms,
error: sendSmsError,
isSuccess: isSendSmsSuccess,
} = useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsFormData) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
onSuccess: () => {
setTimeout(() => {
setOpen(false)
if (onClose) onClose()
}, 1500)
},
})
const {
register,
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<SendSmsFormData>({
resolver: zodResolver(sendSmsSchema),
defaultValues: {
deviceId: message?.device?._id,
recipients: [message.recipient || (message.recipients && message.recipients[0]) || ''],
message: '',
},
})
const { data: devices, isLoading: isLoadingDevices } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
useEffect(() => {
if (open) {
reset({
deviceId: message?.device?._id,
recipients: [message.recipient || (message.recipients && message.recipients[0]) || ''],
message: '',
})
}
}, [open, message, reset])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='ghost' size='sm' className='gap-1'>
<MessageSquare className='h-3.5 w-3.5' />
Follow Up
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<MessageSquare className='h-5 w-5' />
Follow Up with {message.recipient || (message.recipients && message.recipients[0]) || 'Recipient'}
</DialogTitle>
<DialogDescription>
Send a follow-up message to this recipient
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
className='space-y-4 mt-4'
>
<div className='space-y-4'>
<div>
<Controller
name='deviceId'
control={control}
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={message?.device?._id}
>
<SelectTrigger>
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.deviceId && (
<p className='text-sm text-destructive mt-1'>
{errors.deviceId.message}
</p>
)}
</div>
<div>
<Input
type='tel'
placeholder='Phone Number'
{...register('recipients.0')}
/>
{errors.recipients?.[0] && (
<p className='text-sm text-destructive mt-1'>
{errors.recipients[0].message}
</p>
)}
</div>
<div>
<Textarea
placeholder='Message'
{...register('message')}
rows={4}
/>
{errors.message && (
<p className='text-sm text-destructive mt-1'>
{errors.message.message}
</p>
)}
</div>
</div>
{sendSmsError && (
<div className='flex items-center gap-2 text-destructive'>
<p>Error sending SMS: {sendSmsError.message}</p>
<X className='h-5 w-5' />
</div>
)}
{isSendSmsSuccess && (
<div className='flex items-center gap-2 text-green-600'>
<p>SMS sent successfully!</p>
<Check className='h-5 w-5' />
</div>
)}
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type='submit' disabled={isSendingSms}>
{isSendingSms && (
<Spinner size='sm' className='mr-2' color='white' />
)}
{isSendingSms ? 'Sending...' : 'Send Follow Up'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
function MessageCard({ message, type }) {
const isSent = type === 'sent'
const formattedDate = new Date((isSent ? message.requestedAt : message.receivedAt) || message.createdAt).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
})
return (
<Card className={`hover:bg-muted/50 transition-colors max-w-sm md:max-w-none ${isSent ? 'border-l-4 border-l-blue-500' : 'border-l-4 border-l-green-500'}`}>
<CardContent className='p-4'>
<div className='space-y-3'>
<div className='flex justify-between items-start'>
<div className='flex items-center gap-2'>
{isSent ? (
<div className='flex items-center text-blue-600 dark:text-blue-400 font-medium'>
<ArrowUpRight className='h-4 w-4 mr-1' />
<span>To: {message.recipient || (message.recipients && message.recipients[0]) || 'Unknown'}</span>
</div>
) : (
<div className='flex items-center text-green-600 dark:text-green-400 font-medium'>
<ArrowDownLeft className='h-4 w-4 mr-1' />
<span>From: {message.sender || 'Unknown'}</span>
</div>
)}
</div>
<div className='flex items-center gap-1 text-sm text-muted-foreground'>
<Clock className='h-3 w-3' />
<span>{formattedDate}</span>
</div>
</div>
<div className='flex gap-2'>
<p className='text-sm max-w-sm md:max-w-none'>{message.message}</p>
</div>
{!isSent && (
<div className='flex justify-end'>
<ReplyDialog sms={message} />
</div>
)}
{isSent && (
<div className='flex justify-end'>
<FollowUpDialog message={message} />
</div>
)}
</div>
</CardContent>
</Card>
)
}
function MessageCardSkeleton() {
return (
<Card className='hover:bg-muted/50 transition-colors max-w-sm md:max-w-none'>
<CardContent className='p-4'>
<div className='space-y-3'>
<div className='flex justify-between items-start'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-4 w-32' />
</div>
<Skeleton className='h-4 w-full' />
</div>
</CardContent>
</Card>
)
}
export default function MessageHistory() {
const {
data: devices,
isLoading: isLoadingDevices,
error: devicesError,
} = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
const [currentDevice, setCurrentDevice] = useState('')
const [messageType, setMessageType] = useState('all')
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
useEffect(() => {
if (devices?.data?.length) {
setCurrentDevice(devices?.data?.[0]?._id)
}
}, [devices])
// Query for messages with type filter
const {
data: messagesResponse,
isLoading: isLoadingMessages,
error: messagesError,
} = useQuery({
queryKey: ['messages-history', currentDevice, messageType, page, limit],
enabled: !!currentDevice,
queryFn: () =>
httpBrowserClient
.get(
`${ApiEndpoints.gateway.getMessages(currentDevice)}?type=${messageType}&page=${page}&limit=${limit}`
)
.then((res) => res.data),
})
const messages = messagesResponse?.data || []
const pagination = messagesResponse?.meta || {
page: 1,
limit: 20,
total: 0,
totalPages: 1,
}
const handleDeviceChange = (deviceId) => {
setCurrentDevice(deviceId)
setPage(1)
}
const handleMessageTypeChange = (type) => {
setMessageType(type)
setPage(1)
}
const handlePageChange = (newPage) => {
setPage(newPage)
}
if (isLoadingDevices)
return (
<div className='space-y-4'>
<Skeleton className='h-10 w-full' />
<div className='space-y-4'>
{[1, 2, 3].map((i) => (
<MessageCardSkeleton key={i} />
))}
</div>
</div>
)
if (devicesError)
return (
<div className='flex justify-center items-center h-full'>
Error: {devicesError.message}
</div>
)
if (!devices?.data?.length)
return (
<div className='flex justify-center items-center h-full'>
No devices found
</div>
)
return (
<div className='space-y-4'>
<div className="bg-gradient-to-r from-blue-50 to-sky-50 dark:from-blue-950/30 dark:to-sky-950/30 rounded-lg shadow-sm border border-blue-100 dark:border-blue-800/50 p-4 mb-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<Smartphone className="h-3.5 w-3.5 text-blue-500" />
<h3 className="text-sm font-medium text-foreground">Device</h3>
</div>
<Select value={currentDevice} onValueChange={handleDeviceChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-blue-200 dark:border-blue-800/70">
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
<div className="flex items-center gap-2">
<span className="font-medium">{device.brand} {device.model}</span>
{!device.enabled && <Badge variant="outline" className="ml-1 text-xs py-0 h-5">Disabled</Badge>}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-44">
<div className="flex items-center gap-2 mb-1.5">
<MessageSquare className="h-3.5 w-3.5 text-blue-500" />
<h3 className="text-sm font-medium text-foreground">Message Type</h3>
</div>
<Select value={messageType} onValueChange={handleMessageTypeChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-blue-200 dark:border-blue-800/70">
<SelectValue placeholder='Message type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500"></div>
All Messages
</div>
</SelectItem>
<SelectItem value='received'>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Received
</div>
</SelectItem>
<SelectItem value='sent'>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-blue-500"></div>
Sent
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{isLoadingMessages && (
<div className='space-y-4'>
{[1, 2, 3].map((i) => (
<MessageCardSkeleton key={i} />
))}
</div>
)}
{messagesError && (
<div className='flex justify-center items-center h-full'>
Error: {messagesError.message}
</div>
)}
{(!isLoadingDevices && !messages) && (
<div className='flex justify-center items-center h-full py-10'>
No messages found
</div>
)}
<div className='space-y-4'>
{messages?.map((message) => (
<MessageCard
key={message._id}
message={message}
type={message.sender ? 'received' : 'sent'}
/>
))}
</div>
{pagination.totalPages > 1 && (
<div className='flex justify-center mt-6 space-x-2'>
<Button
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page === 1}
variant={page === 1 ? 'ghost' : 'default'}
>
Previous
</Button>
<div className='flex flex-wrap items-center gap-2 justify-center sm:justify-start'>
{/* First page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(1)}
variant={page === 1 ? 'default' : 'ghost'}
size='icon'
className={`h-8 w-8 rounded-full ${
page === 1
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
1
</Button>
)}
{/* Ellipsis if needed */}
{page > 4 && pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Middle pages */}
{Array.from(
{ length: Math.min(6, pagination.totalPages - 2) },
(_, i) => {
let pageToShow
if (pagination.totalPages <= 8) {
// If we have 8 or fewer pages, show pages 2 through 7 (or fewer)
pageToShow = i + 2
} else if (page <= 4) {
// Near the start
pageToShow = i + 2
} else if (page >= pagination.totalPages - 3) {
// Near the end
pageToShow = pagination.totalPages - 7 + i
} else {
// Middle - center around current page
pageToShow = page - 2 + i
}
// Ensure page is within bounds and not the first or last page
if (
pageToShow > 1 &&
pageToShow < pagination.totalPages
) {
return (
<Button
key={pageToShow}
onClick={() => handlePageChange(pageToShow)}
variant={page === pageToShow ? 'default' : 'ghost'}
size='icon'
className={`h-8 w-8 rounded-full ${
page === pageToShow
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{pageToShow}
</Button>
)
}
return null
}
)}
{/* Ellipsis if needed */}
{page < pagination.totalPages - 3 &&
pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Last page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(pagination.totalPages)}
variant={
page === pagination.totalPages ? 'default' : 'ghost'
}
size='icon'
className={`h-8 w-8 rounded-full ${
page === pagination.totalPages
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{pagination.totalPages}
</Button>
)}
</div>
<Button
onClick={() =>
handlePageChange(Math.min(pagination.totalPages, page + 1))
}
disabled={page === pagination.totalPages}
variant={page === pagination.totalPages ? 'ghost' : 'default'}
>
Next
</Button>
</div>
)}
</div>
)
}

11
web/app/(app)/dashboard/(components)/messaging.tsx

@ -4,7 +4,7 @@ import { useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import SendSms from './send-sms'
import ReceivedSms from './received-sms'
import MessageHistory from './message-history'
import BulkSMSSend from './bulk-sms-send'
import { Badge } from '@/components/ui/badge'
@ -35,8 +35,8 @@ export default function Messaging() {
new
</Badge>
</TabsTrigger>
<TabsTrigger value='received' className='flex-1'>
Received
<TabsTrigger value='history' className='flex-1'>
History
</TabsTrigger>
</TabsList>
@ -45,14 +45,13 @@ export default function Messaging() {
</TabsContent>
<TabsContent value='bulk-send' className='space-y-4'>
{/* comming soon section */}
<div className='grid gap-6 max-w-xl mx-auto mt-10'>
<BulkSMSSend />
</div>
</TabsContent>
<TabsContent value='received' className='space-y-4'>
<ReceivedSms />
<TabsContent value='history' className='space-y-4'>
<MessageHistory />
</TabsContent>
</Tabs>
</div>

498
web/app/(app)/dashboard/(components)/received-sms.tsx

@ -1,498 +0,0 @@
import { Tabs, TabsList, TabsContent, TabsTrigger } from '@/components/ui/tabs'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import React, { useEffect, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Clock, MessageSquare, Reply } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { sendSmsSchema } from '@/lib/schemas'
import type { SendSmsFormData } from '@/lib/schemas'
import { useMutation } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import { Check, X } from 'lucide-react'
function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
const [open, setOpen] = useState(false)
const {
mutate: sendSms,
isPending: isSendingSms,
error: sendSmsError,
isSuccess: isSendSmsSuccess,
} = useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsFormData) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
onSuccess: () => {
setTimeout(() => {
setOpen(false)
if (onClose) onClose()
}, 1500)
},
})
const {
register,
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<SendSmsFormData>({
resolver: zodResolver(sendSmsSchema),
defaultValues: {
deviceId: sms?.device?._id,
recipients: [sms.sender],
message: '',
},
})
const { data: devices, isLoading: isLoadingDevices } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
useEffect(() => {
if (open) {
reset({
deviceId: sms?.device?._id,
recipients: [sms.sender],
message: '',
})
}
}, [open, sms, reset])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='ghost' size='sm' className='gap-1'>
<Reply className='h-3.5 w-3.5' />
Reply
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<MessageSquare className='h-5 w-5' />
Reply to {sms.sender}
</DialogTitle>
<DialogDescription>
Send a reply message to this sender
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
className='space-y-4 mt-4'
>
<div className='space-y-4'>
<div>
<Controller
name='deviceId'
control={control}
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={sms?.device?._id}
>
<SelectTrigger>
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.deviceId && (
<p className='text-sm text-destructive mt-1'>
{errors.deviceId.message}
</p>
)}
</div>
<div>
<Input
type='tel'
placeholder='Phone Number'
{...register('recipients.0')}
/>
{errors.recipients?.[0] && (
<p className='text-sm text-destructive mt-1'>
{errors.recipients[0].message}
</p>
)}
</div>
<div>
<Textarea
placeholder='Message'
{...register('message')}
rows={4}
/>
{errors.message && (
<p className='text-sm text-destructive mt-1'>
{errors.message.message}
</p>
)}
</div>
</div>
{sendSmsError && (
<div className='flex items-center gap-2 text-destructive'>
<p>Error sending SMS: {sendSmsError.message}</p>
<X className='h-5 w-5' />
</div>
)}
{isSendSmsSuccess && (
<div className='flex items-center gap-2 text-green-600'>
<p>SMS sent successfully!</p>
<Check className='h-5 w-5' />
</div>
)}
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type='submit' disabled={isSendingSms}>
{isSendingSms && (
<Spinner size='sm' className='mr-2' color='white' />
)}
{isSendingSms ? 'Sending...' : 'Send Reply'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
export function ReceivedSmsCard({ sms }) {
const formattedDate = new Date(sms.receivedAt).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
})
return (
<Card className='hover:bg-muted/50 transition-colors max-w-sm md:max-w-none'>
<CardContent className='p-4'>
<div className='space-y-3'>
<div className='flex justify-between items-start'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{sms.sender}</span>
</div>
<div className='flex items-center gap-1 text-sm text-muted-foreground'>
<Clock className='h-3 w-3' />
<span>{formattedDate}</span>
</div>
</div>
<div className='flex gap-2'>
<p className='text-sm max-w-sm md:max-w-none'>{sms.message}</p>
</div>
<div className='flex justify-end'>
<ReplyDialog sms={sms} />
</div>
</div>
</CardContent>
</Card>
)
}
export function ReceivedSmsCardSkeleton() {
return (
<Card className='hover:bg-muted/50 transition-colors max-w-sm md:max-w-none'>
<CardContent className='p-4'>
<div className='space-y-3'>
<div className='flex justify-between items-start'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-4 w-32' />
</div>
<Skeleton className='h-4 w-full' />
</div>
</CardContent>
</Card>
)
}
export default function ReceivedSms() {
const {
data: devices,
isLoading: isLoadingDevices,
error: devicesError,
} = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
const handleTabChange = (tab: string) => {
setCurrentTab(tab)
setPage(1)
}
const [currentTab, setCurrentTab] = useState('')
useEffect(() => {
if (devices?.data?.length) {
setCurrentTab(devices?.data?.[0]?._id)
}
}, [devices])
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const {
data: receivedSmsResponse,
isLoading: isLoadingReceivedSms,
error: receivedSmsError,
} = useQuery({
queryKey: ['received-sms', currentTab, page, limit],
enabled: !!currentTab,
queryFn: () =>
httpBrowserClient
.get(
`${ApiEndpoints.gateway.getReceivedSMS(
currentTab
)}?page=${page}&limit=${limit}`
)
.then((res) => res.data),
})
const receivedSms = receivedSmsResponse?.data || []
const pagination = receivedSmsResponse?.meta || {
page: 1,
limit: 20,
total: 0,
totalPages: 1,
}
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
if (isLoadingDevices)
return (
<div className='space-y-4'>
<Skeleton className='h-10 w-full' />
<div className='space-y-4'>
{[1, 2, 3].map((i) => (
<ReceivedSmsCardSkeleton key={i} />
))}
</div>
</div>
)
if (devicesError)
return (
<div className='flex justify-center items-center h-full'>
Error: {devicesError.message}
</div>
)
if (!devices?.data?.length)
return (
<div className='flex justify-center items-center h-full'>
No devices found
</div>
)
return (
<div>
<Tabs
value={currentTab}
onValueChange={handleTabChange}
className='space-y-4'
>
<TabsList className='flex'>
{devices?.data?.map((device) => (
<TabsTrigger key={device._id} value={device._id} className='flex-1'>
{device.brand} {device.model}
</TabsTrigger>
))}
</TabsList>
{devices?.data?.map((device) => (
<TabsContent
key={device._id}
value={device._id}
className='space-y-4'
>
{isLoadingReceivedSms && (
<div className='space-y-4'>
{[1, 2, 3].map((i) => (
<ReceivedSmsCardSkeleton key={i} />
))}
</div>
)}
{receivedSmsError && (
<div className='flex justify-center items-center h-full'>
Error: {receivedSmsError.message}
</div>
)}
{!isLoadingReceivedSms && !receivedSms?.length && (
<div className='flex justify-center items-center h-full'>
No messages found
</div>
)}
{receivedSms?.map((sms) => (
<ReceivedSmsCard key={sms._id} sms={sms} />
))}
{pagination.totalPages > 1 && (
<div className='flex justify-center mt-6 space-x-2'>
<Button
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page === 1}
variant={page === 1 ? 'ghost' : 'default'}
>
Previous
</Button>
<div className='flex flex-wrap items-center gap-2 justify-center sm:justify-start'>
{/* First page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(1)}
variant={page === 1 ? 'default' : 'ghost'}
size='icon'
className={`h-8 w-8 rounded-full ${
page === 1
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
1
</Button>
)}
{/* Ellipsis if needed */}
{page > 4 && pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Middle pages */}
{Array.from(
{ length: Math.min(6, pagination.totalPages - 2) },
(_, i) => {
let pageToShow
if (pagination.totalPages <= 8) {
// If we have 8 or fewer pages, show pages 2 through 7 (or fewer)
pageToShow = i + 2
} else if (page <= 4) {
// Near the start
pageToShow = i + 2
} else if (page >= pagination.totalPages - 3) {
// Near the end
pageToShow = pagination.totalPages - 7 + i
} else {
// Middle - center around current page
pageToShow = page - 2 + i
}
// Ensure page is within bounds and not the first or last page
if (
pageToShow > 1 &&
pageToShow < pagination.totalPages
) {
return (
<Button
key={pageToShow}
onClick={() => handlePageChange(pageToShow)}
variant={page === pageToShow ? 'default' : 'ghost'}
size='icon'
className={`h-8 w-8 rounded-full ${
page === pageToShow
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{pageToShow}
</Button>
)
}
return null
}
)}
{/* Ellipsis if needed */}
{page < pagination.totalPages - 3 &&
pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Last page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(pagination.totalPages)}
variant={
page === pagination.totalPages ? 'default' : 'ghost'
}
size='icon'
className={`h-8 w-8 rounded-full ${
page === pagination.totalPages
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{pagination.totalPages}
</Button>
)}
</div>
<Button
onClick={() =>
handlePageChange(Math.min(pagination.totalPages, page + 1))
}
disabled={page === pagination.totalPages}
variant={page === pagination.totalPages ? 'ghost' : 'default'}
>
Next
</Button>
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
)
}

51
web/app/(app)/dashboard/(components)/upgrade-to-pro-alert.tsx

@ -4,6 +4,7 @@ import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { useMemo } from 'react'
export default function UpgradeToProAlert() {
const {
@ -18,6 +19,34 @@ export default function UpgradeToProAlert() {
.then((res) => res.data),
})
const ctaMessages = useMemo(() => [
"Upgrade to Pro for exclusive features and benefits!",
"Offer: You are eligible for a 50% discount when upgrading to Pro!",
"Unlock premium features with our Pro plan today!",
"Take your experience to the next level with Pro!",
"Pro users get priority support and advanced features!",
"Limited time offer: Upgrade to Pro and save 50%!",
], []);
const buttonTexts = useMemo(() => [
"Get Pro Now!",
"Upgrade Today!",
"Go Pro!",
"Unlock Pro!",
"Claim Your Discount!",
"Upgrade & Save!",
], []);
const randomCta = useMemo(() => {
const randomIndex = Math.floor(Math.random() * ctaMessages.length);
return ctaMessages[randomIndex];
}, [ctaMessages]);
const randomButtonText = useMemo(() => {
const randomIndex = Math.floor(Math.random() * buttonTexts.length);
return buttonTexts[randomIndex];
}, [buttonTexts]);
if (isLoadingSubscription || !currentSubscription || subscriptionError) {
return null
}
@ -28,28 +57,28 @@ export default function UpgradeToProAlert() {
return (
<Alert className='bg-gradient-to-r from-purple-500 to-pink-500 text-white'>
<AlertDescription className='flex flex-wrap items-center gap-2 md:gap-4'>
<span className='flex-1'>
Upgrade to Pro for exclusive features and benefits!
<AlertDescription className='flex flex-col sm:flex-row flex-wrap items-center gap-2 md:gap-4'>
<span className='w-full sm:flex-1 text-center sm:text-left text-sm md:text-base font-medium'>
{randomCta}
</span>
<span className='flex-1'>
Use discount code <strong>SAVEBIG50</strong> at checkout for a 50%
<span className='w-full sm:flex-1 text-center sm:text-left text-xs md:text-sm'>
Use discount code <strong className="text-yellow-200">SAVEBIG50</strong> at checkout for a 50%
discount!
</span>
<div className='flex flex-wrap gap-1 md:gap-2'>
<div className='w-full sm:w-auto mt-2 sm:mt-0 flex justify-center sm:justify-end flex-wrap gap-1 md:gap-2'>
<Button
variant='outline'
size='lg'
size='sm'
asChild
className='bg-red-500 text-white hover:bg-red-600'
className='bg-red-500 text-white hover:bg-red-600 text-xs md:text-sm'
>
<Link href={'/checkout/pro'}>Get Pro Now!</Link>
<Link href={'/checkout/pro'}>{randomButtonText}</Link>
</Button>
<Button
variant='outline'
size='lg'
size='sm'
asChild
className='bg-orange-500 text-white hover:bg-orange-600'
className='bg-orange-500 text-white hover:bg-orange-600 text-xs md:text-sm'
>
<Link href={'/#pricing'}>Learn More</Link>
</Button>

1
web/config/api.ts

@ -25,6 +25,7 @@ export const ApiEndpoints = {
sendSMS: (id: string) => `/gateway/devices/${id}/send-sms`,
sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`,
getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`,
getMessages: (id: string) => `/gateway/devices/${id}/messages`,
getWebhooks: () => '/webhooks',
createWebhook: () => '/webhooks',

Loading…
Cancel
Save