mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:11:13 -05:00
feat: Implement comprehensive plan
This commit is contained in:
207
src/components/admin/ManufacturerForm.tsx
Normal file
207
src/components/admin/ManufacturerForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Building2, Save, X } from 'lucide-react';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||
|
||||
const manufacturerSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
description: z.string().optional(),
|
||||
person_type: z.enum(['company', 'individual', 'firm', 'organization']),
|
||||
website_url: z.string().url().optional().or(z.literal('')),
|
||||
founded_year: z.number().min(1800).max(new Date().getFullYear()).optional(),
|
||||
headquarters_location: z.string().optional()
|
||||
});
|
||||
|
||||
type ManufacturerFormData = z.infer<typeof manufacturerSchema>;
|
||||
|
||||
interface ManufacturerFormProps {
|
||||
onSubmit: (data: ManufacturerFormData) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<ManufacturerFormData>;
|
||||
}
|
||||
|
||||
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) {
|
||||
const { headquarters } = useCompanyHeadquarters();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useForm<ManufacturerFormData>({
|
||||
resolver: zodResolver(manufacturerSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
slug: initialData?.slug || '',
|
||||
description: initialData?.description || '',
|
||||
person_type: initialData?.person_type || 'company',
|
||||
website_url: initialData?.website_url || '',
|
||||
founded_year: initialData?.founded_year || undefined,
|
||||
headquarters_location: initialData?.headquarters_location || ''
|
||||
}
|
||||
});
|
||||
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const name = e.target.value;
|
||||
const slug = generateSlug(name);
|
||||
setValue('slug', slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5" />
|
||||
{initialData ? 'Edit Manufacturer' : 'Create New Manufacturer'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
onChange={(e) => {
|
||||
register('name').onChange(e);
|
||||
handleNameChange(e);
|
||||
}}
|
||||
placeholder="Enter manufacturer name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
{...register('slug')}
|
||||
placeholder="manufacturer-slug"
|
||||
/>
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
placeholder="Describe the manufacturer..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Person Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Entity Type *</Label>
|
||||
<RadioGroup
|
||||
value={watch('person_type')}
|
||||
onValueChange={(value) => setValue('person_type', value as any)}
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="company" id="company" />
|
||||
<Label htmlFor="company" className="cursor-pointer">Company</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="individual" id="individual" />
|
||||
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="firm" id="firm" />
|
||||
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="organization" id="organization" />
|
||||
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Additional Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="founded_year">Founded Year</Label>
|
||||
<Input
|
||||
id="founded_year"
|
||||
type="number"
|
||||
min="1800"
|
||||
max={new Date().getFullYear()}
|
||||
{...register('founded_year', { valueAsNumber: true })}
|
||||
placeholder="e.g. 1972"
|
||||
/>
|
||||
{errors.founded_year && (
|
||||
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="headquarters_location">Headquarters Location</Label>
|
||||
<Combobox
|
||||
options={headquarters}
|
||||
value={watch('headquarters_location')}
|
||||
onValueChange={(value) => setValue('headquarters_location', value)}
|
||||
placeholder="Select or type location"
|
||||
searchPlaceholder="Search locations..."
|
||||
emptyText="No locations found"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website_url">Website URL</Label>
|
||||
<Input
|
||||
id="website_url"
|
||||
type="url"
|
||||
{...register('website_url')}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{errors.website_url && (
|
||||
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Manufacturer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,18 @@ 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 { Badge } from '@/components/ui/badge';
|
||||
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Zap, Save, X } from 'lucide-react';
|
||||
import { Plus, Zap, Save, X } from 'lucide-react';
|
||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
||||
import { ManufacturerForm } from './ManufacturerForm';
|
||||
import { RideModelForm } from './RideModelForm';
|
||||
import {
|
||||
convertSpeed,
|
||||
convertDistance,
|
||||
@@ -50,7 +56,10 @@ const rideSchema = z.object({
|
||||
max_g_force: z.number().optional(),
|
||||
former_names: z.string().optional(),
|
||||
coaster_stats: z.string().optional(),
|
||||
technical_specs: z.string().optional()
|
||||
technical_specs: z.string().optional(),
|
||||
// Manufacturer and model
|
||||
manufacturer_id: z.string().uuid().optional(),
|
||||
ride_model_id: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
type RideFormData = z.infer<typeof rideSchema>;
|
||||
@@ -111,6 +120,20 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
const { preferences } = useUnitPreferences();
|
||||
const measurementSystem = preferences.measurement_system;
|
||||
|
||||
// Manufacturer and model state
|
||||
const [selectedManufacturerId, setSelectedManufacturerId] = useState<string>(
|
||||
initialData?.manufacturer_id || ''
|
||||
);
|
||||
const [selectedManufacturerName, setSelectedManufacturerName] = useState<string>('');
|
||||
const [tempNewManufacturer, setTempNewManufacturer] = useState<any>(null);
|
||||
const [tempNewRideModel, setTempNewRideModel] = useState<any>(null);
|
||||
const [isManufacturerModalOpen, setIsManufacturerModalOpen] = useState(false);
|
||||
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
|
||||
|
||||
// Fetch data
|
||||
const { manufacturers, loading: manufacturersLoading } = useManufacturers();
|
||||
const { rideModels, loading: modelsLoading } = useRideModels(selectedManufacturerId);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -154,7 +177,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
max_g_force: initialData?.max_g_force || undefined,
|
||||
former_names: initialData?.former_names || '',
|
||||
coaster_stats: initialData?.coaster_stats || '',
|
||||
technical_specs: initialData?.technical_specs || ''
|
||||
technical_specs: initialData?.technical_specs || '',
|
||||
manufacturer_id: initialData?.manufacturer_id || undefined,
|
||||
ride_model_id: initialData?.ride_model_id || undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,13 +224,36 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
image_url: rideImage || undefined
|
||||
};
|
||||
|
||||
await onSubmit(metricData);
|
||||
// Build composite submission if new entities were created
|
||||
const submissionContent: any = {
|
||||
ride: metricData,
|
||||
};
|
||||
|
||||
// Add new manufacturer if created
|
||||
if (tempNewManufacturer) {
|
||||
submissionContent.new_manufacturer = tempNewManufacturer;
|
||||
submissionContent.ride.manufacturer_id = null; // Clear since using new
|
||||
}
|
||||
|
||||
// Add new ride model if created
|
||||
if (tempNewRideModel) {
|
||||
submissionContent.new_ride_model = tempNewRideModel;
|
||||
submissionContent.ride.ride_model_id = null; // Clear since using new
|
||||
}
|
||||
|
||||
// Pass composite data to parent
|
||||
await onSubmit({
|
||||
...metricData,
|
||||
_compositeSubmission: submissionContent
|
||||
} as any);
|
||||
|
||||
toast({
|
||||
title: isEditing ? "Ride Updated" : "Ride Created",
|
||||
title: isEditing ? "Ride Updated" : "Submission Sent",
|
||||
description: isEditing
|
||||
? "The ride information has been updated successfully."
|
||||
: "The new ride has been created successfully."
|
||||
: tempNewManufacturer
|
||||
? "Ride, manufacturer, and model submitted for review"
|
||||
: "Ride submitted for review"
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -320,6 +368,144 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manufacturer & Model Selection */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Manufacturer & Model</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Manufacturer Column */}
|
||||
<div className="space-y-2">
|
||||
<Label>Manufacturer</Label>
|
||||
|
||||
{tempNewManufacturer ? (
|
||||
// Show temp manufacturer badge
|
||||
<div className="flex items-center gap-2 p-3 border rounded-md bg-blue-50 dark:bg-blue-950">
|
||||
<Badge variant="secondary">New</Badge>
|
||||
<span className="font-medium">{tempNewManufacturer.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTempNewManufacturer(null);
|
||||
setTempNewRideModel(null); // Clear model too
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsManufacturerModalOpen(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Show combobox for existing manufacturers
|
||||
<Combobox
|
||||
options={manufacturers}
|
||||
value={watch('manufacturer_id')}
|
||||
onValueChange={(value) => {
|
||||
setValue('manufacturer_id', value);
|
||||
setSelectedManufacturerId(value);
|
||||
// Find and set manufacturer name
|
||||
const mfr = manufacturers.find(m => m.value === value);
|
||||
setSelectedManufacturerName(mfr?.label || '');
|
||||
// Clear model when manufacturer changes
|
||||
setValue('ride_model_id', undefined);
|
||||
setTempNewRideModel(null);
|
||||
}}
|
||||
placeholder="Select manufacturer"
|
||||
searchPlaceholder="Search manufacturers..."
|
||||
emptyText="No manufacturers found"
|
||||
loading={manufacturersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create New Manufacturer Button */}
|
||||
{!tempNewManufacturer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsManufacturerModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create New Manufacturer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ride Model Column - Conditional */}
|
||||
{(selectedManufacturerId || tempNewManufacturer) && (
|
||||
<div className="space-y-2">
|
||||
<Label>Ride Model (Optional)</Label>
|
||||
|
||||
{tempNewRideModel ? (
|
||||
// Show temp model badge
|
||||
<div className="flex items-center gap-2 p-3 border rounded-md bg-purple-50 dark:bg-purple-950">
|
||||
<Badge variant="secondary">New</Badge>
|
||||
<span className="font-medium">{tempNewRideModel.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTempNewRideModel(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsModelModalOpen(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Show combobox for existing models
|
||||
<>
|
||||
<Combobox
|
||||
options={rideModels}
|
||||
value={watch('ride_model_id')}
|
||||
onValueChange={(value) => setValue('ride_model_id', value)}
|
||||
placeholder="Select model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText={tempNewManufacturer
|
||||
? "Create the manufacturer first to add models"
|
||||
: "No models found for this manufacturer"}
|
||||
loading={modelsLoading}
|
||||
disabled={!!tempNewManufacturer}
|
||||
/>
|
||||
|
||||
{/* Create New Model Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsModelModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create New Model
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tempNewManufacturer
|
||||
? "New models will be created after manufacturer approval"
|
||||
: "Select a specific model or leave blank"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
@@ -565,6 +751,61 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Manufacturer Modal */}
|
||||
<Dialog open={isManufacturerModalOpen} onOpenChange={setIsManufacturerModalOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tempNewManufacturer ? 'Edit New Manufacturer' : 'Create New Manufacturer'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This manufacturer will be submitted for moderation along with the ride.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ManufacturerForm
|
||||
initialData={tempNewManufacturer}
|
||||
onSubmit={(data) => {
|
||||
setTempNewManufacturer(data);
|
||||
setSelectedManufacturerName(data.name);
|
||||
setIsManufacturerModalOpen(false);
|
||||
// Clear existing manufacturer selection
|
||||
setValue('manufacturer_id', undefined);
|
||||
setSelectedManufacturerId('');
|
||||
// Clear any existing model
|
||||
setValue('ride_model_id', undefined);
|
||||
setTempNewRideModel(null);
|
||||
}}
|
||||
onCancel={() => setIsManufacturerModalOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Ride Model Modal */}
|
||||
<Dialog open={isModelModalOpen} onOpenChange={setIsModelModalOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tempNewRideModel ? 'Edit New Ride Model' : 'Create New Ride Model'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creating a model for: <strong>{selectedManufacturerName || tempNewManufacturer?.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RideModelForm
|
||||
manufacturerName={selectedManufacturerName || tempNewManufacturer?.name}
|
||||
manufacturerId={selectedManufacturerId}
|
||||
initialData={tempNewRideModel}
|
||||
onSubmit={(data) => {
|
||||
setTempNewRideModel(data);
|
||||
setIsModelModalOpen(false);
|
||||
// Clear existing model selection
|
||||
setValue('ride_model_id', undefined);
|
||||
}}
|
||||
onCancel={() => setIsModelModalOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
203
src/components/admin/RideModelForm.tsx
Normal file
203
src/components/admin/RideModelForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Layers, Save, X } from 'lucide-react';
|
||||
|
||||
const rideModelSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
ride_type: z.string().min(1, 'Ride type is required'),
|
||||
description: z.string().optional(),
|
||||
technical_specs: z.string().optional()
|
||||
});
|
||||
|
||||
type RideModelFormData = z.infer<typeof rideModelSchema>;
|
||||
|
||||
interface RideModelFormProps {
|
||||
manufacturerName: string;
|
||||
manufacturerId?: string;
|
||||
onSubmit: (data: RideModelFormData) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<RideModelFormData>;
|
||||
}
|
||||
|
||||
const categories = [
|
||||
'roller_coaster',
|
||||
'flat_ride',
|
||||
'water_ride',
|
||||
'dark_ride',
|
||||
'kiddie_ride',
|
||||
'transportation'
|
||||
];
|
||||
|
||||
export function RideModelForm({
|
||||
manufacturerName,
|
||||
manufacturerId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData
|
||||
}: RideModelFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useForm<RideModelFormData>({
|
||||
resolver: zodResolver(rideModelSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
slug: initialData?.slug || '',
|
||||
category: initialData?.category || '',
|
||||
ride_type: initialData?.ride_type || '',
|
||||
description: initialData?.description || '',
|
||||
technical_specs: initialData?.technical_specs || ''
|
||||
}
|
||||
});
|
||||
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const name = e.target.value;
|
||||
const slug = generateSlug(name);
|
||||
setValue('slug', slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" />
|
||||
{initialData ? 'Edit Ride Model' : 'Create New Ride Model'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-sm text-muted-foreground">For manufacturer:</span>
|
||||
<Badge variant="secondary">{manufacturerName}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Model Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
onChange={(e) => {
|
||||
register('name').onChange(e);
|
||||
handleNameChange(e);
|
||||
}}
|
||||
placeholder="e.g. Mega Coaster, Sky Screamer"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
{...register('slug')}
|
||||
placeholder="model-slug"
|
||||
/>
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category and Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Category *</Label>
|
||||
<Select
|
||||
onValueChange={(value) => setValue('category', value)}
|
||||
defaultValue={initialData?.category}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.category && (
|
||||
<p className="text-sm text-destructive">{errors.category.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ride_type">Ride Type *</Label>
|
||||
<Input
|
||||
id="ride_type"
|
||||
{...register('ride_type')}
|
||||
placeholder="e.g. Inverted, Wing Coaster, Pendulum"
|
||||
/>
|
||||
{errors.ride_type && (
|
||||
<p className="text-sm text-destructive">{errors.ride_type.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
placeholder="Describe the ride model features and characteristics..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Technical Specs */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="technical_specs">Technical Specifications</Label>
|
||||
<Textarea
|
||||
id="technical_specs"
|
||||
{...register('technical_specs')}
|
||||
placeholder="Enter technical specifications (e.g., track length, inversions, typical speed range)..."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
General specifications for this model that apply to all installations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Model
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -355,6 +355,117 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
|
||||
setActionLoading(item.id);
|
||||
try {
|
||||
// Handle composite ride submissions with sequential entity creation
|
||||
if (action === 'approved' && item.type === 'content_submission' &&
|
||||
(item.submission_type === 'ride_with_manufacturer' ||
|
||||
item.submission_type === 'ride_with_model' ||
|
||||
item.submission_type === 'ride_with_manufacturer_and_model')) {
|
||||
|
||||
let manufacturerId = item.content.ride?.manufacturer_id;
|
||||
let rideModelId = item.content.ride?.ride_model_id;
|
||||
|
||||
// STEP 1: Create manufacturer if needed
|
||||
if (item.content.new_manufacturer) {
|
||||
const { data: newManufacturer, error: mfrError } = await supabase
|
||||
.from('companies')
|
||||
.insert({
|
||||
name: item.content.new_manufacturer.name,
|
||||
slug: item.content.new_manufacturer.slug,
|
||||
description: item.content.new_manufacturer.description,
|
||||
company_type: 'manufacturer',
|
||||
person_type: item.content.new_manufacturer.person_type || 'company',
|
||||
website_url: item.content.new_manufacturer.website_url,
|
||||
founded_year: item.content.new_manufacturer.founded_year,
|
||||
headquarters_location: item.content.new_manufacturer.headquarters_location
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (mfrError) {
|
||||
throw new Error(`Failed to create manufacturer: ${mfrError.message}`);
|
||||
}
|
||||
|
||||
manufacturerId = newManufacturer.id;
|
||||
|
||||
toast({
|
||||
title: "Manufacturer Created",
|
||||
description: `Created ${newManufacturer.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 2: Create ride model if needed
|
||||
if (item.content.new_ride_model) {
|
||||
const modelManufacturerId = manufacturerId || item.content.new_ride_model.manufacturer_id;
|
||||
|
||||
if (!modelManufacturerId) {
|
||||
throw new Error('Cannot create ride model: No manufacturer ID available');
|
||||
}
|
||||
|
||||
const { data: newModel, error: modelError } = await supabase
|
||||
.from('ride_models')
|
||||
.insert({
|
||||
name: item.content.new_ride_model.name,
|
||||
slug: item.content.new_ride_model.slug,
|
||||
manufacturer_id: modelManufacturerId,
|
||||
category: item.content.new_ride_model.category,
|
||||
ride_type: item.content.new_ride_model.ride_type,
|
||||
description: item.content.new_ride_model.description,
|
||||
technical_specs: item.content.new_ride_model.technical_specs
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (modelError) {
|
||||
throw new Error(`Failed to create ride model: ${modelError.message}`);
|
||||
}
|
||||
|
||||
rideModelId = newModel.id;
|
||||
|
||||
toast({
|
||||
title: "Ride Model Created",
|
||||
description: `Created ${newModel.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 3: Create the ride
|
||||
const { error: rideError } = await supabase
|
||||
.from('rides')
|
||||
.insert({
|
||||
...item.content.ride,
|
||||
manufacturer_id: manufacturerId,
|
||||
ride_model_id: rideModelId,
|
||||
park_id: item.content.park_id
|
||||
});
|
||||
|
||||
if (rideError) {
|
||||
throw new Error(`Failed to create ride: ${rideError.message}`);
|
||||
}
|
||||
|
||||
// STEP 4: Update submission status
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const { error: updateError } = await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
status: 'approved',
|
||||
reviewer_id: user?.id,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer_notes: moderatorNotes
|
||||
})
|
||||
.eq('id', item.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
toast({
|
||||
title: "Submission Approved",
|
||||
description: "All entities created successfully",
|
||||
});
|
||||
|
||||
// Refresh the queue
|
||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard moderation flow for other items
|
||||
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
|
||||
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
|
||||
|
||||
@@ -925,6 +1036,146 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Composite Submissions (Ride with Manufacturer/Model) */}
|
||||
{(item.submission_type === 'ride_with_manufacturer' ||
|
||||
item.submission_type === 'ride_with_model' ||
|
||||
item.submission_type === 'ride_with_manufacturer_and_model') ? (
|
||||
<div className="space-y-4">
|
||||
{/* New Manufacturer Card */}
|
||||
{item.content.new_manufacturer && (
|
||||
<Card className="border-blue-300 dark:border-blue-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-blue-100 dark:bg-blue-900">
|
||||
New Manufacturer
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.content.new_manufacturer.name}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{item.content.new_manufacturer.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description: </span>
|
||||
<span className="text-muted-foreground">{item.content.new_manufacturer.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{item.content.new_manufacturer.person_type && (
|
||||
<div>
|
||||
<span className="font-medium">Type: </span>
|
||||
<span className="text-muted-foreground capitalize">{item.content.new_manufacturer.person_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.new_manufacturer.founded_year && (
|
||||
<div>
|
||||
<span className="font-medium">Founded: </span>
|
||||
<span className="text-muted-foreground">{item.content.new_manufacturer.founded_year}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.new_manufacturer.headquarters_location && (
|
||||
<div>
|
||||
<span className="font-medium">HQ: </span>
|
||||
<span className="text-muted-foreground">{item.content.new_manufacturer.headquarters_location}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.new_manufacturer.website_url && (
|
||||
<div>
|
||||
<span className="font-medium">Website: </span>
|
||||
<a href={item.content.new_manufacturer.website_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline text-xs">
|
||||
{item.content.new_manufacturer.website_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New Ride Model Card */}
|
||||
{item.content.new_ride_model && (
|
||||
<Card className="border-purple-300 dark:border-purple-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-purple-100 dark:bg-purple-900">
|
||||
New Ride Model
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.content.new_ride_model.name}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Manufacturer: </span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.content.new_manufacturer
|
||||
? item.content.new_manufacturer.name
|
||||
: 'Existing manufacturer'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="font-medium">Category: </span>
|
||||
<span className="text-muted-foreground capitalize">{item.content.new_ride_model.category?.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Type: </span>
|
||||
<span className="text-muted-foreground">{item.content.new_ride_model.ride_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
{item.content.new_ride_model.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description: </span>
|
||||
<span className="text-muted-foreground">{item.content.new_ride_model.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Ride Details Card */}
|
||||
<Card className="border-green-300 dark:border-green-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900">
|
||||
Ride
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.content.ride?.name}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{item.content.ride?.description && (
|
||||
<p className="text-muted-foreground">{item.content.ride.description}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{item.content.ride?.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category: </span>
|
||||
<span className="text-muted-foreground capitalize">{item.content.ride.category.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.ride?.status && (
|
||||
<div>
|
||||
<span className="font-medium">Status: </span>
|
||||
<span className="text-muted-foreground">{item.content.ride.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.ride?.max_speed_kmh && (
|
||||
<div>
|
||||
<span className="font-medium">Max Speed: </span>
|
||||
<span className="text-muted-foreground">{item.content.ride.max_speed_kmh} km/h</span>
|
||||
</div>
|
||||
)}
|
||||
{item.content.ride?.max_height_meters && (
|
||||
<div>
|
||||
<span className="font-medium">Max Height: </span>
|
||||
<span className="text-muted-foreground">{item.content.ride.max_height_meters} m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
@@ -936,6 +1187,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons based on status */}
|
||||
{(item.status === 'pending' || item.status === 'flagged') && (
|
||||
|
||||
@@ -96,7 +96,7 @@ export function useManufacturers() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.select('id, name')
|
||||
.eq('company_type', 'manufacturer')
|
||||
.order('name');
|
||||
|
||||
@@ -105,7 +105,7 @@ export function useManufacturers() {
|
||||
setManufacturers(
|
||||
(data || []).map(company => ({
|
||||
label: company.name,
|
||||
value: company.name.toLowerCase().replace(/\s+/g, '_')
|
||||
value: company.id
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -122,6 +122,47 @@ export function useManufacturers() {
|
||||
return { manufacturers, loading };
|
||||
}
|
||||
|
||||
export function useRideModels(manufacturerId?: string) {
|
||||
const [rideModels, setRideModels] = useState<ComboboxOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manufacturerId) {
|
||||
setRideModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchRideModels() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ride_models')
|
||||
.select('id, name')
|
||||
.eq('manufacturer_id', manufacturerId)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setRideModels(
|
||||
(data || []).map(model => ({
|
||||
label: model.name,
|
||||
value: model.id
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching ride models:', error);
|
||||
setRideModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRideModels();
|
||||
}, [manufacturerId]);
|
||||
|
||||
return { rideModels, loading };
|
||||
}
|
||||
|
||||
export function useCompanyHeadquarters() {
|
||||
const [headquarters, setHeadquarters] = useState<ComboboxOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -99,17 +99,34 @@ export default function ParkDetail() {
|
||||
};
|
||||
|
||||
const handleRideSubmit = async (rideData: any) => {
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
navigate('/auth');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract composite submission data
|
||||
const compositeData = rideData._compositeSubmission;
|
||||
delete rideData._compositeSubmission;
|
||||
|
||||
// Determine submission type based on what's being created
|
||||
let submissionType = 'ride';
|
||||
if (compositeData?.new_manufacturer && compositeData?.new_ride_model) {
|
||||
submissionType = 'ride_with_manufacturer_and_model';
|
||||
} else if (compositeData?.new_manufacturer) {
|
||||
submissionType = 'ride_with_manufacturer';
|
||||
} else if (compositeData?.new_ride_model) {
|
||||
submissionType = 'ride_with_model';
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
submission_type: 'ride',
|
||||
submission_type: submissionType,
|
||||
status: 'pending',
|
||||
content: {
|
||||
...rideData,
|
||||
...(compositeData || { ride: rideData }),
|
||||
park_id: park?.id,
|
||||
park_slug: park?.slug
|
||||
}
|
||||
@@ -117,9 +134,18 @@ export default function ParkDetail() {
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
let message = "Your ride submission has been sent for moderation review.";
|
||||
if (compositeData?.new_manufacturer && compositeData?.new_ride_model) {
|
||||
message = "Your ride, new manufacturer, and new model have been submitted for review.";
|
||||
} else if (compositeData?.new_manufacturer) {
|
||||
message = "Your ride and new manufacturer have been submitted for review.";
|
||||
} else if (compositeData?.new_ride_model) {
|
||||
message = "Your ride and new model have been submitted for review.";
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Ride Submitted",
|
||||
description: "Your ride submission has been sent for moderation review.",
|
||||
title: "Submission Sent",
|
||||
description: message,
|
||||
});
|
||||
|
||||
setIsAddRideModalOpen(false);
|
||||
|
||||
Reference in New Issue
Block a user