Skip to content

Commit 4fdda6e

Browse files
authored
Merge pull request #288 from codeunia-dev/feat/adminsupport
feat(support): Add admin reply functionality to support ticket detail page
2 parents 4a5846f + a8f6d7b commit 4fdda6e

File tree

3 files changed

+340
-42
lines changed

3 files changed

+340
-42
lines changed

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

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
User,
1616
Calendar,
1717
MessageSquare,
18-
Save
18+
Save,
19+
Send
1920
} from 'lucide-react'
2021
import { toast } from 'sonner'
2122
import Link from 'next/link'
@@ -47,6 +48,8 @@ export default function TicketDetailPage() {
4748
const [loading, setLoading] = useState(true)
4849
const [updating, setUpdating] = useState(false)
4950
const [notes, setNotes] = useState('')
51+
const [reply, setReply] = useState('')
52+
const [sendingReply, setSendingReply] = useState(false)
5053

5154
useEffect(() => {
5255
fetchTicket()
@@ -94,6 +97,59 @@ export default function TicketDetailPage() {
9497
}
9598
}
9699

100+
const sendReply = async () => {
101+
if (!reply.trim()) {
102+
toast.error('Please enter a reply message')
103+
return
104+
}
105+
106+
setSendingReply(true)
107+
try {
108+
const response = await fetch(`/api/admin/support/tickets/${ticketId}/reply`, {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
body: JSON.stringify({ message: reply }),
112+
})
113+
114+
if (response.ok) {
115+
toast.success('Reply sent successfully!')
116+
setReply('')
117+
118+
// Update status to "in_progress" if it's "open"
119+
if (ticket?.status === 'open') {
120+
try {
121+
const statusResponse = await fetch(`/api/admin/support/tickets/${ticketId}`, {
122+
method: 'PATCH',
123+
headers: { 'Content-Type': 'application/json' },
124+
body: JSON.stringify({ status: 'in_progress' }),
125+
})
126+
127+
if (statusResponse.ok) {
128+
toast.success('Status updated to In Progress')
129+
} else {
130+
console.error('Failed to update status')
131+
toast.error('Reply sent, but failed to update status')
132+
}
133+
} catch (statusError) {
134+
console.error('Error updating status:', statusError)
135+
toast.error('Reply sent, but failed to update status')
136+
}
137+
}
138+
139+
// Refresh ticket data
140+
await fetchTicket()
141+
} else {
142+
const error = await response.json()
143+
toast.error(error.error || 'Failed to send reply')
144+
}
145+
} catch (error) {
146+
console.error('Error sending reply:', error)
147+
toast.error('Failed to send reply')
148+
} finally {
149+
setSendingReply(false)
150+
}
151+
}
152+
97153
const getStatusColor = (status: string) => {
98154
switch (status) {
99155
case 'open': return 'bg-red-500/10 text-red-400 border-red-500/20'
@@ -179,6 +235,56 @@ export default function TicketDetailPage() {
179235
</CardContent>
180236
</Card>
181237

238+
{/* Reply to User */}
239+
<Card className="border-blue-500/20 bg-blue-500/5">
240+
<CardHeader>
241+
<CardTitle className="flex items-center gap-2">
242+
<Send className="h-5 w-5 text-blue-500" />
243+
Reply to User
244+
</CardTitle>
245+
<CardDescription>
246+
Send a response directly to {ticket.user?.email || 'the user'}
247+
</CardDescription>
248+
</CardHeader>
249+
<CardContent className="space-y-4">
250+
<Textarea
251+
placeholder="Type your response here... This will be sent via email to the user."
252+
value={reply}
253+
onChange={(e) => setReply(e.target.value)}
254+
rows={6}
255+
className="resize-none"
256+
disabled={sendingReply}
257+
/>
258+
<div className="flex items-center justify-between">
259+
<p className="text-xs text-muted-foreground">
260+
{reply.length}/2000 characters
261+
</p>
262+
<div className="flex gap-2">
263+
<Button
264+
variant="outline"
265+
onClick={() => setReply('')}
266+
disabled={!reply.trim() || sendingReply}
267+
>
268+
Clear
269+
</Button>
270+
<Button
271+
onClick={sendReply}
272+
disabled={!reply.trim() || sendingReply || reply.length > 2000}
273+
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700"
274+
>
275+
<Send className="h-4 w-4 mr-2" />
276+
{sendingReply ? 'Sending...' : 'Send Reply'}
277+
</Button>
278+
</div>
279+
</div>
280+
{ticket.status === 'open' && (
281+
<p className="text-xs text-muted-foreground bg-yellow-500/10 border border-yellow-500/20 rounded p-2">
282+
💡 Tip: Sending a reply will automatically change the status to &quot;In Progress&quot;
283+
</p>
284+
)}
285+
</CardContent>
286+
</Card>
287+
182288
{/* Internal Notes */}
183289
<Card>
184290
<CardHeader>
@@ -187,7 +293,7 @@ export default function TicketDetailPage() {
187293
Internal Notes
188294
</CardTitle>
189295
<CardDescription>
190-
Add notes visible only to admins
296+
Add notes visible only to admins (not sent to user)
191297
</CardDescription>
192298
</CardHeader>
193299
<CardContent className="space-y-4">
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient } from '@/lib/supabase/server'
3+
import { sendEmail } 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+
console.log('📧 Reply API called for ticket:', id)
12+
13+
try {
14+
const supabase = await createClient()
15+
16+
// Check if user is admin
17+
const { data: { user }, error: authError } = await supabase.auth.getUser()
18+
19+
if (authError || !user) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
23+
const { data: profile } = await supabase
24+
.from('profiles')
25+
.select('is_admin, first_name, last_name')
26+
.eq('id', user.id)
27+
.single()
28+
29+
if (!profile?.is_admin) {
30+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
31+
}
32+
33+
const { message } = await request.json()
34+
35+
if (!message || !message.trim()) {
36+
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
37+
}
38+
39+
if (message.length > 2000) {
40+
return NextResponse.json({ error: 'Message too long (max 2000 characters)' }, { status: 400 })
41+
}
42+
43+
// Get ticket
44+
const { data: ticket, error: ticketError } = await supabase
45+
.from('support_tickets')
46+
.select('*')
47+
.eq('id', id)
48+
.single()
49+
50+
if (ticketError) {
51+
console.error('Error fetching ticket:', ticketError)
52+
return NextResponse.json({ error: 'Ticket not found', details: ticketError.message }, { status: 404 })
53+
}
54+
55+
if (!ticket) {
56+
console.error('Ticket not found in database:', id)
57+
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 })
58+
}
59+
60+
// Get user information
61+
const { data: userProfile } = await supabase
62+
.from('profiles')
63+
.select('email, first_name, last_name')
64+
.eq('id', ticket.user_id)
65+
.single()
66+
67+
if (!userProfile?.email) {
68+
console.error('User email not found for ticket:', id)
69+
return NextResponse.json({ error: 'User email not found' }, { status: 400 })
70+
}
71+
72+
console.log('✅ Ticket found:', { id: ticket.id, userEmail: userProfile.email })
73+
74+
// Prepare email
75+
const userName = userProfile.first_name || userProfile.email.split('@')[0] || 'User'
76+
const adminName = `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Support Team'
77+
78+
const emailHtml = getAdminReplyEmail({
79+
userName,
80+
adminName,
81+
ticketId: ticket.id,
82+
ticketSubject: ticket.subject,
83+
replyMessage: message
84+
})
85+
86+
// Send email to user
87+
console.log('📧 Sending reply email to:', userProfile.email)
88+
89+
const emailResult = await sendEmail({
90+
to: userProfile.email,
91+
subject: `Re: [Ticket #${ticket.id.substring(0, 8)}] ${ticket.subject}`,
92+
html: emailHtml
93+
})
94+
95+
if (!emailResult.success) {
96+
console.error('❌ Failed to send reply email:', emailResult.error)
97+
return NextResponse.json({ error: 'Failed to send email', details: emailResult.error }, { status: 500 })
98+
}
99+
100+
console.log('✅ Reply email sent successfully')
101+
102+
// TODO: Save reply to database (for reply history)
103+
// This would go in a support_ticket_replies table
104+
105+
return NextResponse.json({
106+
success: true,
107+
message: 'Reply sent successfully'
108+
})
109+
} catch (error) {
110+
console.error('Error in reply API:', error)
111+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
112+
}
113+
}
114+
115+
// Email template for admin reply
116+
function getAdminReplyEmail(params: {
117+
userName: string
118+
adminName: string
119+
ticketId: string
120+
ticketSubject: string
121+
replyMessage: string
122+
}) {
123+
const content = `
124+
<h2 style="margin: 0 0 20px 0; color: #111827; font-size: 20px;">
125+
Response to your support ticket
126+
</h2>
127+
128+
<p style="margin: 0 0 15px 0; color: #374151; font-size: 16px; line-height: 1.5;">
129+
Hi ${params.userName},
130+
</p>
131+
132+
<p style="margin: 0 0 15px 0; color: #374151; font-size: 16px; line-height: 1.5;">
133+
${params.adminName} from our support team has responded to your ticket:
134+
</p>
135+
136+
<div style="background-color: #f9fafb; border-left: 4px solid #3b82f6; padding: 15px; margin: 20px 0; border-radius: 4px;">
137+
<p style="margin: 0 0 10px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
138+
Ticket ID: ${params.ticketId}
139+
</p>
140+
<p style="margin: 0 0 5px 0; color: #111827; font-size: 16px; font-weight: 600;">
141+
${params.ticketSubject}
142+
</p>
143+
</div>
144+
145+
<div style="background-color: #eff6ff; border: 1px solid #dbeafe; padding: 20px; margin: 20px 0; border-radius: 8px;">
146+
<p style="margin: 0 0 10px 0; color: #1e40af; font-size: 14px; font-weight: 600;">
147+
${params.adminName} replied:
148+
</p>
149+
<p style="margin: 0; color: #1f2937; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">
150+
${params.replyMessage}
151+
</p>
152+
</div>
153+
154+
<p style="margin: 20px 0 15px 0; color: #374151; font-size: 16px; line-height: 1.5;">
155+
If you have any follow-up questions, please reply to this email or create a new ticket.
156+
</p>
157+
158+
<a href="https://codeunia.com/protected/help" style="display: inline-block; background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 600; margin-top: 10px;">
159+
View Help Center
160+
</a>
161+
`
162+
163+
return getEmailTemplate(content)
164+
}
165+
166+
// Base email template
167+
function getEmailTemplate(content: string) {
168+
return `
169+
<!DOCTYPE html>
170+
<html>
171+
<head>
172+
<meta charset="utf-8">
173+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
174+
<title>Codeunia Support</title>
175+
</head>
176+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
177+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 20px;">
178+
<tr>
179+
<td align="center">
180+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
181+
<!-- Header -->
182+
<tr>
183+
<td style="background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); padding: 30px; text-align: center;">
184+
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold;">Codeunia Support</h1>
185+
</td>
186+
</tr>
187+
188+
<!-- Content -->
189+
<tr>
190+
<td style="padding: 40px 30px;">
191+
${content}
192+
</td>
193+
</tr>
194+
195+
<!-- Footer -->
196+
<tr>
197+
<td style="background-color: #f9fafb; padding: 20px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
198+
<p style="margin: 0 0 10px 0; color: #6b7280; font-size: 14px;">
199+
Need help? Reply to this email or visit our <a href="https://codeunia.com/protected/help" style="color: #3b82f6; text-decoration: none;">Help Center</a>
200+
</p>
201+
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
202+
© ${new Date().getFullYear()} Codeunia. All rights reserved.
203+
</p>
204+
</td>
205+
</tr>
206+
</table>
207+
</td>
208+
</tr>
209+
</table>
210+
</body>
211+
</html>
212+
`
213+
}

0 commit comments

Comments
 (0)