From dd142c145233cc67e41b546ed4a86301fd6b7de5 Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 8 Mar 2025 22:56:54 +0300 Subject: [PATCH 1/2] chore(web): improve upgrade to pro cta --- .../(components)/upgrade-to-pro-alert.tsx | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/web/app/(app)/dashboard/(components)/upgrade-to-pro-alert.tsx b/web/app/(app)/dashboard/(components)/upgrade-to-pro-alert.tsx index 07b909a..9c3ed09 100644 --- a/web/app/(app)/dashboard/(components)/upgrade-to-pro-alert.tsx +++ b/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,38 +19,66 @@ 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 } - if (['pro', 'custom'].includes(currentSubscription?.plan?.name)) { + if (!['pro', 'custom'].includes(currentSubscription?.plan?.name)) { return null } return ( - - - Upgrade to Pro for exclusive features and benefits! + + + {randomCta} - - Use discount code SAVEBIG50 at checkout for a 50% + + Use discount code SAVEBIG50 at checkout for a 50% discount! -
+
From 7876bd2f74c6781bd7f413823a761572370c7ff0 Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 8 Mar 2025 22:57:53 +0300 Subject: [PATCH 2/2] feat: show sent messages in dashboard --- api/src/gateway/gateway.controller.ts | 20 + api/src/gateway/gateway.service.ts | 60 ++ .../(components)/message-history.tsx | 752 ++++++++++++++++++ .../dashboard/(components)/messaging.tsx | 11 +- .../dashboard/(components)/received-sms.tsx | 498 ------------ .../(components)/upgrade-to-pro-alert.tsx | 2 +- web/config/api.ts | 1 + 7 files changed, 839 insertions(+), 505 deletions(-) create mode 100644 web/app/(app)/dashboard/(components)/message-history.tsx delete mode 100644 web/app/(app)/dashboard/(components)/received-sms.tsx diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 6bdf1e0..7e6cf75 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/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 { + // 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; + } } diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 27bcb7a..31d1f8d 100644 --- a/api/src/gateway/gateway.service.ts +++ b/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) diff --git a/web/app/(app)/dashboard/(components)/message-history.tsx b/web/app/(app)/dashboard/(components)/message-history.tsx new file mode 100644 index 0000000..2566a04 --- /dev/null +++ b/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({ + 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 ( + + + + + + + + + Reply to {sms.sender} + + + Send a reply message to this sender + + +
handleSubmit((data) => sendSms(data))(e)} + className='space-y-4 mt-4' + > +
+
+ ( + + )} + /> + {errors.deviceId && ( +

+ {errors.deviceId.message} +

+ )} +
+ +
+ + {errors.recipients?.[0] && ( +

+ {errors.recipients[0].message} +

+ )} +
+ +
+