From 1f67105f82e7de7d9d4931a969976683079bdd6c Mon Sep 17 00:00:00 2001 From: Akshay Date: Sat, 8 Nov 2025 09:53:49 +0530 Subject: [PATCH 1/3] feat(support): Enhance ticket history with advanced filtering and status tracking - Add dynamic ticket filtering by status with interactive buttons - Implement status count tracking for each ticket status - Add responsive design improvements to ticket history section - Include empty state handling with informative messaging - Enhance user experience with filter and status visualization - Improve component with useMemo for performance optimization - Add CardDescription for better context and user guidance --- components/TicketHistory.tsx | 116 +++++++++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 27 deletions(-) diff --git a/components/TicketHistory.tsx b/components/TicketHistory.tsx index 4231f9ce..6199c4ab 100644 --- a/components/TicketHistory.tsx +++ b/components/TicketHistory.tsx @@ -1,17 +1,20 @@ 'use client' -import React, { useState, useEffect } from 'react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import React, { useState, useEffect, useMemo } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { toast } from 'sonner' -import { MessageSquare, Clock, ChevronRight } from 'lucide-react' +import { MessageSquare, Clock, ChevronRight, Inbox } from 'lucide-react' import Link from 'next/link' +type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed' + interface SupportTicket { id: string subject: string - status: 'open' | 'in_progress' | 'resolved' | 'closed' + status: TicketStatus created_at: string updated_at: string } @@ -19,6 +22,7 @@ interface SupportTicket { export default function TicketHistory() { const [tickets, setTickets] = useState([]) const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState('all') useEffect(() => { fetchTickets() @@ -51,14 +55,26 @@ export default function TicketHistory() { } } + const filteredTickets = useMemo(() => { + if (filter === 'all') { + return tickets + } + return tickets.filter((ticket) => ticket.status === filter) + }, [tickets, filter]) + + const statusCounts = useMemo(() => { + return tickets.reduce((acc, ticket) => { + acc[ticket.status] = (acc[ticket.status] || 0) + 1 + return acc + }, {} as Record) + }, [tickets]) + if (loading) { return ( - - - My Support Tickets - + + {[1, 2, 3].map((i) => ( @@ -73,32 +89,78 @@ export default function TicketHistory() { return null } + const filterOptions: (TicketStatus | 'all')[] = ['all', 'open', 'in_progress', 'resolved', 'closed'] + return ( - - - My Support Tickets - +
+
+ + + My Support Tickets + + + Track the status of your support requests. + +
+
+ {filterOptions.map((status) => { + const count = status === 'all' ? tickets.length : statusCounts[status as TicketStatus] || 0 + if (count === 0 && status !== 'all') return null + + return ( + + ) + })} +
+
- - {tickets.map((ticket) => ( - -
-
-

{ticket.subject}

-
- {ticket.status.replace('_', ' ')} -
- - Last updated: {new Date(ticket.updated_at).toLocaleDateString()} + + {filteredTickets.length > 0 ? ( + filteredTickets.map((ticket) => ( + +
+
+

{ticket.subject}

+
+ {ticket.status.replace('_', ' ')} +
+ + Last updated: {new Date(ticket.updated_at).toLocaleDateString()} +
+
- -
- - ))} + + )) + ) : ( +
+ +

No tickets found

+

+ There are no tickets with the status "{filter.replace('_', ' ')}". +

+
+ )} ) From 599e73e9a03ca934d3f2cbc43f1f2b515d9cfb0d Mon Sep 17 00:00:00 2001 From: Akshay Date: Sat, 8 Nov 2025 10:10:19 +0530 Subject: [PATCH 2/3] feat(support): Enhance ticket detail page with advanced UI and responsive design - Refactor ticket detail page layout with responsive grid system - Add dark mode styling for ticket cards and sections - Improve reply history display with enhanced visual hierarchy - Update icons and styling for ticket type and status indicators - Remove unused card description and simplify header components - Enhance user experience with more intuitive ticket information presentation - Optimize mobile and desktop layouts for better readability --- app/protected/help/ticket/[id]/page.tsx | 187 +++++++++++++++--------- 1 file changed, 118 insertions(+), 69 deletions(-) diff --git a/app/protected/help/ticket/[id]/page.tsx b/app/protected/help/ticket/[id]/page.tsx index 55494e11..0109c139 100644 --- a/app/protected/help/ticket/[id]/page.tsx +++ b/app/protected/help/ticket/[id]/page.tsx @@ -2,11 +2,11 @@ import React, { useState, useEffect } from 'react' import { useParams, useRouter } from 'next/navigation' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' -import { ArrowLeft, MessageSquare, Clock, Calendar, Bug, Mail } from 'lucide-react' +import { ArrowLeft, MessageSquare, Bug, Mail, User, PlusCircle, XCircle } from 'lucide-react' import { toast } from 'sonner' import Link from 'next/link' @@ -80,7 +80,15 @@ export default function UserTicketDetailPage() { return (
- +
+
+ + +
+
+ +
+
) } @@ -109,81 +117,122 @@ export default function UserTicketDetailPage() {
-

Ticket Details

-

ID: {ticket.id}

+

+ {ticket.type === 'bug' ? ( + + ) : ( + + )} + {ticket.subject} +

{ticket.status.replace('_', ' ')}
- - -
- {ticket.type === 'bug' ? ( - - ) : ( - - )} - {ticket.subject} -
- - {ticket.type === 'bug' ? 'Bug Report' : 'Support Request'} - -
- -
-

{ticket.message}

-
-
-
- - Created: {new Date(ticket.created_at).toLocaleString()} -
-
- - Last updated: {new Date(ticket.updated_at).toLocaleString()} -
-
-
-
+
+
+ {/* Original Message */} + + + + + Your Initial Request + + + +

{ticket.message}

+
+
- {ticket.replies && ticket.replies.length > 0 && ( - - - - - Reply History - - - - {ticket.replies.map((reply) => ( -
-
-
- {reply.admin?.first_name?.[0] || 'A'} -
-
-

- {reply.admin?.first_name && reply.admin?.last_name - ? `${reply.admin.first_name} ${reply.admin.last_name}` - : 'Support Team'} -

-

- {new Date(reply.created_at).toLocaleString()} -

+ {/* Reply History */} + {ticket.replies && ticket.replies.length > 0 && ( + + + + + Activity + + + + {ticket.replies.map((reply) => ( +
+
+ {reply.admin?.first_name?.[0] || 'S'} +
+
+
+

+ {reply.admin?.first_name && reply.admin?.last_name + ? `${reply.admin.first_name} ${reply.admin.last_name}` + : 'Support Team'} +

+

+ {new Date(reply.created_at).toLocaleString()} +

+
+
+

{reply.message}

+
+
-
-
-

- {reply.message} -

-
+ ))} + + + )} +
+ +
+ {/* Ticket Details */} + + + Ticket Details + + +
+ Ticket ID + {ticket.id}
- ))} -
-
- )} +
+ Status + {ticket.status.replace('_', ' ')} +
+
+ Created + {new Date(ticket.created_at).toLocaleDateString()} +
+
+ Last Updated + {new Date(ticket.updated_at).toLocaleDateString()} +
+ + + + {/* Actions */} + + + Actions + + + + + + +
+
) } From c7440baf2b5be75086c8d68a3fb910390d4aed41 Mon Sep 17 00:00:00 2001 From: Akshay Date: Sat, 8 Nov 2025 10:52:41 +0530 Subject: [PATCH 3/3] feat(support): Enhance ticket reply system with user and admin interactions - Add support for user and admin replies in ticket detail view - Implement dynamic styling for user and admin reply messages - Update ticket reply API to fetch and display user and admin profiles - Add new API route for submitting user ticket replies - Improve reply display with author details, timestamps, and badges - Enhance ticket detail page to handle mixed user and admin reply scenarios - Add documentation for support ticket system --- app/admin/support/[id]/page.tsx | 79 +++++++----- app/api/admin/support/tickets/[id]/route.ts | 20 +-- app/api/support/tickets/[id]/reply/route.ts | 136 ++++++++++++++++++++ app/api/support/tickets/[id]/route.ts | 17 ++- app/protected/help/ticket/[id]/page.tsx | 99 +++++++++++++- lib/email/support-emails.ts | 55 +++++++- 6 files changed, 353 insertions(+), 53 deletions(-) create mode 100644 app/api/support/tickets/[id]/reply/route.ts diff --git a/app/admin/support/[id]/page.tsx b/app/admin/support/[id]/page.tsx index f69e7281..5f163776 100644 --- a/app/admin/support/[id]/page.tsx +++ b/app/admin/support/[id]/page.tsx @@ -24,7 +24,8 @@ import Link from 'next/link' interface TicketReply { id: string ticket_id: string - admin_id: string + admin_id?: string + user_id?: string message: string created_at: string updated_at: string @@ -35,6 +36,13 @@ interface TicketReply { last_name?: string avatar_url?: string } + user?: { + id: string + email: string + first_name?: string + last_name?: string + avatar_url?: string + } } interface SupportTicket { @@ -265,38 +273,49 @@ export default function TicketDetailPage() { - {ticket.replies.map((reply, index) => ( -
-
-
-
- {reply.admin?.first_name?.[0] || reply.admin?.email[0].toUpperCase() || 'A'} -
-
-

- {reply.admin?.first_name && reply.admin?.last_name - ? `${reply.admin.first_name} ${reply.admin.last_name}` - : reply.admin?.email || 'Admin'} -

-

- {new Date(reply.created_at).toLocaleString()} -

+ {ticket.replies.map((reply, index) => { + const isAdminReply = !!reply.admin_id + const author = isAdminReply ? reply.admin : reply.user + const authorName = author?.first_name && author?.last_name + ? `${author.first_name} ${author.last_name}` + : author?.email || (isAdminReply ? 'Admin' : 'User') + const authorInitial = author?.first_name?.[0] || author?.email?.[0]?.toUpperCase() || (isAdminReply ? 'A' : 'U') + const bgGradient = isAdminReply + ? 'from-purple-500 to-blue-600' + : 'from-blue-500 to-green-600' + + return ( +
+
+
+
+ {authorInitial} +
+
+

+ {authorName} + {!isAdminReply && (User)} +

+

+ {new Date(reply.created_at).toLocaleString()} +

+
+ + Reply #{index + 1} + +
+
+

+ {reply.message} +

- - Reply #{index + 1} - -
-
-

- {reply.message} -

-
- ))} + ) + })} )} diff --git a/app/api/admin/support/tickets/[id]/route.ts b/app/api/admin/support/tickets/[id]/route.ts index 9dce0a54..54af08b5 100644 --- a/app/api/admin/support/tickets/[id]/route.ts +++ b/app/api/admin/support/tickets/[id]/route.ts @@ -55,24 +55,28 @@ export async function GET( .eq('ticket_id', id) .order('created_at', { ascending: true }) - // Get admin profiles for replies - const adminIds = [...new Set(replies?.map(r => r.admin_id) || [])] - const { data: adminProfiles } = await supabase + // Get admin and user profiles for replies + const adminIds = [...new Set(replies?.map(r => r.admin_id).filter(id => id) || [])] + const userIds = [...new Set(replies?.map(r => r.user_id).filter(id => id) || [])] + const allProfileIds = [...adminIds, ...userIds] + + const { data: profiles } = await supabase .from('profiles') .select('id, email, first_name, last_name, avatar_url') - .in('id', adminIds) + .in('id', allProfileIds) - // Map admin data to replies - const repliesWithAdmins = replies?.map(reply => ({ + // Map admin and user data to replies + const repliesWithAuthors = replies?.map(reply => ({ ...reply, - admin: adminProfiles?.find(p => p.id === reply.admin_id) || null + admin: reply.admin_id ? profiles?.find(p => p.id === reply.admin_id) || null : null, + user: reply.user_id ? profiles?.find(p => p.id === reply.user_id) || null : null })) || [] // Combine ticket with user data and replies const ticketWithUser = { ...ticket, user: userProfile || null, - replies: repliesWithAdmins + replies: repliesWithAuthors } return NextResponse.json({ ticket: ticketWithUser }) diff --git a/app/api/support/tickets/[id]/reply/route.ts b/app/api/support/tickets/[id]/reply/route.ts new file mode 100644 index 00000000..77a1f3f4 --- /dev/null +++ b/app/api/support/tickets/[id]/reply/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { sendEmail, getAdminReplyEmail, getSupportTeamNotificationEmail } from '@/lib/email/support-emails' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + try { + const supabase = await createClient() + + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data: profile } = await supabase + .from('profiles') + .select('is_admin, first_name, last_name, email') + .eq('id', user.id) + .single() + + if (!profile) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) + } + + const { message } = await request.json() + + if (!message || !message.trim()) { + return NextResponse.json({ error: 'Message is required' }, { status: 400 }) + } + + const { data: ticket, error: ticketError } = await supabase + .from('support_tickets') + .select('*') + .eq('id', id) + .single() + + if (ticketError || !ticket) { + return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }) + } + + if (profile.is_admin) { + // Admin reply logic + const { data: userProfile } = await supabase + .from('profiles') + .select('email, first_name') + .eq('id', ticket.user_id) + .single() + + if (!userProfile?.email) { + return NextResponse.json({ error: 'User email not found' }, { status: 400 }) + } + + const userName = userProfile.first_name || 'User' + const adminName = `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Support Team' + + const { subject, html } = getAdminReplyEmail({ + userName, + adminName, + ticketId: ticket.id, + ticketSubject: ticket.subject, + replyMessage: message + }) + + await sendEmail({ + to: userProfile.email, + subject, + html, + }) + + const { data: reply, error: replyError } = await supabase + .from('support_ticket_replies') + .insert({ + ticket_id: ticket.id, + admin_id: user.id, + message: message.trim() + }) + .select() + .single() + + if (replyError) { + console.error('Error saving admin reply:', replyError) + return NextResponse.json({ error: 'Failed to save reply' }, { status: 500 }) + } + + return NextResponse.json({ success: true, reply }) + + } else { + // User reply logic + if (ticket.user_id !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const supportEmail = process.env.SUPPORT_EMAIL + if (supportEmail) { + const { subject, html } = getSupportTeamNotificationEmail({ + ticketId: ticket.id, + ticketType: ticket.type, + subject: `New reply on: ${ticket.subject}`, + message, + userEmail: profile.email, + userName: profile.first_name || 'User', + }) + await sendEmail({ + to: supportEmail, + subject, + html, + }) + } + + const { data: reply, error: replyError } = await supabase + .from('support_ticket_replies') + .insert({ + ticket_id: ticket.id, + user_id: user.id, + message: message.trim() + }) + .select() + .single() + + if (replyError) { + console.error('Error saving user reply:', replyError) + return NextResponse.json({ error: 'Failed to save reply' }, { status: 500 }) + } + + return NextResponse.json({ success: true, reply }) + } + } catch (error) { + console.error('Error in POST ticket reply:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/support/tickets/[id]/route.ts b/app/api/support/tickets/[id]/route.ts index 85270a71..72781ad8 100644 --- a/app/api/support/tickets/[id]/route.ts +++ b/app/api/support/tickets/[id]/route.ts @@ -32,24 +32,27 @@ export async function GET( const { data: replies } = await supabase .from('support_ticket_replies') - .select('id, admin_id, message, created_at') + .select('id, admin_id, user_id, message, created_at') .eq('ticket_id', id) .order('created_at', { ascending: true }) - const adminIds = [...new Set(replies?.map(r => r.admin_id) || [])] - const { data: adminProfiles } = await supabase + const adminIds = [...new Set(replies?.map(r => r.admin_id).filter(id => id) || [])] + const userIds = [...new Set(replies?.map(r => r.user_id).filter(id => id) || [])] + + const { data: profiles } = await supabase .from('profiles') .select('id, first_name, last_name, avatar_url') - .in('id', adminIds) + .in('id', [...adminIds, ...userIds]) - const repliesWithAdmins = replies?.map(reply => ({ + const repliesWithAuthors = replies?.map(reply => ({ ...reply, - admin: adminProfiles?.find(p => p.id === reply.admin_id) || null + admin: profiles?.find(p => p.id === reply.admin_id) || null, + user: profiles?.find(p => p.id === reply.user_id) || null })) || [] const ticketWithReplies = { ...ticket, - replies: repliesWithAdmins + replies: repliesWithAuthors } return NextResponse.json({ ticket: ticketWithReplies }) diff --git a/app/protected/help/ticket/[id]/page.tsx b/app/protected/help/ticket/[id]/page.tsx index 0109c139..80a6a924 100644 --- a/app/protected/help/ticket/[id]/page.tsx +++ b/app/protected/help/ticket/[id]/page.tsx @@ -6,20 +6,27 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' -import { ArrowLeft, MessageSquare, Bug, Mail, User, PlusCircle, XCircle } from 'lucide-react' +import { Textarea } from '@/components/ui/textarea' +import { ArrowLeft, MessageSquare, Bug, Mail, User, PlusCircle, XCircle, Send } from 'lucide-react' import { toast } from 'sonner' import Link from 'next/link' interface TicketReply { id: string - admin_id: string message: string created_at: string + admin_id?: string + user_id?: string admin?: { first_name?: string last_name?: string avatar_url?: string } + user?: { + first_name?: string + last_name?: string + avatar_url?: string + } } interface SupportTicket { @@ -40,6 +47,9 @@ export default function UserTicketDetailPage() { const [ticket, setTicket] = useState(null) const [loading, setLoading] = useState(true) + const [reply, setReply] = useState('') + const [sendingReply, setSendingReply] = useState(false) + const [replyError, setReplyError] = useState('') useEffect(() => { if (ticketId) { @@ -66,6 +76,37 @@ export default function UserTicketDetailPage() { } } + const handleReplySubmit = async () => { + if (!reply.trim()) { + setReplyError('Reply message cannot be empty.') + return + } + setReplyError('') + setSendingReply(true) + + try { + const response = await fetch(`/api/support/tickets/${ticketId}/reply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: reply }), + }) + + if (response.ok) { + toast.success('Reply sent successfully!') + setReply('') + await fetchTicket() + } else { + const error = await response.json() + toast.error(error.error || 'Failed to send reply') + } + } catch (error) { + console.error('Error sending reply:', error) + toast.error('Failed to send reply') + } finally { + setSendingReply(false) + } + } + const getStatusColor = (status: string) => { switch (status) { case 'open': return 'bg-red-500/10 text-red-400 border-red-500/20' @@ -158,15 +199,27 @@ export default function UserTicketDetailPage() { {ticket.replies.map((reply) => (
-
- {reply.admin?.first_name?.[0] || 'S'} +
+ {reply.admin_id + ? reply.admin?.first_name?.[0] || 'S' + : reply.user?.first_name?.[0] || 'U' + }

- {reply.admin?.first_name && reply.admin?.last_name - ? `${reply.admin.first_name} ${reply.admin.last_name}` - : 'Support Team'} + {reply.admin_id + ? (reply.admin?.first_name && reply.admin?.last_name + ? `${reply.admin.first_name} ${reply.admin.last_name}` + : 'Support Team') + : (reply.user?.first_name && reply.user?.last_name + ? `${reply.user.first_name} ${reply.user.last_name}` + : 'You') + }

{new Date(reply.created_at).toLocaleString()} @@ -181,6 +234,38 @@ export default function UserTicketDetailPage() { )} + + {/* Reply Form */} + {ticket.status !== 'resolved' && ticket.status !== 'closed' && ( + + + + + Send a Reply + + + +