diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index e8ddeaff..d06ce6d9 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -22,7 +22,11 @@ import { Combobox } from '@/components/ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData'; import { useUserRole } from '@/hooks/useUserRole'; +import { useAuth } from '@/hooks/useAuth'; +import type { TempCompanyData } from '@/types/company'; import { LocationSearch } from './LocationSearch'; +import { OperatorForm } from './OperatorForm'; +import { PropertyOwnerForm } from './PropertyOwnerForm'; const parkSchema = z.object({ name: z.string().min(1, 'Park name is required'), @@ -112,14 +116,16 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: validateSubmissionHandler(onSubmit, 'park'); }, [onSubmit]); + const { user } = useAuth(); + // Operator state const [selectedOperatorId, setSelectedOperatorId] = useState(initialData?.operator_id || ''); - const [tempNewOperator, setTempNewOperator] = useState<{ name: string; slug: string; company_type: string } | null>(null); + const [tempNewOperator, setTempNewOperator] = useState(null); const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false); // Property Owner state const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState(initialData?.property_owner_id || ''); - const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<{ name: string; slug: string; company_type: string } | null>(null); + const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState(null); const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false); // Fetch data @@ -488,29 +494,33 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: - {/* Operator Modal - Placeholder */} + {/* Operator Modal */} - - - Create New Operator - - Add a new park operator company - - -

Operator form coming soon...

+ + { + setTempNewOperator(data); + setIsOperatorModalOpen(false); + setValue('operator_id', 'temp-operator'); + }} + onCancel={() => setIsOperatorModalOpen(false)} + />
- {/* Property Owner Modal - Placeholder */} + {/* Property Owner Modal */} - - - Create New Property Owner - - Add a new park property owner company - - -

Property owner form coming soon...

