Skip to content

Commit c7440ba

Browse files
committed
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
1 parent 599e73e commit c7440ba

File tree

6 files changed

+353
-53
lines changed

6 files changed

+353
-53
lines changed

app/admin/support/[id]/page.tsx

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import Link from 'next/link'
2424
interface TicketReply {
2525
id: string
2626
ticket_id: string
27-
admin_id: string
27+
admin_id?: string
28+
user_id?: string
2829
message: string
2930
created_at: string
3031
updated_at: string
@@ -35,6 +36,13 @@ interface TicketReply {
3536
last_name?: string
3637
avatar_url?: string
3738
}
39+
user?: {
40+
id: string
41+
email: string
42+
first_name?: string
43+
last_name?: string
44+
avatar_url?: string
45+
}
3846
}
3947

4048
interface SupportTicket {
@@ -265,38 +273,49 @@ export default function TicketDetailPage() {
265273
</CardDescription>
266274
</CardHeader>
267275
<CardContent className="space-y-4">
268-
{ticket.replies.map((reply, index) => (
269-
<div
270-
key={reply.id}
271-
className="border-l-4 border-purple-500/30 bg-purple-500/5 rounded-r-lg p-4 space-y-2"
272-
>
273-
<div className="flex items-center justify-between">
274-
<div className="flex items-center gap-2">
275-
<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">
276-
{reply.admin?.first_name?.[0] || reply.admin?.email[0].toUpperCase() || 'A'}
277-
</div>
278-
<div>
279-
<p className="text-sm font-medium">
280-
{reply.admin?.first_name && reply.admin?.last_name
281-
? `${reply.admin.first_name} ${reply.admin.last_name}`
282-
: reply.admin?.email || 'Admin'}
283-
</p>
284-
<p className="text-xs text-muted-foreground">
285-
{new Date(reply.created_at).toLocaleString()}
286-
</p>
276+
{ticket.replies.map((reply, index) => {
277+
const isAdminReply = !!reply.admin_id
278+
const author = isAdminReply ? reply.admin : reply.user
279+
const authorName = author?.first_name && author?.last_name
280+
? `${author.first_name} ${author.last_name}`
281+
: author?.email || (isAdminReply ? 'Admin' : 'User')
282+
const authorInitial = author?.first_name?.[0] || author?.email?.[0]?.toUpperCase() || (isAdminReply ? 'A' : 'U')
283+
const bgGradient = isAdminReply
284+
? 'from-purple-500 to-blue-600'
285+
: 'from-blue-500 to-green-600'
286+
287+
return (
288+
<div
289+
key={reply.id}
290+
className={`border-l-4 ${isAdminReply ? 'border-purple-500/30 bg-purple-500/5' : 'border-blue-500/30 bg-blue-500/5'} rounded-r-lg p-4 space-y-2`}
291+
>
292+
<div className="flex items-center justify-between">
293+
<div className="flex items-center gap-2">
294+
<div className={`h-8 w-8 rounded-full bg-gradient-to-br ${bgGradient} flex items-center justify-center text-white text-sm font-semibold`}>
295+
{authorInitial}
296+
</div>
297+
<div>
298+
<p className="text-sm font-medium">
299+
{authorName}
300+
{!isAdminReply && <span className="text-xs text-muted-foreground ml-2">(User)</span>}
301+
</p>
302+
<p className="text-xs text-muted-foreground">
303+
{new Date(reply.created_at).toLocaleString()}
304+
</p>
305+
</div>
287306
</div>
307+
<Badge variant="outline" className="text-xs">
308+
Reply #{index + 1}
309+
</Badge>
310+
</div>
311+
<div className="pl-10">
312+
<p className="text-sm text-foreground whitespace-pre-wrap">
313+
{reply.message}
314+
</p>
288315
</div>
289-
<Badge variant="outline" className="text-xs">
290-
Reply #{index + 1}
291-
</Badge>
292-
</div>
293-
<div className="pl-10">
294-
<p className="text-sm text-foreground whitespace-pre-wrap">
295-
{reply.message}
296-
</p>
297316
</div>
298-
</div>
299-
))}
317+
)
318+
})}
300319
</CardContent>
301320
</Card>
302321
)}

