Skip to content

Commit 5777b38

Browse files
committed
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
1 parent 849e7bc commit 5777b38

File tree

5 files changed

+388
-0
lines changed

5 files changed

+388
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient } from '@/lib/supabase/server'
3+
4+
export async function GET(
5+
request: NextRequest,
6+
{ params }: { params: Promise<{ id:string }> }
7+
) {
8+
const { id } = await params
9+
10+
try {
11+
const supabase = await createClient()
12+
13+
const { data: { user }, error: authError } = await supabase.auth.getUser()
14+
15+
if (authError || !user) {
16+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const { data: ticket, error } = await supabase
20+
.from('support_tickets')
21+
.select('*')
22+
.eq('id', id)
23+
.single()
24+
25+
if (error || !ticket) {
26+
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 })
27+
}
28+
29+
if (ticket.user_id !== user.id) {
30+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
31+
}
32+
33+
const { data: replies } = await supabase
34+
.from('support_ticket_replies')
35+
.select('id, admin_id, message, created_at')
36+
.eq('ticket_id', id)
37+
.order('created_at', { ascending: true })
38+
39+
const adminIds = [...new Set(replies?.map(r => r.admin_id) || [])]
40+
const { data: adminProfiles } = await supabase
41+
.from('profiles')
42+
.select('id, first_name, last_name, avatar_url')
43+
.in('id', adminIds)
44+
45+
const repliesWithAdmins = replies?.map(reply => ({
46+
...reply,
47+
admin: adminProfiles?.find(p => p.id === reply.admin_id) || null
48+
})) || []
49+
50+
const ticketWithReplies = {
51+
...ticket,
52+
replies: repliesWithAdmins
53+
}
54+
55+
return NextResponse.json({ ticket: ticketWithReplies })
56+
} catch (error) {
57+
console.error('Error in GET ticket:', error)
58+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
59+
}
60+
}

app/api/support/tickets/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextResponse } from 'next/server'
2+
import { createClient } from '@/lib/supabase/server'
3+
4+
export async function GET() {
5+
try {
6+
const supabase = await createClient()
7+
8+
const { data: { user }, error: authError } = await supabase.auth.getUser()
9+
10+
if (authError || !user) {
11+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12+
}
13+
14+
const { data: tickets, error } = await supabase
15+
.from('support_tickets')
16+
.select('*')
17+
.eq('user_id', user.id)
18+
.order('created_at', { ascending: false })
19+
20+
if (error) {
21+
console.error('Error fetching tickets:', error)
22+
return NextResponse.json({ error: 'Failed to fetch tickets' }, { status: 500 })
23+
}
24+
25+
return NextResponse.json({ tickets })
26+
} catch (error) {
27+
console.error('Error in GET tickets:', error)
28+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
29+
}
30+
}

app/protected/help/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
88
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
99
import { Skeleton } from '@/components/ui/skeleton'
1010
import { toast } from 'sonner'
11+
import TicketHistory from '@/components/TicketHistory'
1112
import {
1213
Search,
1314
HelpCircle,
@@ -483,6 +484,9 @@ export default function HelpPage() {
483484
)}
484485
</div>
485486

