mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 10:51:12 -05:00
feat: Implement contact page and backend
This commit is contained in:
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"
|
||||
|
||||
Reference in New Issue
Block a user