app/api/admin/support/tickets/[id]/route.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,24 +55,28 @@ export async function GET(
5555
.eq('ticket_id', id)
5656
.order('created_at', { ascending: true })
5757

58-
// Get admin profiles for replies
59-
const adminIds = [...new Set(replies?.map(r => r.admin_id) || [])]
60-
const { data: adminProfiles } = await supabase
58+
// Get admin and user profiles for replies
59+
const adminIds = [...new Set(replies?.map(r => r.admin_id).filter(id => id) || [])]
60+
const userIds = [...new Set(replies?.map(r => r.user_id).filter(id => id) || [])]
61+
const allProfileIds = [...adminIds, ...userIds]
62+
63+
const { data: profiles } = await supabase
6164
.from('profiles')
6265
.select('id, email, first_name, last_name, avatar_url')
63-
.in('id', adminIds)
66+
.in('id', allProfileIds)
6467

65-
// Map admin data to replies
66-
const repliesWithAdmins = replies?.map(reply => ({
68+
// Map admin and user data to replies
69+
const repliesWithAuthors = replies?.map(reply => ({
6770
...reply,
68-
admin: adminProfiles?.find(p => p.id === reply.admin_id) || null
71+
admin: reply.admin_id ? profiles?.find(p => p.id === reply.admin_id) || null : null,
72+
user: reply.user_id ? profiles?.find(p => p.id === reply.user_id) || null : null
6973
})) || []
7074

7175
// Combine ticket with user data and replies
7276
const ticketWithUser = {
7377
...ticket,
7478
user: userProfile || null,
75-
replies: repliesWithAdmins
79+
replies: repliesWithAuthors
7680
}
7781

7882
return NextResponse.json({ ticket: ticketWithUser })
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient } from '@/lib/supabase/server'
3+
import { sendEmail, getAdminReplyEmail, getSupportTeamNotificationEmail } from '@/lib/email/support-emails'
4+
5+
export async function POST(
6+
request: NextRequest,
7+
{ params }: { params: Promise<{ id: string }> }
8+
) {
9+
const { id } = await params
10+
11+
try {
12+
const supabase = await createClient()
13+
14+
const { data: { user }, error: authError } = await supabase.auth.getUser()
15+
16+
if (authError || !user) {
17+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18+
}
19+
20+
const { data: profile } = await supabase
21+
.from('profiles')
22+
.select('is_admin, first_name, last_name, email')
23+
.eq('id', user.id)
24+
.single()
25+
26+
if (!profile) {
27+
return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
28+
}
29+
30+
const { message } = await request.json()
31+
32+
if (!message || !message.trim()) {
33+
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
34+
}
35+
36+
const { data: ticket, error: ticketError } = await supabase
37+
.from('support_tickets')
38+
.select('*')
39+
.eq('id', id)
40+
.single()
41+
42+
if (ticketError || !ticket) {
43+
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 })
44+
}
45+
46+
if (profile.is_admin) {
47+
// Admin reply logic
48+
const { data: userProfile } = await supabase
49+
.from('profiles')
50+
.select('email, first_name')
51+
.eq('id', ticket.user_id)
52+
.single()
53+
54+
if (!userProfile?.email) {
55+
return NextResponse.json({ error: 'User email not found' }, { status: 400 })
56+
}
57+
58+
const userName = userProfile.first_name || 'User'
59+
const adminName = `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Support Team'
60+
61+
const { subject, html } = getAdminReplyEmail({
62+
userName,
63+
adminName,
64+
ticketId: ticket.id,
65+
ticketSubject: ticket.subject,
66+
replyMessage: message
67+
})
68+
69+
await sendEmail({
70+
to: userProfile.email,
71+
subject,
72+
html,
73+
})
74+
75+
const { data: reply, error: replyError } = await supabase
76+
.from('support_ticket_replies')
77+
.insert({
78+
ticket_id: ticket.id,
79+
admin_id: user.id,
80+
message: message.trim()
81+
})
82+
.select()
83+
.single()
84+
85+
if (replyError) {
86+
console.error('Error saving admin reply:', replyError)
87+
return NextResponse.json({ error: 'Failed to save reply' }, { status: 500 })
88+
}
89+
90+
return NextResponse.json({ success: true, reply })
91+
92+
} else {
93+
// User reply logic
94+
if (ticket.user_id !== user.id) {
95+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
96+
}
97+
98+
const supportEmail = process.env.SUPPORT_EMAIL
99+
if (supportEmail) {
100+
const { subject, html } = getSupportTeamNotificationEmail({
101+
ticketId: ticket.id,
102+
ticketType: ticket.type,
103+
subject: `New reply on: ${ticket.subject}`,
104+
message,
105+
userEmail: profile.email,
106+
userName: profile.first_name || 'User',
107+
})
108+
await sendEmail({
109+
to: supportEmail,
110+
subject,
111+
html,
112+
})
113+
}
114+
115+
const { data: reply, error: replyError } = await supabase
116+
.from('support_ticket_replies')
117+
.insert({
118+
ticket_id: ticket.id,
119+
user_id: user.id,
120+
message: message.trim()
121+
})
122+
.select()
123+
.single()
124+
125+
if (replyError) {
126+
console.error('Error saving user reply:', replyError)
127+
return NextResponse.json({ error: 'Failed to save reply' }, { status: 500 })
128+
}
129+
130+
return NextResponse.json({ success: true, reply })
131+
}
132+
} catch (error) {
133+
console.error('Error in POST ticket reply:', error)
134+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
135+
}
136+
}

app/api/support/tickets/[id]/route.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,27 @@ export async function GET(
3232

3333
const { data: replies } = await supabase
3434
.from('support_ticket_replies')
35-
.select('id, admin_id, message, created_at')
35+
.select('id, admin_id, user_id, message, created_at')
3636
.eq('ticket_id', id)
3737
.order('created_at', { ascending: true })
3838

39-
const adminIds = [...new Set(replies?.map(r => r.admin_id) || [])]
40-
const { data: adminProfiles } = await supabase
39+
const adminIds = [...new Set(replies?.map(r => r.admin_id).filter(id => id) || [])]
40+
const userIds = [...new Set(replies?.map(r => r.user_id).filter(id => id) || [])]
41+
42+
const { data: profiles } = await supabase
4143
.from('profiles')
4244
.select('id, first_name, last_name, avatar_url')
43-
.in('id', adminIds)
45+
.in('id', [...adminIds, ...userIds])
4446

45-
const repliesWithAdmins = replies?.map(reply => ({
47+
const repliesWithAuthors = replies?.map(reply => ({
4648
...reply,
47-
admin: adminProfiles?.find(p => p.id === reply.admin_id) || null
49+
admin: profiles?.find(p => p.id === reply.admin_id) || null,
50+
user: profiles?.find(p => p.id === reply.user_id) || null
4851
})) || []
4952

5053
const ticketWithReplies = {
5154
...ticket,
52-
replies: repliesWithAdmins
55+
replies: repliesWithAuthors
5356
}
5457

5558
return NextResponse.json({ ticket: ticketWithReplies })

0 commit comments

Comments
 (0)