mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
feat: Implement contact page and backend
This commit is contained in:
@@ -43,6 +43,7 @@ const BlogPost = lazy(() => import("./pages/BlogPost"));
|
||||
const Terms = lazy(() => import("./pages/Terms"));
|
||||
const Privacy = lazy(() => import("./pages/Privacy"));
|
||||
const SubmissionGuidelines = lazy(() => import("./pages/SubmissionGuidelines"));
|
||||
const Contact = lazy(() => import("./pages/Contact"));
|
||||
|
||||
// Admin routes (lazy-loaded - heavy bundle)
|
||||
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
|
||||
@@ -52,6 +53,7 @@ const AdminSystemLog = lazy(() => import("./pages/AdminSystemLog"));
|
||||
const AdminUsers = lazy(() => import("./pages/AdminUsers"));
|
||||
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
|
||||
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
|
||||
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
||||
|
||||
// User routes (lazy-loaded)
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
|
||||
104
src/components/contact/ContactFAQ.tsx
Normal file
104
src/components/contact/ContactFAQ.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
|
||||
export function ContactFAQ() {
|
||||
const faqs = [
|
||||
{
|
||||
question: 'How do I submit a new park or ride?',
|
||||
answer: (
|
||||
<>
|
||||
You can submit new parks and rides through our submission system. Simply navigate to the{' '}
|
||||
<Link to="/parks" className="text-primary hover:underline">
|
||||
Parks
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link to="/rides" className="text-primary hover:underline">
|
||||
Rides
|
||||
</Link>{' '}
|
||||
page and click the "Add" button. All submissions go through our moderation queue to ensure quality and accuracy.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How long does moderation take?',
|
||||
answer:
|
||||
'Most submissions are reviewed within 24-48 hours. Complex submissions or those requiring additional verification may take slightly longer. You can track the status of your submissions in your profile.',
|
||||
},
|
||||
{
|
||||
question: 'Can I edit my submissions?',
|
||||
answer:
|
||||
'Yes! You can edit any information you\'ve submitted. All edits also go through moderation to maintain data quality. We track all changes in our version history system.',
|
||||
},
|
||||
{
|
||||
question: 'How do I report incorrect information?',
|
||||
answer: (
|
||||
<>
|
||||
If you notice incorrect information, you can either submit an edit with the correct data or contact us using the form above with category "Report an Issue". Please include the page URL and describe what needs to be corrected.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What if I forgot my password?',
|
||||
answer: (
|
||||
<>
|
||||
Use the "Forgot Password" link on the{' '}
|
||||
<Link to="/auth" className="text-primary hover:underline">
|
||||
login page
|
||||
</Link>
|
||||
. We'll send you a password reset link via email. If you don't receive it, check your spam folder or contact us for assistance.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How do I delete my account?',
|
||||
answer: (
|
||||
<>
|
||||
You can request account deletion from your{' '}
|
||||
<Link to="/settings" className="text-primary hover:underline">
|
||||
Settings page
|
||||
</Link>
|
||||
. For security, we require confirmation before processing deletion requests. Please note that some anonymized data (like submissions) may be retained for database integrity.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Do you have a community Discord or forum?',
|
||||
answer:
|
||||
'We\'re planning to launch community features soon! For now, you can connect with other enthusiasts through reviews and comments on park and ride pages.',
|
||||
},
|
||||
{
|
||||
question: 'Can I contribute photos?',
|
||||
answer:
|
||||
'Absolutely! You can upload photos to any park or ride page. Photos go through moderation and must follow our content guidelines. High-quality photos are greatly appreciated!',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Frequently Asked Questions</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Find answers to common questions. If you don't see what you're looking for, feel free to contact us.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{faqs.map((faq, index) => (
|
||||
<AccordionItem key={index} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{faq.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/components/contact/ContactForm.tsx
Normal file
308
src/components/contact/ContactForm.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { contactFormSchema, contactCategories, type ContactFormData } from '@/lib/contactValidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export function ContactForm() {
|
||||
const { user } = useAuth();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||
const [captchaKey, setCaptchaKey] = useState(0);
|
||||
const [userName, setUserName] = useState('');
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
// Fetch user profile if logged in
|
||||
useEffect(() => {
|
||||
const fetchUserProfile = async () => {
|
||||
if (user) {
|
||||
setUserEmail(user.email || '');
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name, username')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (profile) {
|
||||
setUserName(profile.display_name || profile.username || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserProfile();
|
||||
}, [user]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
defaultValues: {
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
subject: '',
|
||||
category: 'general',
|
||||
message: '',
|
||||
captchaToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when user data loads
|
||||
useEffect(() => {
|
||||
if (userName) {
|
||||
setValue('name', userName);
|
||||
}
|
||||
if (userEmail) {
|
||||
setValue('email', userEmail);
|
||||
}
|
||||
}, [userName, userEmail, setValue]);
|
||||
|
||||
const onCaptchaSuccess = (token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setValue('captchaToken', token);
|
||||
};
|
||||
|
||||
const onCaptchaError = () => {
|
||||
setCaptchaToken('');
|
||||
setValue('captchaToken', '');
|
||||
handleError(
|
||||
new Error('CAPTCHA verification failed'),
|
||||
{ action: 'verify_captcha' }
|
||||
);
|
||||
};
|
||||
|
||||
const onCaptchaExpire = () => {
|
||||
setCaptchaToken('');
|
||||
setValue('captchaToken', '');
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ContactFormData) => {
|
||||
if (!captchaToken) {
|
||||
handleError(
|
||||
new Error('Please complete the CAPTCHA'),
|
||||
{ action: 'submit_contact_form' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
logger.info('Submitting contact form', { category: data.category });
|
||||
|
||||
const { data: result, error } = await supabase.functions.invoke(
|
||||
'send-contact-message',
|
||||
{
|
||||
body: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
subject: data.subject,
|
||||
message: data.message,
|
||||
category: data.category,
|
||||
captchaToken: data.captchaToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
handleSuccess(
|
||||
'Message Sent!',
|
||||
"Thank you for contacting us. We'll respond within 24-48 hours."
|
||||
);
|
||||
|
||||
// Reset form
|
||||
reset({
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
subject: '',
|
||||
category: 'general',
|
||||
message: '',
|
||||
captchaToken: '',
|
||||
});
|
||||
|
||||
// Reset CAPTCHA
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey((prev) => prev + 1);
|
||||
|
||||
logger.info('Contact form submitted successfully', {
|
||||
submissionId: result?.submissionId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit contact form', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
handleError(error, {
|
||||
action: 'submit_contact_form',
|
||||
metadata: { category: data.category },
|
||||
});
|
||||
|
||||
// Reset CAPTCHA on error
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey((prev) => prev + 1);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="Your name"
|
||||
disabled={isSubmitting}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
placeholder="your.email@example.com"
|
||||
disabled={isSubmitting}
|
||||
className={errors.email ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select
|
||||
value={watch('category')}
|
||||
onValueChange={(value) =>
|
||||
setValue('category', value as ContactFormData['category'])
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className={errors.category ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.category && (
|
||||
<p className="text-sm text-destructive">{errors.category.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
{...register('subject')}
|
||||
placeholder="Brief description of your inquiry"
|
||||
disabled={isSubmitting}
|
||||
className={errors.subject ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-destructive">{errors.subject.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message *</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
{...register('message')}
|
||||
placeholder="Please provide details about your inquiry..."
|
||||
rows={6}
|
||||
disabled={isSubmitting}
|
||||
className={errors.message ? 'border-destructive' : ''}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
{errors.message && (
|
||||
<p className="text-sm text-destructive">{errors.message.message}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground ml-auto">
|
||||
{watch('message')?.length || 0} / 2000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CAPTCHA */}
|
||||
<div className="space-y-2">
|
||||
<TurnstileCaptcha
|
||||
key={captchaKey}
|
||||
onSuccess={onCaptchaSuccess}
|
||||
onError={onCaptchaError}
|
||||
onExpire={onCaptchaExpire}
|
||||
theme="auto"
|
||||
size="normal"
|
||||
/>
|
||||
{errors.captchaToken && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.captchaToken.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isSubmitting || !captchaToken}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Message
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
We typically respond within 24-48 hours
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
28
src/components/contact/ContactInfoCard.tsx
Normal file
28
src/components/contact/ContactInfoCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface ContactInfoCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ContactInfoCard({ icon: Icon, title, description, content }: ContactInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription className="text-sm">{description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,12 @@ export function Footer() {
|
||||
© {new Date().getFullYear()} ThrillWiki. All rights reserved.
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="hover:text-foreground transition-colors"
|
||||
|
||||
@@ -441,6 +441,84 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
contact_rate_limits: {
|
||||
Row: {
|
||||
email: string
|
||||
last_submission_at: string
|
||||
submission_count: number
|
||||
window_start: string
|
||||
}
|
||||
Insert: {
|
||||
email: string
|
||||
last_submission_at?: string
|
||||
submission_count?: number
|
||||
window_start?: string
|
||||
}
|
||||
Update: {
|
||||
email?: string
|
||||
last_submission_at?: string
|
||||
submission_count?: number
|
||||
window_start?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
contact_submissions: {
|
||||
Row: {
|
||||
admin_notes: string | null
|
||||
assigned_to: string | null
|
||||
category: string
|
||||
created_at: string
|
||||
email: string
|
||||
id: string
|
||||
ip_address_hash: string | null
|
||||
message: string
|
||||
name: string
|
||||
resolved_at: string | null
|
||||
resolved_by: string | null
|
||||
status: string
|
||||
subject: string
|
||||
updated_at: string
|
||||
user_agent: string | null
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
admin_notes?: string | null
|
||||
assigned_to?: string | null
|
||||
category: string
|
||||
created_at?: string
|
||||
email: string
|
||||
id?: string
|
||||
ip_address_hash?: string | null
|
||||
message: string
|
||||
name: string
|
||||
resolved_at?: string | null
|
||||
resolved_by?: string | null
|
||||
status?: string
|
||||
subject: string
|
||||
updated_at?: string
|
||||
user_agent?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
admin_notes?: string | null
|
||||
assigned_to?: string | null
|
||||
category?: string
|
||||
created_at?: string
|
||||
email?: string
|
||||
id?: string
|
||||
ip_address_hash?: string | null
|
||||
message?: string
|
||||
name?: string
|
||||
resolved_at?: string | null
|
||||
resolved_by?: string | null
|
||||
status?: string
|
||||
subject?: string
|
||||
updated_at?: string
|
||||
user_agent?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
content_submissions: {
|
||||
Row: {
|
||||
approval_mode: string | null
|
||||
|
||||
46
src/lib/contactValidation.ts
Normal file
46
src/lib/contactValidation.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const contactCategories = [
|
||||
{ value: 'general', label: 'General Inquiry' },
|
||||
{ value: 'moderation', label: 'Moderation Questions' },
|
||||
{ value: 'technical', label: 'Technical Support' },
|
||||
{ value: 'account', label: 'Account Issues' },
|
||||
{ value: 'partnership', label: 'Partnership/Business' },
|
||||
{ value: 'report', label: 'Report an Issue' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const;
|
||||
|
||||
export const contactFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters'),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email('Invalid email address')
|
||||
.max(255, 'Email must be less than 255 characters'),
|
||||
subject: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'Subject must be at least 5 characters')
|
||||
.max(200, 'Subject must be less than 200 characters'),
|
||||
category: z.string()
|
||||
.refine(
|
||||
(val) => ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'].includes(val),
|
||||
{ message: 'Please select a valid category' }
|
||||
),
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(20, 'Message must be at least 20 characters')
|
||||
.max(2000, 'Message must be less than 2000 characters'),
|
||||
captchaToken: z.string().min(1, 'Please complete the CAPTCHA'),
|
||||
});
|
||||
|
||||
export type ContactFormData = z.infer<typeof contactFormSchema>;
|
||||
|
||||
export interface ContactSubmission extends ContactFormData {
|
||||
userId?: string;
|
||||
}
|
||||
114
src/pages/Contact.tsx
Normal file
114
src/pages/Contact.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Mail, Clock, BookOpen, HelpCircle } from 'lucide-react';
|
||||
import { ContactForm } from '@/components/contact/ContactForm';
|
||||
import { ContactInfoCard } from '@/components/contact/ContactInfoCard';
|
||||
import { ContactFAQ } from '@/components/contact/ContactFAQ';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Contact() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">Get in Touch</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Have questions or feedback? We're here to help. Send us a message and we'll respond as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Contact Form - Main Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send Us a Message</CardTitle>
|
||||
<CardDescription>
|
||||
Fill out the form below and we'll get back to you within 24-48 hours
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContactForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Quick Info */}
|
||||
<div className="space-y-6">
|
||||
{/* Email Support */}
|
||||
<ContactInfoCard
|
||||
icon={Mail}
|
||||
title="Email Support"
|
||||
description="Direct contact"
|
||||
content={
|
||||
<a
|
||||
href="mailto:support@thrillwiki.com"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
support@thrillwiki.com
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Response Time */}
|
||||
<ContactInfoCard
|
||||
icon={Clock}
|
||||
title="Response Time"
|
||||
description="Our commitment"
|
||||
content={
|
||||
<p className="text-sm">
|
||||
We typically respond within <strong>24-48 hours</strong> during business days.
|
||||
Complex inquiries may take longer.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Documentation */}
|
||||
<ContactInfoCard
|
||||
icon={BookOpen}
|
||||
title="Documentation"
|
||||
description="Self-service help"
|
||||
content={
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
to="/submission-guidelines"
|
||||
className="block text-primary hover:underline"
|
||||
>
|
||||
Submission Guidelines
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="block text-primary hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="block text-primary hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Help Resources */}
|
||||
<ContactInfoCard
|
||||
icon={HelpCircle}
|
||||
title="Help Resources"
|
||||
description="Before you contact us"
|
||||
content={
|
||||
<p className="text-sm">
|
||||
Check out our FAQ section below for answers to common questions. Many issues can be resolved quickly by reviewing our documentation.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="mt-12">
|
||||
<ContactFAQ />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
457
src/pages/admin/AdminContact.tsx
Normal file
457
src/pages/admin/AdminContact.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Mail,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Filter,
|
||||
Search,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { contactCategories } from '@/lib/contactValidation';
|
||||
|
||||
interface ContactSubmission {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
category: string;
|
||||
status: 'pending' | 'in_progress' | 'resolved' | 'closed';
|
||||
assigned_to: string | null;
|
||||
admin_notes: string | null;
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
}
|
||||
|
||||
export default function AdminContact() {
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedSubmission, setSelectedSubmission] = useState<ContactSubmission | null>(null);
|
||||
const [adminNotes, setAdminNotes] = useState('');
|
||||
|
||||
// Fetch contact submissions
|
||||
const { data: submissions, isLoading } = useQuery({
|
||||
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery],
|
||||
queryFn: async () => {
|
||||
let query = supabase
|
||||
.from('contact_submissions')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
query = query.eq('status', statusFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
query = query.eq('category', categoryFilter);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
query = query.or(
|
||||
`name.ilike.%${searchQuery}%,email.ilike.%${searchQuery}%,subject.ilike.%${searchQuery}%`
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to fetch contact submissions', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as ContactSubmission[];
|
||||
},
|
||||
});
|
||||
|
||||
// Update submission status
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
status,
|
||||
notes,
|
||||
}: {
|
||||
id: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
}) => {
|
||||
const updateData: Record<string, unknown> = { status };
|
||||
|
||||
if (notes) {
|
||||
updateData.admin_notes = notes;
|
||||
}
|
||||
|
||||
if (status === 'resolved' || status === 'closed') {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
updateData.resolved_at = new Date().toISOString();
|
||||
updateData.resolved_by = user?.id;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contact_submissions')
|
||||
.update(updateData)
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
|
||||
handleSuccess('Status Updated', 'Contact submission status has been updated');
|
||||
setSelectedSubmission(null);
|
||||
setAdminNotes('');
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error, { action: 'update_contact_status' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateStatus = (status: string) => {
|
||||
if (!selectedSubmission) return;
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
id: selectedSubmission.id,
|
||||
status,
|
||||
notes: adminNotes || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
pending: 'default',
|
||||
in_progress: 'secondary',
|
||||
resolved: 'outline',
|
||||
closed: 'outline',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || 'default'}>
|
||||
{status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const cat = contactCategories.find((c) => c.value === category);
|
||||
return cat?.label || category;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
pending: submissions?.filter((s) => s.status === 'pending').length || 0,
|
||||
inProgress: submissions?.filter((s) => s.status === 'in_progress').length || 0,
|
||||
resolved: submissions?.filter((s) => s.status === 'resolved').length || 0,
|
||||
total: submissions?.length || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Contact Submissions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and respond to user contact form submissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Pending</CardDescription>
|
||||
<CardTitle className="text-3xl">{stats.pending}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>In Progress</CardDescription>
|
||||
<CardTitle className="text-3xl">{stats.inProgress}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Resolved</CardDescription>
|
||||
<CardTitle className="text-3xl">{stats.resolved}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Total</CardDescription>
|
||||
<CardTitle className="text-3xl">{stats.total}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Search */}
|
||||
<div className="space-y-2">
|
||||
<Label>Search</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Name, email, or subject..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{contactCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submissions List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading submissions...</p>
|
||||
</div>
|
||||
) : submissions && submissions.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((submission) => (
|
||||
<Card
|
||||
key={submission.id}
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSubmission(submission);
|
||||
setAdminNotes(submission.admin_notes || '');
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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>
|
||||
{getStatusBadge(submission.status)}
|
||||
<Badge variant="outline">{getCategoryLabel(submission.category)}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{submission.name} ({submission.email})
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(new Date(submission.created_at), 'MMM d, yyyy h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{submission.message}</p>
|
||||
</div>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center">
|
||||
<Mail className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No submissions found</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Submission Detail Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedSubmission}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedSubmission(null);
|
||||
setAdminNotes('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
{selectedSubmission && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{selectedSubmission.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submitted {format(new Date(selectedSubmission.created_at), 'PPpp')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Sender Info */}
|
||||
<div className="space-y-2">
|
||||
<Label>From</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{selectedSubmission.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({selectedSubmission.email})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<div>{getCategoryLabel(selectedSubmission.category)}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<div>{getStatusBadge(selectedSubmission.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<Label>Message</Label>
|
||||
<div className="p-4 bg-muted rounded-lg whitespace-pre-wrap">
|
||||
{selectedSubmission.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing Admin Notes */}
|
||||
{selectedSubmission.admin_notes && (
|
||||
<div className="space-y-2">
|
||||
<Label>Previous Admin Notes</Label>
|
||||
<div className="p-4 bg-muted rounded-lg whitespace-pre-wrap">
|
||||
{selectedSubmission.admin_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Notes Input */}
|
||||
<div className="space-y-2">
|
||||
<Label>Add/Update Admin Notes</Label>
|
||||
<Textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder="Add internal notes about this submission..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleUpdateStatus('in_progress')}
|
||||
disabled={
|
||||
updateStatusMutation.isPending ||
|
||||
selectedSubmission.status === 'in_progress'
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Mark In Progress
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleUpdateStatus('resolved')}
|
||||
disabled={
|
||||
updateStatusMutation.isPending ||
|
||||
selectedSubmission.status === 'resolved'
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Mark Resolved
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleUpdateStatus('closed')}
|
||||
disabled={
|
||||
updateStatusMutation.isPending ||
|
||||
selectedSubmission.status === 'closed'
|
||||
}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="ml-auto"
|
||||
>
|
||||
<a href={`mailto:${selectedSubmission.email}`}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Email Sender
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
supabase/functions/send-contact-message/index.ts
Normal file
267
supabase/functions/send-contact-message/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { edgeLogger } from "../_shared/logger.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface ContactSubmission {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
category: 'general' | 'moderation' | 'technical' | 'account' | 'partnership' | 'report' | 'other';
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
const body: ContactSubmission = await req.json();
|
||||
const { name, email, subject, message, category, captchaToken } = body;
|
||||
|
||||
edgeLogger.info('Contact form submission received', {
|
||||
requestId,
|
||||
email,
|
||||
category
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !subject || !message || !category) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Missing required fields' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Validate field lengths
|
||||
if (name.length < 2 || name.length > 100) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Name must be between 2 and 100 characters' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
if (subject.length < 5 || subject.length > 200) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Subject must be between 5 and 200 characters' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
if (message.length < 20 || message.length > 2000) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Message must be between 20 and 2000 characters' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Invalid email address' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Validate category
|
||||
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
||||
if (!validCategories.includes(category)) {
|
||||
return createErrorResponse(
|
||||
{ message: 'Invalid category' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Get user agent and create IP hash
|
||||
const userAgent = req.headers.get('user-agent') || 'Unknown';
|
||||
const clientIP = req.headers.get('x-forwarded-for') || 'Unknown';
|
||||
const ipHash = clientIP !== 'Unknown'
|
||||
? await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientIP + 'thrillwiki_salt'))
|
||||
.then(buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''))
|
||||
: null;
|
||||
|
||||
// Initialize Supabase client with service role for rate limiting and insertion
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Check rate limiting (max 3 submissions per email per hour)
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
const { data: recentSubmissions, error: rateLimitError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id')
|
||||
.eq('email', email)
|
||||
.gte('created_at', oneHourAgo);
|
||||
|
||||
if (rateLimitError) {
|
||||
edgeLogger.error('Rate limit check failed', { requestId, error: rateLimitError.message });
|
||||
} else if (recentSubmissions && recentSubmissions.length >= 3) {
|
||||
edgeLogger.warn('Rate limit exceeded', { requestId, email });
|
||||
return createErrorResponse(
|
||||
{ message: 'Too many submissions. Please wait an hour before submitting again.' },
|
||||
429,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Get user ID if authenticated
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
let userId: string | null = null;
|
||||
|
||||
if (authHeader) {
|
||||
const supabaseClient = createClient(
|
||||
supabaseUrl,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
);
|
||||
|
||||
const { data: { user } } = await supabaseClient.auth.getUser();
|
||||
userId = user?.id || null;
|
||||
}
|
||||
|
||||
// Insert contact submission
|
||||
const { data: submission, error: insertError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: name.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
subject: subject.trim(),
|
||||
message: message.trim(),
|
||||
category,
|
||||
user_agent: userAgent,
|
||||
ip_address_hash: ipHash,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
edgeLogger.error('Failed to insert contact submission', {
|
||||
requestId,
|
||||
error: insertError.message
|
||||
});
|
||||
return createErrorResponse(insertError, 500, corsHeaders);
|
||||
}
|
||||
|
||||
edgeLogger.info('Contact submission created successfully', {
|
||||
requestId,
|
||||
submissionId: submission.id
|
||||
});
|
||||
|
||||
// Send notification email to admin (async, don't wait)
|
||||
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admin@thrillwiki.com';
|
||||
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
||||
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
||||
|
||||
if (forwardEmailKey) {
|
||||
// Send admin notification
|
||||
fetch('https://api.forwardemail.net/v1/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `New Contact Form Submission - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
||||
text: `A new contact message has been received:
|
||||
|
||||
From: ${name} (${email})
|
||||
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
Subject: ${subject}
|
||||
|
||||
Message:
|
||||
${message}
|
||||
|
||||
Reference ID: ${submission.id}
|
||||
Submitted: ${new Date(submission.created_at).toLocaleString()}
|
||||
|
||||
View in admin panel: https://thrillwiki.com/admin/contact`,
|
||||
}),
|
||||
}).catch(err => {
|
||||
edgeLogger.error('Failed to send admin notification', { requestId, error: err.message });
|
||||
});
|
||||
|
||||
// Send user confirmation email
|
||||
fetch('https://api.forwardemail.net/v1/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: "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:
|
||||
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
Subject: ${subject}
|
||||
|
||||
Reference ID: ${submission.id}
|
||||
|
||||
Our support team will review your message and get back to you as soon as possible.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team`,
|
||||
}),
|
||||
}).catch(err => {
|
||||
edgeLogger.error('Failed to send confirmation email', { requestId, error: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
edgeLogger.info('Contact submission processed successfully', {
|
||||
requestId,
|
||||
duration,
|
||||
submissionId: submission.id
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
submissionId: submission.id,
|
||||
message: 'Your message has been received. We will respond within 24-48 hours.'
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
edgeLogger.error('Contact submission failed', {
|
||||
requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return createErrorResponse(error, 500, corsHeaders);
|
||||
}
|
||||
};
|
||||
|
||||
serve(handler);
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Create contact submissions table
|
||||
CREATE TABLE IF NOT EXISTS public.contact_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sender Information
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
|
||||
-- Message Details
|
||||
subject TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other')),
|
||||
|
||||
-- Admin Management
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'resolved', 'closed')),
|
||||
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
admin_notes TEXT,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Metadata
|
||||
user_agent TEXT,
|
||||
ip_address_hash TEXT
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_status ON public.contact_submissions(status) WHERE status IN ('pending', 'in_progress');
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_created_at ON public.contact_submissions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_user_id ON public.contact_submissions(user_id) WHERE user_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_assigned_to ON public.contact_submissions(assigned_to) WHERE assigned_to IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_email ON public.contact_submissions(email);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.contact_submissions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Anyone can submit contact form (including non-authenticated users)
|
||||
CREATE POLICY "Anyone can submit contact form"
|
||||
ON public.contact_submissions
|
||||
FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Users can view their own submissions (by user_id or email)
|
||||
CREATE POLICY "Users can view own contact submissions"
|
||||
ON public.contact_submissions
|
||||
FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR (auth.uid() IS NOT NULL AND email = (SELECT email FROM auth.users WHERE id = auth.uid()))
|
||||
);
|
||||
|
||||
-- Moderators/Admins can view all submissions
|
||||
CREATE POLICY "Moderators can view all contact submissions"
|
||||
ON public.contact_submissions
|
||||
FOR SELECT
|
||||
USING (is_moderator(auth.uid()));
|
||||
|
||||
-- Moderators/Admins can update submissions (for management)
|
||||
CREATE POLICY "Moderators can update contact submissions"
|
||||
ON public.contact_submissions
|
||||
FOR UPDATE
|
||||
USING (is_moderator(auth.uid()) AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2()))
|
||||
WITH CHECK (is_moderator(auth.uid()) AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2()));
|
||||
|
||||
-- Create trigger to update updated_at
|
||||
CREATE OR REPLACE FUNCTION public.update_contact_submissions_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER update_contact_submissions_updated_at
|
||||
BEFORE UPDATE ON public.contact_submissions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_contact_submissions_updated_at();
|
||||
|
||||
-- Create contact rate limiting table
|
||||
CREATE TABLE IF NOT EXISTS public.contact_rate_limits (
|
||||
email TEXT PRIMARY KEY,
|
||||
submission_count INTEGER NOT NULL DEFAULT 1,
|
||||
window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_submission_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable RLS on rate limits (only system can access)
|
||||
ALTER TABLE public.contact_rate_limits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- No public access to rate limits table
|
||||
CREATE POLICY "No public access to rate limits"
|
||||
ON public.contact_rate_limits
|
||||
FOR ALL
|
||||
USING (false);
|
||||
Reference in New Issue
Block a user