From 5777b38aaa167892148575bd1eeb3f28bd680966 Mon Sep 17 00:00:00 2001 From: Akshay Date: Sat, 8 Nov 2025 09:28:07 +0530 Subject: [PATCH] feat(support): Add comprehensive support ticket detail and management system - Implement new API routes for fetching support tickets and ticket details - Create ticket detail page with dynamic routing and comprehensive ticket information - Add TicketHistory component to help page for displaying user's support tickets - Enhance support ticket management with status tracking and reply functionality - Implement error handling and authorization checks for ticket-related operations - Add documentation for new support ticket features - Improve user experience with detailed ticket views and status indicators --- app/api/support/tickets/[id]/route.ts | 60 ++++++++ app/api/support/tickets/route.ts | 30 ++++ app/protected/help/page.tsx | 4 + app/protected/help/ticket/[id]/page.tsx | 189 ++++++++++++++++++++++++ components/TicketHistory.tsx | 105 +++++++++++++ 5 files changed, 388 insertions(+) create mode 100644 app/api/support/tickets/[id]/route.ts create mode 100644 app/api/support/tickets/route.ts create mode 100644 app/protected/help/ticket/[id]/page.tsx create mode 100644 components/TicketHistory.tsx diff --git a/app/api/support/tickets/[id]/route.ts b/app/api/support/tickets/[id]/route.ts new file mode 100644 index 00000000..85270a71 --- /dev/null +++ b/app/api/support/tickets/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET( + 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: ticket, error } = await supabase + .from('support_tickets') + .select('*') + .eq('id', id) + .single() + + if (error || !ticket) { + return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }) + } + + if (ticket.user_id !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { data: replies } = await supabase + .from('support_ticket_replies') + .select('id, admin_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 + .from('profiles') + .select('id, first_name, last_name, avatar_url') + .in('id', adminIds) + + const repliesWithAdmins = replies?.map(reply => ({ + ...reply, + admin: adminProfiles?.find(p => p.id === reply.admin_id) || null + })) || [] + + const ticketWithReplies = { + ...ticket, + replies: repliesWithAdmins + } + + return NextResponse.json({ ticket: ticketWithReplies }) + } catch (error) { + console.error('Error in GET ticket:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/support/tickets/route.ts b/app/api/support/tickets/route.ts new file mode 100644 index 00000000..3cad232b --- /dev/null +++ b/app/api/support/tickets/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET() { + 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: tickets, error } = await supabase + .from('support_tickets') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + + if (error) { + console.error('Error fetching tickets:', error) + return NextResponse.json({ error: 'Failed to fetch tickets' }, { status: 500 }) + } + + return NextResponse.json({ tickets }) + } catch (error) { + console.error('Error in GET tickets:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/protected/help/page.tsx b/app/protected/help/page.tsx index 306fa8ea..3075e610 100644 --- a/app/protected/help/page.tsx +++ b/app/protected/help/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Skeleton } from '@/components/ui/skeleton' import { toast } from 'sonner' +import TicketHistory from '@/components/TicketHistory' import { Search, HelpCircle, @@ -483,6 +484,9 @@ export default function HelpPage() { )} + {/* Ticket History */} + + {/* Quick Actions */} {!searchQuery && (
diff --git a/app/protected/help/ticket/[id]/page.tsx b/app/protected/help/ticket/[id]/page.tsx new file mode 100644 index 00000000..55494e11 --- /dev/null +++ b/app/protected/help/ticket/[id]/page.tsx @@ -0,0 +1,189 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Card, CardContent, CardDescription, 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 { toast } from 'sonner' +import Link from 'next/link' + +interface TicketReply { + id: string + admin_id: string + message: string + created_at: string + admin?: { + first_name?: string + last_name?: string + avatar_url?: string + } +} + +interface SupportTicket { + id: string + subject: string + message: string + status: 'open' | 'in_progress' | 'resolved' | 'closed' + created_at: string + updated_at: string + type: 'contact' | 'bug' + replies?: TicketReply[] +} + +export default function UserTicketDetailPage() { + const params = useParams() + const router = useRouter() + const ticketId = params.id as string + + const [ticket, setTicket] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (ticketId) { + fetchTicket() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticketId]) + + const fetchTicket = async () => { + try { + const response = await fetch(`/api/support/tickets/${ticketId}`) + if (response.ok) { + const data = await response.json() + setTicket(data.ticket) + } else { + toast.error('Failed to load ticket details') + router.push('/protected/help') + } + } catch (error) { + console.error('Error fetching ticket:', error) + toast.error('Failed to load ticket details') + } finally { + setLoading(false) + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'open': return 'bg-red-500/10 text-red-400 border-red-500/20' + case 'in_progress': return 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' + case 'resolved': return 'bg-green-500/10 text-green-400 border-green-500/20' + case 'closed': return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' + default: return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' + } + } + + if (loading) { + return ( +
+ + +
+ ) + } + + if (!ticket) { + return ( +
+ + +

Ticket not found

+ +
+
+
+ ) + } + + return ( +
+
+ +
+

Ticket Details

+

ID: {ticket.id}

+
+ + {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()} +
+
+
+
+ + {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.message} +

+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/components/TicketHistory.tsx b/components/TicketHistory.tsx new file mode 100644 index 00000000..4231f9ce --- /dev/null +++ b/components/TicketHistory.tsx @@ -0,0 +1,105 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { toast } from 'sonner' +import { MessageSquare, Clock, ChevronRight } from 'lucide-react' +import Link from 'next/link' + +interface SupportTicket { + id: string + subject: string + status: 'open' | 'in_progress' | 'resolved' | 'closed' + created_at: string + updated_at: string +} + +export default function TicketHistory() { + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchTickets() + }, []) + + const fetchTickets = async () => { + try { + const response = await fetch('/api/support/tickets') + if (response.ok) { + const data = await response.json() + setTickets(data.tickets) + } else { + toast.error('Failed to load ticket history') + } + } catch (error) { + console.error('Error fetching tickets:', error) + toast.error('Failed to load ticket history') + } finally { + setLoading(false) + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'open': return 'bg-red-500/10 text-red-400 border-red-500/20' + case 'in_progress': return 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' + case 'resolved': return 'bg-green-500/10 text-green-400 border-green-500/20' + case 'closed': return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' + default: return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' + } + } + + if (loading) { + return ( + + + + + My Support Tickets + + + + {[1, 2, 3].map((i) => ( + + ))} + + + ) + } + + if (tickets.length === 0) { + return null + } + + return ( + + + + + My Support Tickets + + + + {tickets.map((ticket) => ( + +
+
+

{ticket.subject}

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