487+
{/* Ticket History */}
488+
<TicketHistory />
489+
486490
{/* Quick Actions */}
487491
{!searchQuery && (
488492
<div>
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
'use client'
2+
3+
import React, { useState, useEffect } from 'react'
4+
import { useParams, useRouter } from 'next/navigation'
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6+
import { Button } from '@/components/ui/button'
7+
import { Badge } from '@/components/ui/badge'
8+
import { Skeleton } from '@/components/ui/skeleton'
9+
import { ArrowLeft, MessageSquare, Clock, Calendar, Bug, Mail } from 'lucide-react'
10+
import { toast } from 'sonner'
11+
import Link from 'next/link'
12+
13+
interface TicketReply {
14+
id: string
15+
admin_id: string
16+
message: string
17+
created_at: string
18+
admin?: {
19+
first_name?: string
20+
last_name?: string
21+
avatar_url?: string
22+
}
23+
}
24+
25+
interface SupportTicket {
26+
id: string
27+
subject: string
28+
message: string
29+
status: 'open' | 'in_progress' | 'resolved' | 'closed'
30+
created_at: string
31+
updated_at: string
32+
type: 'contact' | 'bug'
33+
replies?: TicketReply[]
34+
}
35+
36+
export default function UserTicketDetailPage() {
37+
const params = useParams()
38+
const router = useRouter()
39+
const ticketId = params.id as string
40+
41+
const [ticket, setTicket] = useState<SupportTicket | null>(null)
42+
const [loading, setLoading] = useState(true)
43+
44+
useEffect(() => {
45+
if (ticketId) {
46+
fetchTicket()
47+
}
48+
// eslint-disable-next-line react-hooks/exhaustive-deps
49+
}, [ticketId])
50+
51+
const fetchTicket = async () => {
52+
try {
53+
const response = await fetch(`/api/support/tickets/${ticketId}`)
54+
if (response.ok) {
55+
const data = await response.json()
56+
setTicket(data.ticket)
57+
} else {
58+
toast.error('Failed to load ticket details')
59+
router.push('/protected/help')
60+
}
61+
} catch (error) {
62+
console.error('Error fetching ticket:', error)
63+
toast.error('Failed to load ticket details')
64+
} finally {
65+
setLoading(false)
66+
}
67+
}
68+
69+
const getStatusColor = (status: string) => {
70+
switch (status) {
71+
case 'open': return 'bg-red-500/10 text-red-400 border-red-500/20'
72+
case 'in_progress': return 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
73+
case 'resolved': return 'bg-green-500/10 text-green-400 border-green-500/20'
74+
case 'closed': return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
75+
default: return 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
76+
}
77+
}
78+
79+
if (loading) {
80+
return (
81+
<div className="p-6 space-y-6">
82+
<Skeleton className="h-10 w-64" />
83+
<Skeleton className="h-96" />
84+
</div>
85+
)
86+
}
87+
88+
if (!ticket) {
89+
return (
90+
<div className="p-6">
91+
<Card>
92+
<CardContent className="py-12 text-center">
93+
<p className="text-muted-foreground">Ticket not found</p>
94+
<Button asChild className="mt-4">
95+
<Link href="/protected/help">Back to Help Center</Link>
96+
</Button>
97+
</CardContent>
98+
</Card>
99+
</div>
100+
)
101+
}
102+
103+
return (
104+
<div className="p-6 space-y-6">
105+
<div className="flex items-center gap-4">
106+
<Button variant="outline" size="icon" asChild>
107+
<Link href="/protected/help">
108+
<ArrowLeft className="h-4 w-4" />
109+
</Link>
110+
</Button>
111+
<div className="flex-1">
112+
<h1 className="text-2xl font-bold">Ticket Details</h1>
113+
<p className="text-sm text-muted-foreground">ID: {ticket.id}</p>
114+
</div>
115+
<Badge className={getStatusColor(ticket.status)}>
116+
{ticket.status.replace('_', ' ')}
117+
</Badge>
118+
</div>
119+
120+
<Card>
121+
<CardHeader>
122+
<div className="flex items-center gap-2">
123+
{ticket.type === 'bug' ? (
124+
<Bug className="h-5 w-5 text-red-500" />
125+
) : (
126+
<Mail className="h-5 w-5 text-blue-500" />
127+
)}
128+
<CardTitle>{ticket.subject}</CardTitle>
129+
</div>
130+
<CardDescription>
131+
{ticket.type === 'bug' ? 'Bug Report' : 'Support Request'}
132+
</CardDescription>
133+
</CardHeader>
134+
<CardContent>
135+
<div className="prose prose-sm max-w-none">
136+
<p className="whitespace-pre-wrap">{ticket.message}</p>
137+
</div>
138+
<div className="flex items-center gap-4 text-xs text-zinc-400 mt-4">
139+
<div className="flex items-center gap-1">
140+
<Calendar className="h-3 w-3" />
141+
<span>Created: {new Date(ticket.created_at).toLocaleString()}</span>
142+
</div>
143+
<div className="flex items-center gap-1">
144+
<Clock className="h-3 w-3" />
145+
<span>Last updated: {new Date(ticket.updated_at).toLocaleString()}</span>
146+
</div>
147+
</div>
148+
</CardContent>
149+
</Card>
150+
151+
{ticket.replies && ticket.replies.length > 0 && (
152+
<Card>
153+
<CardHeader>
154+
<CardTitle className="flex items-center gap-2">
155+
<MessageSquare className="h-5 w-5 text-purple-500" />
156+
Reply History
157+
</CardTitle>
158+
</CardHeader>
159+
<CardContent className="space-y-4">
160+
{ticket.replies.map((reply) => (
161+
<div key={reply.id} className="border-l-4 border-purple-500/30 bg-purple-500/5 rounded-r-lg p-4 space-y-2">
162+
<div className="flex items-center gap-2">
163+
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center text-white text-sm font-semibold">
164+
{reply.admin?.first_name?.[0] || 'A'}
165+
</div>
166+
<div>
167+
<p className="text-sm font-medium">
168+
{reply.admin?.first_name && reply.admin?.last_name
169+
? `${reply.admin.first_name} ${reply.admin.last_name}`
170+
: 'Support Team'}
171+
</p>
172+
<p className="text-xs text-muted-foreground">
173+
{new Date(reply.created_at).toLocaleString()}
174+
</p>
175+
</div>
176+
</div>
177+
<div className="pl-10">
178+
<p className="text-sm text-foreground whitespace-pre-wrap">
179+
{reply.message}
180+
</p>
181+
</div>
182+
</div>
183+
))}
184+
</CardContent>
185+
</Card>
186+
)}
187+
</div>
188+
)
189+
}

0 commit comments

Comments
 (0)