mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:51:12 -05:00
feat: Implement ticket system and reply button
This commit is contained in:
@@ -539,6 +539,7 @@ export type Database = {
|
||||
status: string
|
||||
subject: string
|
||||
thread_id: string | null
|
||||
ticket_number: string | null
|
||||
updated_at: string
|
||||
user_agent: string | null
|
||||
user_id: string | null
|
||||
@@ -560,6 +561,7 @@ export type Database = {
|
||||
status?: string
|
||||
subject: string
|
||||
thread_id?: string | null
|
||||
ticket_number?: string | null
|
||||
updated_at?: string
|
||||
user_agent?: string | null
|
||||
user_id?: string | null
|
||||
@@ -581,6 +583,7 @@ export type Database = {
|
||||
status?: string
|
||||
subject?: string
|
||||
thread_id?: string | null
|
||||
ticket_number?: string | null
|
||||
updated_at?: string
|
||||
user_agent?: string | null
|
||||
user_id?: string | null
|
||||
@@ -3954,6 +3957,7 @@ export type Database = {
|
||||
}
|
||||
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
||||
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
||||
generate_ticket_number: { Args: never; Returns: string }
|
||||
get_email_change_status: { Args: never; Returns: Json }
|
||||
get_filtered_profile: {
|
||||
Args: { _profile_user_id: string; _viewer_id?: string }
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
ArrowDownLeft,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -71,6 +74,7 @@ interface ContactSubmission {
|
||||
thread_id: string;
|
||||
last_admin_response_at: string | null;
|
||||
response_count: number;
|
||||
ticket_number: string;
|
||||
}
|
||||
|
||||
interface EmailThread {
|
||||
@@ -98,6 +102,7 @@ export default function AdminContact() {
|
||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||
const [emailThreads, setEmailThreads] = useState<EmailThread[]>([]);
|
||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||
const [copiedTicket, setCopiedTicket] = useState<string | null>(null);
|
||||
|
||||
// Fetch contact submissions
|
||||
const { data: submissions, isLoading } = useQuery({
|
||||
@@ -169,8 +174,10 @@ export default function AdminContact() {
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
handleSuccess('Reply Sent', 'Your email response has been sent successfully');
|
||||
onSuccess: (_data, variables) => {
|
||||
const submission = submissions?.find(s => s.id === variables.submissionId);
|
||||
const ticketNumber = submission?.ticket_number || 'Unknown';
|
||||
handleSuccess('Reply Sent', `Your email response has been sent for ticket ${ticketNumber}`);
|
||||
setReplyBody('');
|
||||
setShowReplyForm(false);
|
||||
// Refetch threads
|
||||
@@ -264,6 +271,19 @@ export default function AdminContact() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
|
||||
};
|
||||
|
||||
const handleCopyTicket = (ticketNumber: string) => {
|
||||
navigator.clipboard.writeText(ticketNumber);
|
||||
setCopiedTicket(ticketNumber);
|
||||
setTimeout(() => setCopiedTicket(null), 2000);
|
||||
};
|
||||
|
||||
const handleQuickReply = (e: React.MouseEvent, submission: ContactSubmission) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSubmission(submission);
|
||||
setAdminNotes(submission.admin_notes || '');
|
||||
setShowReplyForm(true);
|
||||
};
|
||||
|
||||
// Show loading state while roles are being fetched
|
||||
if (rolesLoading) {
|
||||
return (
|
||||
@@ -463,6 +483,20 @@ export default function AdminContact() {
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="font-semibold text-lg">{submission.subject}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyTicket(submission.ticket_number);
|
||||
}}
|
||||
>
|
||||
{copiedTicket === submission.ticket_number ? (
|
||||
<><Check className="h-3 w-3 mr-1" /> Copied!</>
|
||||
) : (
|
||||
<><Copy className="h-3 w-3 mr-1" /> {submission.ticket_number}</>
|
||||
)}
|
||||
</Badge>
|
||||
{getStatusBadge(submission.status)}
|
||||
<Badge variant="outline">{getCategoryLabel(submission.category)}</Badge>
|
||||
{submission.response_count > 0 && (
|
||||
@@ -489,7 +523,19 @@ export default function AdminContact() {
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{submission.message}</p>
|
||||
</div>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleQuickReply(e, submission)}
|
||||
disabled={sendReplyMutation.isPending}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -520,11 +566,22 @@ export default function AdminContact() {
|
||||
{selectedSubmission && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DialogTitle className="flex items-center gap-2 flex-wrap">
|
||||
<Mail className="h-5 w-5" />
|
||||
{selectedSubmission.subject}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleCopyTicket(selectedSubmission.ticket_number)}
|
||||
>
|
||||
{copiedTicket === selectedSubmission.ticket_number ? (
|
||||
<><Check className="h-3 w-3 mr-1" /> Copied!</>
|
||||
) : (
|
||||
<><Copy className="h-3 w-3 mr-1" /> {selectedSubmission.ticket_number}</>
|
||||
)}
|
||||
</Badge>
|
||||
{selectedSubmission.response_count > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
📧 {selectedSubmission.response_count} {selectedSubmission.response_count === 1 ? 'reply' : 'replies'}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -654,9 +711,17 @@ export default function AdminContact() {
|
||||
) : (
|
||||
<Card className="border-primary/20">
|
||||
<CardContent className="pt-4">
|
||||
<Label className="text-sm font-medium mb-2 block">
|
||||
Reply to {selectedSubmission.name} ({selectedSubmission.email})
|
||||
</Label>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Reply to {selectedSubmission.name} ({selectedSubmission.email})
|
||||
</Label>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{selectedSubmission.ticket_number}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-3">
|
||||
Subject: Re: [{selectedSubmission.ticket_number}] {selectedSubmission.subject}
|
||||
</div>
|
||||
<Textarea
|
||||
value={replyBody}
|
||||
onChange={(e) => setReplyBody(e.target.value)}
|
||||
|
||||
@@ -66,7 +66,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
// Fetch submission
|
||||
const { data: submission, error: fetchError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id, email, name, subject, thread_id, response_count')
|
||||
.select('id, email, name, subject, thread_id, response_count, ticket_number')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
@@ -89,8 +89,9 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
}, 429, corsHeaders);
|
||||
}
|
||||
|
||||
const messageId = `<${crypto.randomUUID()}@thrillwiki.com>`;
|
||||
const finalSubject = replySubject || `Re: ${submission.subject}`;
|
||||
const ticketNumber = submission.ticket_number || 'UNKNOWN';
|
||||
const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`;
|
||||
const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`;
|
||||
|
||||
// Get previous message for threading
|
||||
const { data: previousMessages } = await supabase
|
||||
@@ -100,7 +101,13 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
const inReplyTo = previousMessages?.[0]?.message_id || `<${submission.thread_id}@thrillwiki.com>`;
|
||||
const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
|
||||
const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId;
|
||||
|
||||
// Build reference chain for threading
|
||||
const referenceChain = previousMessages?.[0]?.message_id
|
||||
? [originalMessageId, previousMessages[0].message_id].join(' ')
|
||||
: originalMessageId;
|
||||
|
||||
// Send email via ForwardEmail
|
||||
const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
|
||||
@@ -117,8 +124,9 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
headers: {
|
||||
'Message-ID': messageId,
|
||||
'In-Reply-To': inReplyTo,
|
||||
'References': inReplyTo,
|
||||
'X-Thread-ID': submission.thread_id
|
||||
'References': referenceChain,
|
||||
'X-Thread-ID': submission.thread_id,
|
||||
'X-Ticket-Number': ticketNumber
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -139,7 +139,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
userId = user?.id || null;
|
||||
}
|
||||
|
||||
// Insert contact submission
|
||||
// Insert contact submission (ticket number auto-generated by trigger)
|
||||
const { data: submission, error: insertError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.insert({
|
||||
@@ -153,7 +153,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
ip_address_hash: ipHash,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.select('*, ticket_number')
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
@@ -174,6 +174,9 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
||||
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
||||
|
||||
const ticketNumber = submission.ticket_number || 'PENDING';
|
||||
const messageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
|
||||
|
||||
if (forwardEmailKey) {
|
||||
// Send admin notification
|
||||
fetch('https://api.forwardemail.net/v1/emails', {
|
||||
@@ -185,9 +188,10 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `New Contact Form Submission - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
||||
subject: `[${ticketNumber}] New Contact - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
||||
text: `A new contact message has been received:
|
||||
|
||||
Ticket: ${ticketNumber}
|
||||
From: ${name} (${email})
|
||||
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
Subject: ${subject}
|
||||
@@ -199,6 +203,10 @@ Reference ID: ${submission.id}
|
||||
Submitted: ${new Date(submission.created_at).toLocaleString()}
|
||||
|
||||
View in admin panel: https://thrillwiki.com/admin/contact`,
|
||||
headers: {
|
||||
'Message-ID': messageId,
|
||||
'X-Ticket-Number': ticketNumber
|
||||
}
|
||||
}),
|
||||
}).catch(err => {
|
||||
edgeLogger.error('Failed to send admin notification', { requestId, error: err.message });
|
||||
@@ -214,25 +222,37 @@ View in admin panel: https://thrillwiki.com/admin/contact`,
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: "We've received your message - ThrillWiki Support",
|
||||
subject: `[${ticketNumber}] We've received your message - ThrillWiki Support`,
|
||||
text: `Hi ${name},
|
||||
|
||||
Thank you for contacting ThrillWiki! We've received your message and will respond within 24-48 hours.
|
||||
|
||||
Your Message Details:
|
||||
Ticket Number: ${ticketNumber}
|
||||
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
Subject: ${subject}
|
||||
|
||||
Reference ID: ${submission.id}
|
||||
When replying to this email, please keep the ticket number in the subject line to ensure your response is properly tracked.
|
||||
|
||||
Our support team will review your message and get back to you as soon as possible.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team`,
|
||||
headers: {
|
||||
'Message-ID': messageId,
|
||||
'X-Ticket-Number': ticketNumber,
|
||||
'References': messageId
|
||||
}
|
||||
}),
|
||||
}).catch(err => {
|
||||
edgeLogger.error('Failed to send confirmation email', { requestId, error: err.message });
|
||||
});
|
||||
|
||||
// Update thread_id with ticket number
|
||||
await supabase
|
||||
.from('contact_submissions')
|
||||
.update({ thread_id: `ticket-${ticketNumber}` })
|
||||
.eq('id', submission.id);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -246,7 +266,8 @@ The ThrillWiki Team`,
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
submissionId: submission.id,
|
||||
message: 'Your message has been received. We will respond within 24-48 hours.'
|
||||
ticketNumber: ticketNumber,
|
||||
message: `Your message has been received (Ticket: ${ticketNumber}). We will respond within 24-48 hours.`
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Add ticket number system to contact submissions
|
||||
-- Create sequence for ticket numbers starting at 100000
|
||||
CREATE SEQUENCE IF NOT EXISTS contact_ticket_number_seq START 100000;
|
||||
|
||||
-- Add ticket_number column with unique constraint
|
||||
ALTER TABLE public.contact_submissions
|
||||
ADD COLUMN IF NOT EXISTS ticket_number TEXT UNIQUE;
|
||||
|
||||
-- Create function to generate ticket number in format TW-XXXXXX
|
||||
CREATE OR REPLACE FUNCTION generate_ticket_number()
|
||||
RETURNS TEXT AS $$
|
||||
BEGIN
|
||||
RETURN 'TW-' || LPAD(nextval('contact_ticket_number_seq')::TEXT, 6, '0');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create function to auto-set ticket number on insert
|
||||
CREATE OR REPLACE FUNCTION set_ticket_number()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.ticket_number IS NULL THEN
|
||||
NEW.ticket_number := generate_ticket_number();
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Add trigger to auto-generate ticket numbers
|
||||
DROP TRIGGER IF EXISTS set_ticket_number_trigger ON public.contact_submissions;
|
||||
CREATE TRIGGER set_ticket_number_trigger
|
||||
BEFORE INSERT ON public.contact_submissions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_ticket_number();
|
||||
|
||||
-- Backfill existing submissions with ticket numbers based on creation order
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT id
|
||||
FROM public.contact_submissions
|
||||
WHERE ticket_number IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LOOP
|
||||
UPDATE public.contact_submissions
|
||||
SET ticket_number = generate_ticket_number()
|
||||
WHERE id = rec.id;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Add index for ticket_number lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_ticket_number
|
||||
ON public.contact_submissions(ticket_number);
|
||||
Reference in New Issue
Block a user