diff --git a/src/components/history/FormerNamesSection.tsx b/src/components/history/FormerNamesSection.tsx index 883eb12f..d5ef0e65 100644 --- a/src/components/history/FormerNamesSection.tsx +++ b/src/components/history/FormerNamesSection.tsx @@ -13,7 +13,7 @@ interface FormerName { interface FormerNamesSectionProps { currentName: string; formerNames: FormerName[]; - entityType: 'ride' | 'park' | 'company'; + entityType: 'ride' | 'park' | 'company' | 'ride_model'; } export function FormerNamesSection({ currentName, formerNames, entityType }: FormerNamesSectionProps) { diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index e73422bd..165ee277 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -548,6 +548,81 @@ export async function submitRideModelCreation( return { submitted: true, submissionId: submissionData.id }; } +/** + * ⚠️ CRITICAL SECURITY PATTERN ⚠️ + * + * Submits a ride model update through the moderation queue. + * This is the ONLY correct way to update ride models. + */ +export async function submitRideModelUpdate( + rideModelId: string, + data: RideModelFormData, + userId: string +): Promise<{ submitted: boolean; submissionId: string }> { + // Fetch existing ride model + const { data: existingModel, error: fetchError } = await supabase + .from('ride_models') + .select('*') + .eq('id', rideModelId) + .single(); + + if (fetchError) throw new Error(`Failed to fetch ride model: ${fetchError.message}`); + if (!existingModel) throw new Error('Ride model not found'); + + // Upload any pending local images first + let processedImages = data.images; + if (data.images?.uploaded && data.images.uploaded.length > 0) { + try { + const uploadedImages = await uploadPendingImages(data.images.uploaded); + processedImages = { + ...data.images, + uploaded: uploadedImages + }; + } catch (error) { + console.error('Failed to upload images for ride model update:', error); + throw new Error('Failed to upload images. Please check your connection and try again.'); + } + } + + // Create the main submission record + const { data: submissionData, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'ride_model', + content: { + action: 'edit', + ride_model_id: rideModelId + }, + status: 'pending' + }) + .select() + .single(); + + if (submissionError) throw submissionError; + + // Create the submission item with actual ride model data + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'ride_model', + action_type: 'edit', + item_data: { + ...data, + ride_model_id: rideModelId, + images: processedImages as unknown as Json + }, + original_data: JSON.parse(JSON.stringify(existingModel)), + status: 'pending', + order_index: 0 + }); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; +} + /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * diff --git a/src/pages/RideModelDetail.tsx b/src/pages/RideModelDetail.tsx index 915fb636..bf89c1bc 100644 --- a/src/pages/RideModelDetail.tsx +++ b/src/pages/RideModelDetail.tsx @@ -5,32 +5,30 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { ArrowLeft, FerrisWheel, Building2, Filter, SlidersHorizontal } from 'lucide-react'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { ArrowLeft, FerrisWheel, Building2, Edit } from 'lucide-react'; import { RideModel, Ride, Company } from '@/types/database'; import { supabase } from '@/integrations/supabase/client'; -import { RideCard } from '@/components/rides/RideCard'; -import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; - -interface RideModelWithImages extends RideModel { - card_image_url?: string; - card_image_id?: string; - banner_image_url?: string; - banner_image_id?: string; - technical_specs?: Record; -} +import { useAuthModal } from '@/hooks/useAuthModal'; +import { useAuth } from '@/hooks/useAuth'; +import { toast } from '@/hooks/use-toast'; +import { RideModelForm } from '@/components/admin/RideModelForm'; +import { ManufacturerPhotoGallery } from '@/components/companies/ManufacturerPhotoGallery'; +import { VersionIndicator } from '@/components/versioning/VersionIndicator'; +import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory'; export default function RideModelDetail() { const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>(); const navigate = useNavigate(); - const [model, setModel] = useState(null); + const { user } = useAuth(); + const { requireAuth } = useAuthModal(); + const [model, setModel] = useState(null); const [manufacturer, setManufacturer] = useState(null); const [rides, setRides] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [sortBy, setSortBy] = useState('name'); - const [filterStatus, setFilterStatus] = useState('all'); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [statistics, setStatistics] = useState({ rideCount: 0, photoCount: 0 }); const fetchData = useCallback(async () => { try { @@ -55,43 +53,35 @@ export default function RideModelDetail() { .maybeSingle(); if (modelError) throw modelError; - setModel(modelData as RideModelWithImages); + setModel(modelData as RideModel); if (modelData) { - // Fetch rides using this model - let query = supabase + // Fetch rides using this model with proper joins + const { data: ridesData, error: ridesError } = await supabase .from('rides') .select(` *, park:parks!inner(name, slug, location:locations(*)), - manufacturer:companies!rides_manufacturer_id_fkey(*) + manufacturer:companies!rides_manufacturer_id_fkey(*), + ride_model:ride_models(id, name, slug, manufacturer_id, category) `) - .eq('ride_model_id', modelData.id); + .eq('ride_model_id', modelData.id) + .order('name'); - if (filterStatus !== 'all') { - query = query.eq('status', filterStatus); - } - - switch (sortBy) { - case 'rating': - query = query.order('average_rating', { ascending: false }); - break; - case 'speed': - query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false }); - break; - case 'height': - query = query.order('max_height_meters', { ascending: false, nullsFirst: false }); - break; - case 'reviews': - query = query.order('review_count', { ascending: false }); - break; - default: - query = query.order('name'); - } - - const { data: ridesData, error: ridesError } = await query; if (ridesError) throw ridesError; - setRides(ridesData || []); + setRides(ridesData as Ride[] || []); + + // Fetch statistics + const { count: photoCount } = await supabase + .from('photos') + .select('*', { count: 'exact', head: true }) + .eq('entity_type', 'ride_model') + .eq('entity_id', modelData.id); + + setStatistics({ + rideCount: ridesData?.length || 0, + photoCount: photoCount || 0 + }); } } } catch (error) { @@ -99,7 +89,7 @@ export default function RideModelDetail() { } finally { setLoading(false); } - }, [manufacturerSlug, modelSlug, sortBy, filterStatus]); + }, [manufacturerSlug, modelSlug]); useEffect(() => { if (manufacturerSlug && modelSlug) { @@ -107,10 +97,33 @@ export default function RideModelDetail() { } }, [manufacturerSlug, modelSlug, fetchData]); - const filteredRides = rides.filter(ride => - ride.name.toLowerCase().includes(searchQuery.toLowerCase()) || - ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const handleEditSubmit = async (data: any) => { + try { + if (!user || !model) return; + + const submissionData = { + ...data, + manufacturer_id: model.manufacturer_id, + }; + + const { submitRideModelUpdate } = await import('@/lib/entitySubmissionHelpers'); + await submitRideModelUpdate(model.id, submissionData, user.id); + + toast({ + title: "Ride Model Updated", + description: "Your changes have been submitted for review." + }); + + setIsEditModalOpen(false); + fetchData(); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Failed to update ride model.", + variant: "destructive" + }); + } + }; const formatCategory = (category: string | null | undefined) => { if (!category) return 'Unknown'; @@ -126,14 +139,6 @@ export default function RideModelDetail() { ).join(' '); }; - const statusOptions = [ - { value: 'all', label: 'All Status' }, - { value: 'operating', label: 'Operating' }, - { value: 'seasonal', label: 'Seasonal' }, - { value: 'under_construction', label: 'Under Construction' }, - { value: 'closed', label: 'Closed' } - ]; - if (loading) { return (
@@ -175,11 +180,19 @@ export default function RideModelDetail() {
-
+
+ +
{/* Hero Section */} @@ -199,6 +212,12 @@ export default function RideModelDetail() {

{model.name}

+
@@ -218,11 +237,13 @@ export default function RideModelDetail() { {formatCategory(model.category)} + {model.ride_type && ( + + {formatRideType(model.ride_type)} + + )} - {formatRideType(model.ride_type)} - - - {rides.length} {rides.length === 1 ? 'ride' : 'rides'} + {statistics.rideCount} {statistics.rideCount === 1 ? 'ride' : 'rides'}
@@ -230,7 +251,9 @@ export default function RideModelDetail() { Overview - Rides ({rides.length}) + Rides ({statistics.rideCount}) + Photos ({statistics.photoCount}) + History @@ -260,66 +283,67 @@ export default function RideModelDetail() { )} - -
-
- setSearchQuery(query)} - showRecentSearches={false} - /> -
-
- - - -
-
- - {filteredRides.length > 0 ? ( -
- {filteredRides.map((ride) => ( - - ))} -
- ) : ( -
- -

No rides found

+ + + +
+

Rides

+ +

- No rides match your search criteria + View all {statistics.rideCount} rides using the {model.name} model

-
- )} + + +
+ + + + + + + + + + +
+ + {/* Edit Modal */} + + + setIsEditModalOpen(false)} + /> + +
); -} +} \ No newline at end of file diff --git a/src/pages/RideModelRides.tsx b/src/pages/RideModelRides.tsx index 55893ccf..033bbc1c 100644 --- a/src/pages/RideModelRides.tsx +++ b/src/pages/RideModelRides.tsx @@ -1,35 +1,37 @@ import { useParams, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { supabase } from "@/integrations/supabase/client"; import { Header } from "@/components/layout/Header"; import { RideCard } from "@/components/rides/RideCard"; import { Button } from "@/components/ui/button"; -import { ArrowLeft } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { ArrowLeft, Filter, SlidersHorizontal, Plus, FerrisWheel } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; -import type { Ride, Company } from "@/types/database"; - -interface RideModel { - id: string; - name: string; - slug: string; - manufacturer_id: string; -} +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; +import { RideForm } from '@/components/admin/RideForm'; +import { useAuth } from '@/hooks/useAuth'; +import { useAuthModal } from '@/hooks/useAuthModal'; +import { toast } from '@/hooks/use-toast'; +import type { Ride, Company, RideModel } from "@/types/database"; export default function RideModelRides() { const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>(); const navigate = useNavigate(); + const { user } = useAuth(); + const { requireAuth } = useAuthModal(); const [model, setModel] = useState(null); const [manufacturer, setManufacturer] = useState(null); const [rides, setRides] = useState([]); const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [filterCategory, setFilterCategory] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - useEffect(() => { - if (manufacturerSlug && modelSlug) { - fetchData(); - } - }, [manufacturerSlug, modelSlug]); - - const fetchData = async () => { + const fetchData = useCallback(async () => { try { setLoading(true); @@ -61,23 +63,92 @@ export default function RideModelRides() { return; } - // Fetch rides for this model - const { data: ridesData, error: ridesError } = await supabase + // Enhanced query with filters and sort + let query = supabase .from("rides") - .select("*") - .eq("ride_model_id", modelData.id) - .order("name"); + .select(` + *, + park:parks!inner(name, slug, location:locations(*)), + manufacturer:companies!rides_manufacturer_id_fkey(*), + ride_model:ride_models(id, name, slug, manufacturer_id, category) + `) + .eq("ride_model_id", modelData.id); + if (filterCategory !== 'all') { + query = query.eq('category', filterCategory); + } + if (filterStatus !== 'all') { + query = query.eq('status', filterStatus); + } + + switch (sortBy) { + case 'rating': + query = query.order('average_rating', { ascending: false }); + break; + case 'speed': + query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false }); + break; + case 'height': + query = query.order('height_meters', { ascending: false, nullsFirst: false }); + break; + case 'reviews': + query = query.order('review_count', { ascending: false }); + break; + default: + query = query.order('name'); + } + + const { data: ridesData, error: ridesError } = await query; if (ridesError) throw ridesError; setManufacturer(manufacturerData); - setModel(modelData); - setRides(ridesData || []); + setModel(modelData as RideModel); + setRides(ridesData as Ride[] || []); } catch (error) { console.error("Error fetching data:", error); } finally { setLoading(false); } + }, [manufacturerSlug, modelSlug, sortBy, filterCategory, filterStatus]); + + useEffect(() => { + if (manufacturerSlug && modelSlug) { + fetchData(); + } + }, [manufacturerSlug, modelSlug, fetchData]); + + const filteredRides = rides.filter(ride => + ride.name.toLowerCase().includes(searchQuery.toLowerCase()) || + ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleCreateSubmit = async (data: any) => { + try { + if (!user || !model) return; + + const submissionData = { + ...data, + ride_model_id: model.id, + manufacturer_id: manufacturer!.id, + }; + + const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers'); + await submitRideCreation(submissionData, user.id); + + toast({ + title: "Ride Submitted", + description: "Your ride submission has been sent for review." + }); + + setIsCreateModalOpen(false); + fetchData(); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Failed to submit ride.", + variant: "destructive" + }); + } }; if (loading) { @@ -86,8 +157,8 @@ export default function RideModelRides() {
-
- {[...Array(6)].map((_, i) => ( +
+ {[...Array(8)].map((_, i) => ( ))}
@@ -121,26 +192,108 @@ export default function RideModelRides() {
-

- {model.name} Installations -

-

- All rides based on the {model.name} by {manufacturer.name} +

+
+ +

{model.name} Installations

+
+ +
+

+ All rides using the {model.name} by {manufacturer.name}

+ {filteredRides.length} rides
- {rides.length === 0 ? ( -

- No rides found for this model. -

- ) : ( -
- {rides.map((ride) => ( - +
+
+
+ setSearchQuery(query)} + showRecentSearches={false} + /> +
+
+ + + + + +
+
+
+ + {filteredRides.length > 0 ? ( +
+ {filteredRides.map((ride) => ( + ))}
+ ) : ( +
+ +

No rides found

+

+ No rides match your search criteria for the {model.name} +

+
)} + + {/* Create Modal */} + + + setIsCreateModalOpen(false)} + /> + +
); -} +} \ No newline at end of file diff --git a/src/types/database.ts b/src/types/database.ts index 713b18cb..e2a795f7 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -113,11 +113,19 @@ export interface RideModel { id: string; name: string; slug: string; + manufacturer_id: string; manufacturer?: Company; category: 'roller_coaster' | 'flat_ride' | 'water_ride' | 'dark_ride' | 'kiddie_ride' | 'transportation'; - ride_type: string; + ride_type?: string; description?: string; + technical_specs?: Record; technical_specifications?: RideModelTechnicalSpec[]; + banner_image_url?: string; + banner_image_id?: string; + card_image_url?: string; + card_image_id?: string; + created_at?: string; + updated_at?: string; } export interface Ride { diff --git a/src/types/timeline.ts b/src/types/timeline.ts index 142af1e3..94bad60d 100644 --- a/src/types/timeline.ts +++ b/src/types/timeline.ts @@ -20,7 +20,7 @@ export type TimelineEventType = | 'milestone' | 'other'; -export type EntityType = 'park' | 'ride' | 'company'; +export type EntityType = 'park' | 'ride' | 'company' | 'ride_model'; export type DatePrecision = 'day' | 'month' | 'year';