+ + { + setTempNewPropertyOwner(data); + setIsPropertyOwnerModalOpen(false); + setValue('property_owner_id', 'temp-property-owner'); + }} + onCancel={() => setIsPropertyOwnerModalOpen(false)} + />
diff --git a/src/components/profile/AddRideCreditDialog.tsx b/src/components/profile/AddRideCreditDialog.tsx new file mode 100644 index 00000000..b85335b5 --- /dev/null +++ b/src/components/profile/AddRideCreditDialog.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; +import { getErrorMessage } from '@/lib/errorHandler'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar as CalendarIcon } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; + +interface AddRideCreditDialogProps { + userId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +export function AddRideCreditDialog({ userId, open, onOpenChange, onSuccess }: AddRideCreditDialogProps) { + const [selectedRideId, setSelectedRideId] = useState(''); + const [firstRideDate, setFirstRideDate] = useState(undefined); + const [rideCount, setRideCount] = useState(1); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedRideId) { + toast.error('Please select a ride'); + return; + } + + try { + setSubmitting(true); + + // Check if credit already exists + const { data: existing } = await supabase + .from('user_ride_credits') + .select('id') + .eq('user_id', userId) + .eq('ride_id', selectedRideId) + .maybeSingle(); + + if (existing) { + toast.error('You already have a credit for this ride'); + return; + } + + const { error } = await supabase + .from('user_ride_credits') + .insert({ + user_id: userId, + ride_id: selectedRideId, + first_ride_date: firstRideDate ? format(firstRideDate, 'yyyy-MM-dd') : null, + ride_count: rideCount + }); + + if (error) throw error; + + toast.success('Ride credit added!'); + onSuccess(); + handleReset(); + } catch (error) { + console.error('Error adding credit:', error); + toast.error(getErrorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + const handleReset = () => { + setSelectedRideId(''); + setFirstRideDate(undefined); + setRideCount(1); + }; + + return ( + + + + Add Ride Credit + + Log a ride you've experienced + + + +
+
+ + { + if (result.type === 'ride') { + setSelectedRideId(result.id); + } + }} + types={['ride']} + placeholder="Search rides..." + className="w-full" + /> +
+ +
+ + + + + + + date > new Date()} + /> + + +
+ +
+ + setRideCount(parseInt(e.target.value) || 1)} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/profile/RideCreditCard.tsx b/src/components/profile/RideCreditCard.tsx new file mode 100644 index 00000000..f8d69543 --- /dev/null +++ b/src/components/profile/RideCreditCard.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Calendar, MapPin, Edit, Trash2, Plus, Minus, Check, X } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { format } from 'date-fns'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; +import { getErrorMessage } from '@/lib/errorHandler'; +import { UserRideCredit } from '@/types/database'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface RideCreditCardProps { + credit: UserRideCredit; + viewMode: 'grid' | 'list'; + onUpdate: () => void; + onDelete: () => void; +} + +export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCreditCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [editCount, setEditCount] = useState(credit.ride_count); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [updating, setUpdating] = useState(false); + + const rideName = credit.rides?.name || 'Unknown Ride'; + const parkName = credit.rides?.parks?.name || 'Unknown Park'; + const parkSlug = credit.rides?.parks?.slug || ''; + const rideSlug = credit.rides?.slug || ''; + const imageUrl = credit.rides?.card_image_url; + const category = credit.rides?.category || ''; + + const handleUpdateCount = async () => { + try { + setUpdating(true); + const { error } = await supabase + .from('user_ride_credits') + .update({ ride_count: editCount }) + .eq('id', credit.id); + + if (error) throw error; + + toast.success('Ride count updated'); + setIsEditing(false); + onUpdate(); + } catch (error) { + console.error('Error updating count:', error); + toast.error(getErrorMessage(error)); + } finally { + setUpdating(false); + } + }; + + const handleQuickIncrement = async () => { + try { + const newCount = credit.ride_count + 1; + const { error } = await supabase + .from('user_ride_credits') + .update({ ride_count: newCount }) + .eq('id', credit.id); + + if (error) throw error; + + toast.success('Ride count increased'); + onUpdate(); + } catch (error) { + console.error('Error incrementing count:', error); + toast.error(getErrorMessage(error)); + } + }; + + const getCategoryBadge = (category: string) => { + const categoryMap: Record = { + roller_coaster: { label: 'Coaster', variant: 'default' }, + flat_ride: { label: 'Flat Ride', variant: 'secondary' }, + water_ride: { label: 'Water Ride', variant: 'outline' }, + dark_ride: { label: 'Dark Ride', variant: 'outline' }, + }; + const info = categoryMap[category] || { label: category, variant: 'outline' as const }; + return {info.label}; + }; + + const ridePath = parkSlug && rideSlug + ? `/parks/${parkSlug}/rides/${rideSlug}` + : '#'; + + if (viewMode === 'list') { + return ( + + +
+ {imageUrl && ( + {rideName} + )} + +
+
+ + {rideName} + + {getCategoryBadge(category)} +
+ + + + {parkName} + + + {credit.first_ride_date && ( +
+ + First ride: {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} +
+ )} +
+ +
+ {isEditing ? ( + <> + setEditCount(parseInt(e.target.value) || 1)} + className="w-20" + min="1" + /> + + + + ) : ( + <> +
+
{credit.ride_count}
+
+ {credit.ride_count === 1 ? 'ride' : 'rides'} +
+
+ + + + + )} +
+
+
+ + + + + Delete Ride Credit? + + This will remove {rideName} from your ride credits. This action cannot be undone. + + + + Cancel + Delete + + + +
+ ); + } + + // Grid view + return ( + + {imageUrl && ( +
+ {rideName} +
+ )} + + +
+
+
+ + {rideName} + + {getCategoryBadge(category)} +
+ + + + {parkName} + +
+ + {credit.first_ride_date && ( +
+ + {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} +
+ )} + +
+ {isEditing ? ( +
+ setEditCount(parseInt(e.target.value) || 1)} + className="w-20" + min="1" + /> + + +
+ ) : ( + <> +
+ {credit.ride_count} + + {credit.ride_count === 1 ? 'ride' : 'rides'} + +
+ +
+ + + +
+ + )} +
+
+
+ + + + + Delete Ride Credit? + + This will remove {rideName} from your ride credits. This action cannot be undone. + + + + Cancel + Delete + + + +
+ ); +} diff --git a/src/components/profile/RideCreditsManager.tsx b/src/components/profile/RideCreditsManager.tsx new file mode 100644 index 00000000..0678ca3b --- /dev/null +++ b/src/components/profile/RideCreditsManager.tsx @@ -0,0 +1,249 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Plus, LayoutGrid, List } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; +import { getErrorMessage } from '@/lib/errorHandler'; +import { AddRideCreditDialog } from './AddRideCreditDialog'; +import { RideCreditCard } from './RideCreditCard'; +import { UserRideCredit } from '@/types/database'; + +interface RideCreditsManagerProps { + userId: string; +} + +export function RideCreditsManager({ userId }: RideCreditsManagerProps) { + const [credits, setCredits] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [sortBy, setSortBy] = useState<'date' | 'count' | 'name'>('date'); + const [filterCategory, setFilterCategory] = useState('all'); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + + useEffect(() => { + fetchCredits(); + }, [userId, sortBy, filterCategory]); + + const fetchCredits = async () => { + try { + setLoading(true); + let query = supabase + .from('user_ride_credits') + .select(` + *, + rides:ride_id ( + id, + name, + slug, + category, + card_image_url, + parks:park_id ( + id, + name, + slug + ) + ) + `) + .eq('user_id', userId); + + if (filterCategory !== 'all') { + query = query.eq('rides.category', filterCategory); + } + + if (sortBy === 'date') { + query = query.order('first_ride_date', { ascending: false, nullsFirst: false }); + } else if (sortBy === 'count') { + query = query.order('ride_count', { ascending: false }); + } else if (sortBy === 'name') { + query = query.order('rides(name)', { ascending: true }); + } + + const { data, error } = await query; + if (error) throw error; + + // Type assertion since the query returns properly structured data + setCredits((data || []) as UserRideCredit[]); + } catch (error) { + console.error('Error fetching ride credits:', error); + toast.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const handleCreditAdded = () => { + fetchCredits(); + setIsAddDialogOpen(false); + }; + + const handleCreditUpdated = () => { + fetchCredits(); + }; + + const handleCreditDeleted = async (creditId: string) => { + try { + const { error } = await supabase + .from('user_ride_credits') + .delete() + .eq('id', creditId) + .eq('user_id', userId); + + if (error) throw error; + + toast.success('Ride credit removed'); + fetchCredits(); + } catch (error) { + console.error('Error deleting credit:', error); + toast.error(getErrorMessage(error)); + } + }; + + const stats = { + totalRides: credits.reduce((sum, c) => sum + c.ride_count, 0), + uniqueCredits: credits.length, + parksVisited: new Set(credits.map(c => c.rides?.parks?.id).filter(Boolean)).size, + coasters: credits.filter(c => c.rides?.category === 'roller_coaster').length + }; + + if (loading) { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + +
+
+
+ ))} +
+ ); + } + + return ( +
+ {/* Statistics */} +
+ + +
{stats.totalRides}
+
Total Rides
+
+
+ + +
{stats.uniqueCredits}
+
Unique Credits
+
+
+ + +
{stats.parksVisited}
+
Parks Visited
+
+
+ + +
{stats.coasters}
+
Coasters
+
+
+
+ + {/* Controls */} +
+
+ +
+ +
+ + + + +
+ + +
+
+
+ + {/* Credits Display */} + {credits.length === 0 ? ( + + +
+

No Ride Credits Yet

+

+ Start tracking your ride experiences +

+ +
+
+
+ ) : ( +
+ {credits.map((credit) => ( + handleCreditDeleted(credit.id)} + /> + ))} +
+ )} + + {/* Add Credit Dialog */} + +
+ ); +} diff --git a/src/components/profile/UserReviewsList.tsx b/src/components/profile/UserReviewsList.tsx new file mode 100644 index 00000000..963b5c89 --- /dev/null +++ b/src/components/profile/UserReviewsList.tsx @@ -0,0 +1,321 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Star, Calendar, MapPin, Edit, Trash2, ThumbsUp } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { StarRating } from '@/components/reviews/StarRating'; +import { Button } from '@/components/ui/button'; +import { Link } from 'react-router-dom'; +import { toast } from 'sonner'; +import { getErrorMessage } from '@/lib/errorHandler'; + +interface ReviewWithEntity { + id: string; + rating: number; + title: string | null; + content: string | null; + visit_date: string | null; + wait_time_minutes: number | null; + helpful_votes: number; + moderation_status: string; + created_at: string; + parks?: { + id: string; + name: string; + slug: string; + } | null; + rides?: { + id: string; + name: string; + slug: string; + parks?: { + name: string; + slug: string; + } | null; + } | null; +} + +interface UserReviewsListProps { + userId: string; + reviewCount: number; +} + +export function UserReviewsList({ userId, reviewCount }: UserReviewsListProps) { + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'all' | 'parks' | 'rides'>('all'); + const [sortBy, setSortBy] = useState<'date' | 'rating'>('date'); + + useEffect(() => { + fetchReviews(); + }, [userId, filter, sortBy]); + + const fetchReviews = async () => { + try { + setLoading(true); + let query = supabase + .from('reviews') + .select(` + id, + rating, + title, + content, + visit_date, + wait_time_minutes, + helpful_votes, + moderation_status, + created_at, + parks:park_id (id, name, slug), + rides:ride_id ( + id, + name, + slug, + parks:park_id (name, slug) + ) + `) + .eq('user_id', userId); + + if (filter === 'parks') { + query = query.not('park_id', 'is', null); + } else if (filter === 'rides') { + query = query.not('ride_id', 'is', null); + } + + if (sortBy === 'date') { + query = query.order('created_at', { ascending: false }); + } else { + query = query.order('rating', { ascending: false }); + } + + const { data, error } = await query; + + if (error) throw error; + setReviews(data || []); + } catch (error) { + console.error('Error fetching reviews:', error); + toast.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'approved': + return Approved; + case 'pending': + return Pending Review; + case 'rejected': + return Rejected; + default: + return null; + } + }; + + if (loading) { + return ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (reviewCount === 0) { + return ( + + +
+ +

No Reviews Yet

+

+ Start sharing your park and ride experiences +

+
+ + +
+
+
+
+ ); + } + + const stats = { + total: reviews.length, + parks: reviews.filter(r => r.parks).length, + rides: reviews.filter(r => r.rides).length, + avgRating: reviews.length > 0 + ? (reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length).toFixed(1) + : '0' + }; + + return ( +
+ {/* Statistics */} +
+ + +
{stats.total}
+
Total Reviews
+
+
+ + +
{stats.parks}
+
Park Reviews
+
+
+ + +
{stats.rides}
+
Ride Reviews
+
+
+ + +
+ + {stats.avgRating} +
+
Avg Rating
+
+
+
+ + {/* Filters */} +
+ + + +
+ + {/* Reviews List */} +
+ {reviews.map((review) => { + const entityName = review.parks?.name || review.rides?.name || 'Unknown'; + const entitySlug = review.parks?.slug || review.rides?.slug || ''; + const entityType = review.parks ? 'park' : 'ride'; + const entityPath = review.parks + ? `/parks/${entitySlug}` + : review.rides + ? `/parks/${review.rides.parks?.slug}/rides/${entitySlug}` + : '#'; + + return ( + + +
+ {/* Header */} +
+
+
+ + {entityName} + + + {entityType === 'park' ? 'Park' : 'Ride'} + + {getStatusBadge(review.moderation_status)} +
+ {review.rides?.parks && ( + + at {review.rides.parks.name} + + )} +
+
+ + + {review.rating} + +
+
+ + {/* Title */} + {review.title && ( +

{review.title}

+ )} + + {/* Content */} + {review.content && ( +

+ {review.content} +

+ )} + + {/* Metadata */} +
+
+ + {formatDate(review.created_at)} +
+ {review.visit_date && ( +
+ + Visited {formatDate(review.visit_date)} +
+ )} + {review.wait_time_minutes && ( + Wait: {review.wait_time_minutes} min + )} +
+ + {review.helpful_votes} helpful +
+
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 5227d9d9..6b300107 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -14,6 +14,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; +import { UserReviewsList } from '@/components/profile/UserReviewsList'; +import { UserListManager } from '@/components/lists/UserListManager'; +import { RideCreditsManager } from '@/components/profile/RideCreditsManager'; import { useUsernameValidation } from '@/hooks/useUsernameValidation'; import { User, MapPin, Calendar, Star, Trophy, Settings, Camera, Edit3, Save, X, ArrowLeft, Check, AlertCircle, Loader2, UserX, FileText, Image } from 'lucide-react'; import { Profile as ProfileType, ActivityEntry, ReviewActivity, SubmissionActivity, RankingActivity } from '@/types/database'; @@ -795,61 +798,15 @@ export default function Profile() { - - - Reviews ({profile.review_count}) - - Parks and rides reviews - - - -
- -

Reviews Coming Soon

-

- User reviews and ratings will appear here -

-
-
-
+
- - - Rankings - Personal rankings of rides - - -
- -

Rankings Coming Soon

-

- Create and share your favorite park and ride rankings -

-
-
-
+
- - - Ride Credits - - Track all the rides you've experienced - - - -
- -

Ride Credits Coming Soon

-

- Log and track your ride experiences -

-
-
-
+
diff --git a/src/types/database.ts b/src/types/database.ts index a874fed4..c337c1e4 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -281,6 +281,18 @@ export interface AuditLogEntry { created_at: string; } +// User ride credit tracking +export interface UserRideCredit { + id: string; + user_id: string; + ride_id: string; + first_ride_date?: string; + ride_count: number; + created_at: string; + updated_at: string; + rides?: Ride & { parks?: Park }; +} + // Activity entry - discriminated union for different activity types export type ActivityEntry = | ReviewActivity