mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:11:12 -05:00
Batch update all date precision handling to use expanded DatePrecision, replace hardcoded day defaults, and adjust related validation, UI, and helpers. Includes wrapper migration across Phase 1-3 functions, updates to logs, displays, and formatting utilities to align frontend with new precision values ('exact', 'month', 'year', 'decade', 'century', 'approximate').
740 lines
28 KiB
TypeScript
740 lines
28 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import * as z from 'zod';
|
|
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
|
|
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
|
|
import { getErrorMessage } from '@/lib/errorHandler';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
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 { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
|
|
import { SlugField } from '@/components/ui/slug-field';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { handleError } from '@/lib/errorHandler';
|
|
import { MapPin, Save, X, Plus, AlertCircle } from 'lucide-react';
|
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
|
import { Badge } from '@/components/ui/badge';
|
|
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';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
const parkSchema = z.object({
|
|
name: z.string().min(1, 'Park name is required'),
|
|
slug: z.string().min(1, 'Slug is required'), // Auto-generated, validated on submit
|
|
description: z.string().optional(),
|
|
park_type: z.string().min(1, 'Park type is required'),
|
|
status: z.string().min(1, 'Status is required'),
|
|
opening_date: z.string().optional().transform(val => val || undefined),
|
|
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
|
closing_date: z.string().optional().transform(val => val || undefined),
|
|
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
|
location: z.object({
|
|
name: z.string(),
|
|
street_address: z.string().optional(),
|
|
city: z.string().optional(),
|
|
state_province: z.string().optional(),
|
|
country: z.string(),
|
|
postal_code: z.string().optional(),
|
|
latitude: z.number(),
|
|
longitude: z.number(),
|
|
timezone: z.string().optional(),
|
|
display_name: z.string(),
|
|
}).optional(),
|
|
location_id: z.string().uuid().optional(),
|
|
website_url: z.string().url().optional().or(z.literal('')),
|
|
phone: z.string().optional(),
|
|
email: z.string().email().optional().or(z.literal('')),
|
|
operator_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
|
|
property_owner_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
|
|
source_url: z.string().url().optional().or(z.literal('')),
|
|
submission_notes: z.string().max(1000).optional().or(z.literal('')),
|
|
images: z.object({
|
|
uploaded: z.array(z.object({
|
|
url: z.string(),
|
|
cloudflare_id: z.string().optional(),
|
|
file: z.instanceof(File).optional(),
|
|
isLocal: z.boolean().optional(),
|
|
caption: z.string().optional(),
|
|
})),
|
|
banner_assignment: z.number().nullable().optional(),
|
|
card_assignment: z.number().nullable().optional(),
|
|
}).optional()
|
|
});
|
|
|
|
type ParkFormData = z.infer<typeof parkSchema>;
|
|
|
|
interface ParkFormProps {
|
|
onSubmit: (data: ParkFormData & {
|
|
operator_id?: string;
|
|
property_owner_id?: string;
|
|
_compositeSubmission?: import('@/types/composite-submission').ParkCompositeSubmission;
|
|
}) => Promise<void>;
|
|
onCancel?: () => void;
|
|
initialData?: Partial<ParkFormData & {
|
|
id?: string;
|
|
operator_id?: string;
|
|
property_owner_id?: string;
|
|
banner_image_url?: string;
|
|
card_image_url?: string;
|
|
}>;
|
|
isEditing?: boolean;
|
|
}
|
|
|
|
const parkTypes = [
|
|
{ value: 'theme_park', label: 'Theme Park' },
|
|
{ value: 'amusement_park', label: 'Amusement Park' },
|
|
{ value: 'water_park', label: 'Water Park' },
|
|
{ value: 'family_entertainment', label: 'Family Entertainment Center' },
|
|
{ value: 'adventure_park', label: 'Adventure Park' },
|
|
{ value: 'safari_park', label: 'Safari Park' },
|
|
{ value: 'carnival', label: 'Carnival' },
|
|
{ value: 'fair', label: 'Fair' }
|
|
];
|
|
|
|
const statusOptions = [
|
|
'Operating',
|
|
'Closed Temporarily',
|
|
'Closed Permanently',
|
|
'Under Construction',
|
|
'Planned',
|
|
'Abandoned'
|
|
];
|
|
|
|
// Status mappings
|
|
const STATUS_DISPLAY_TO_DB: Record<string, string> = {
|
|
'Operating': 'operating',
|
|
'Closed Temporarily': 'closed_temporarily',
|
|
'Closed Permanently': 'closed_permanently',
|
|
'Under Construction': 'under_construction',
|
|
'Planned': 'planned',
|
|
'Abandoned': 'abandoned'
|
|
};
|
|
|
|
const STATUS_DB_TO_DISPLAY: Record<string, string> = {
|
|
'operating': 'Operating',
|
|
'closed_temporarily': 'Closed Temporarily',
|
|
'closed_permanently': 'Closed Permanently',
|
|
'under_construction': 'Under Construction',
|
|
'planned': 'Planned',
|
|
'abandoned': 'Abandoned'
|
|
};
|
|
|
|
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
|
|
const { isModerator } = useUserRole();
|
|
|
|
// Validate that onSubmit uses submission helpers (dev mode only)
|
|
useEffect(() => {
|
|
validateSubmissionHandler(onSubmit, 'park');
|
|
}, [onSubmit]);
|
|
|
|
const { user } = useAuth();
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Operator state
|
|
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
|
|
const [tempNewOperator, setTempNewOperator] = useState<TempCompanyData | null>(null);
|
|
const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false);
|
|
|
|
// Property Owner state
|
|
const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState<string>(initialData?.property_owner_id || '');
|
|
const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<TempCompanyData | null>(null);
|
|
const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false);
|
|
|
|
// Operator is Owner checkbox state
|
|
const [operatorIsOwner, setOperatorIsOwner] = useState<boolean>(
|
|
!!(initialData?.operator_id && initialData?.property_owner_id &&
|
|
initialData?.operator_id === initialData?.property_owner_id)
|
|
);
|
|
|
|
// Fetch data
|
|
const { operators, loading: operatorsLoading } = useOperators();
|
|
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
trigger,
|
|
formState: { errors }
|
|
} = useForm<ParkFormData>({
|
|
resolver: zodResolver(entitySchemas.park),
|
|
defaultValues: {
|
|
name: initialData?.name || '',
|
|
slug: initialData?.slug || '',
|
|
description: initialData?.description || '',
|
|
park_type: initialData?.park_type || '',
|
|
status: initialData?.status || 'operating' as const, // Store DB value
|
|
opening_date: initialData?.opening_date || undefined,
|
|
closing_date: initialData?.closing_date || undefined,
|
|
location_id: initialData?.location_id || undefined,
|
|
website_url: initialData?.website_url || '',
|
|
phone: initialData?.phone || '',
|
|
email: initialData?.email || '',
|
|
operator_id: initialData?.operator_id || undefined,
|
|
property_owner_id: initialData?.property_owner_id || undefined,
|
|
source_url: initialData?.source_url || '',
|
|
submission_notes: initialData?.submission_notes || '',
|
|
images: { uploaded: [] }
|
|
}
|
|
});
|
|
|
|
// Sync property owner with operator when checkbox is enabled
|
|
useEffect(() => {
|
|
if (operatorIsOwner && selectedOperatorId) {
|
|
setSelectedPropertyOwnerId(selectedOperatorId);
|
|
setValue('property_owner_id', selectedOperatorId);
|
|
}
|
|
}, [operatorIsOwner, selectedOperatorId, setValue]);
|
|
|
|
|
|
const handleFormSubmit = async (data: ParkFormData) => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
// Pre-submission validation for required fields
|
|
const { valid, errors: validationErrors } = validateRequiredFields('park', data);
|
|
if (!valid) {
|
|
validationErrors.forEach(error => {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Missing Required Fields',
|
|
description: error
|
|
});
|
|
});
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
// CRITICAL: Block new photo uploads on edits
|
|
if (isEditing && data.images?.uploaded) {
|
|
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
|
|
if (hasNewPhotos) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Validation Error',
|
|
description: 'New photos cannot be added during edits. Please remove new photos or use the photo gallery.'
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build composite submission if new entities were created
|
|
const submissionContent: import('@/types/composite-submission').ParkCompositeSubmission = {
|
|
park: data,
|
|
};
|
|
|
|
// Add new operator if created
|
|
if (tempNewOperator) {
|
|
submissionContent.new_operator = tempNewOperator;
|
|
submissionContent.park.operator_id = null;
|
|
|
|
// If operator is also owner, use same entity for both
|
|
if (operatorIsOwner) {
|
|
submissionContent.new_property_owner = tempNewOperator;
|
|
submissionContent.park.property_owner_id = null;
|
|
}
|
|
}
|
|
|
|
// Add new property owner if created (and not already set above)
|
|
if (tempNewPropertyOwner && !operatorIsOwner) {
|
|
submissionContent.new_property_owner = tempNewPropertyOwner;
|
|
submissionContent.park.property_owner_id = null;
|
|
}
|
|
|
|
// Determine final IDs to pass
|
|
// When creating new entities via composite submission, IDs should be undefined
|
|
// When using existing entities, pass their IDs directly
|
|
let finalOperatorId: string | undefined;
|
|
let finalPropertyOwnerId: string | undefined;
|
|
|
|
if (tempNewOperator) {
|
|
// New operator being created via composite submission
|
|
finalOperatorId = undefined;
|
|
finalPropertyOwnerId = operatorIsOwner ? undefined :
|
|
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
|
|
} else {
|
|
// Using existing operator
|
|
finalOperatorId = selectedOperatorId || undefined;
|
|
finalPropertyOwnerId = operatorIsOwner ? finalOperatorId :
|
|
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
|
|
}
|
|
|
|
// Debug: Log what's being submitted
|
|
const submissionData = {
|
|
...data,
|
|
operator_id: finalOperatorId,
|
|
property_owner_id: finalPropertyOwnerId,
|
|
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
|
|
};
|
|
|
|
console.info('[ParkForm] Submitting park data:', {
|
|
hasLocation: !!submissionData.location,
|
|
hasLocationId: !!submissionData.location_id,
|
|
locationData: submissionData.location,
|
|
parkName: submissionData.name,
|
|
isEditing
|
|
});
|
|
|
|
await onSubmit(submissionData);
|
|
|
|
// Parent component handles success feedback
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
handleError(error, {
|
|
action: isEditing ? 'Update Park' : 'Create Park',
|
|
userId: user?.id,
|
|
metadata: {
|
|
parkName: data.name,
|
|
hasLocation: !!data.location_id,
|
|
hasNewOperator: !!tempNewOperator,
|
|
hasNewOwner: !!tempNewPropertyOwner
|
|
}
|
|
});
|
|
|
|
// Re-throw so parent can handle modal closing
|
|
throw error;
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="w-full max-w-4xl mx-auto">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MapPin className="w-5 h-5" />
|
|
{isEditing ? 'Edit Park' : 'Create New Park'}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(handleFormSubmit)} 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">Park Name *</Label>
|
|
<Input
|
|
id="name"
|
|
{...register('name')}
|
|
placeholder="Enter park name"
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-sm text-destructive">{errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<SlugField
|
|
name={watch('name')}
|
|
slug={watch('slug')}
|
|
onSlugChange={(slug) => setValue('slug', slug)}
|
|
isModerator={isModerator()}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
{...register('description')}
|
|
placeholder="Describe the park..."
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
{/* Park Type and Status */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label>Park Type *</Label>
|
|
<Select onValueChange={(value) => setValue('park_type', value)} defaultValue={initialData?.park_type}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select park type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{parkTypes.map((type) => (
|
|
<SelectItem key={type.value} value={type.value}>
|
|
{type.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{errors.park_type && (
|
|
<p className="text-sm text-destructive">{errors.park_type.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Status *</Label>
|
|
<Select
|
|
onValueChange={(value) => setValue('status', value)}
|
|
defaultValue={initialData?.status || 'operating'}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{statusOptions.map((displayStatus) => {
|
|
const dbValue = STATUS_DISPLAY_TO_DB[displayStatus];
|
|
return (
|
|
<SelectItem key={dbValue} value={dbValue}>
|
|
{displayStatus}
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
{errors.status && (
|
|
<p className="text-sm text-destructive">{errors.status.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<FlexibleDateInput
|
|
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
|
|
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
|
onChange={(date, precision) => {
|
|
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
|
setValue('opening_date_precision', precision);
|
|
}}
|
|
label="Opening Date"
|
|
placeholder="Select opening date"
|
|
disableFuture={true}
|
|
fromYear={1800}
|
|
/>
|
|
|
|
<FlexibleDateInput
|
|
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
|
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
|
onChange={(date, precision) => {
|
|
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
|
setValue('closing_date_precision', precision);
|
|
}}
|
|
label="Closing Date (if applicable)"
|
|
placeholder="Select closing date"
|
|
disablePast={false}
|
|
fromYear={1800}
|
|
/>
|
|
</div>
|
|
|
|
{/* Location */}
|
|
<div className="space-y-2">
|
|
<Label className="flex items-center gap-1">
|
|
Location
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<LocationSearch
|
|
onLocationSelect={(location) => {
|
|
console.info('[ParkForm] Location selected:', location);
|
|
setValue('location', location);
|
|
console.info('[ParkForm] Location set in form:', watch('location'));
|
|
// Manually trigger validation for the location field
|
|
trigger('location');
|
|
}}
|
|
initialLocationId={watch('location_id')}
|
|
/>
|
|
{errors.location && (
|
|
<p className="text-sm text-destructive flex items-center gap-1">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{errors.location.message}
|
|
</p>
|
|
)}
|
|
{!errors.location && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Operator & Property Owner Selection */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
|
|
|
|
<div className="flex items-center space-x-2 mb-4">
|
|
<Checkbox
|
|
id="operator-is-owner"
|
|
checked={operatorIsOwner}
|
|
onCheckedChange={(checked) => {
|
|
setOperatorIsOwner(checked as boolean);
|
|
if (checked && selectedOperatorId) {
|
|
setSelectedPropertyOwnerId(selectedOperatorId);
|
|
setValue('property_owner_id', selectedOperatorId);
|
|
setTempNewPropertyOwner(null);
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor="operator-is-owner" className="text-sm font-normal cursor-pointer">
|
|
Operator is also the property owner
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Operator Column */}
|
|
<div className="space-y-2">
|
|
<Label>Park Operator</Label>
|
|
|
|
{tempNewOperator ? (
|
|
<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">{tempNewOperator.name}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setTempNewOperator(null)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Combobox
|
|
options={operators}
|
|
value={watch('operator_id') || ''}
|
|
onValueChange={(value) => {
|
|
const cleanValue = value || undefined;
|
|
setValue('operator_id', cleanValue);
|
|
setSelectedOperatorId(cleanValue || '');
|
|
}}
|
|
placeholder="Select operator"
|
|
searchPlaceholder="Search operators..."
|
|
emptyText="No operators found"
|
|
loading={operatorsLoading}
|
|
/>
|
|
)}
|
|
|
|
{!tempNewOperator && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => setIsOperatorModalOpen(true)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create New Operator
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Property Owner Column */}
|
|
{!operatorIsOwner && (
|
|
<div className="space-y-2">
|
|
<Label>Property Owner</Label>
|
|
|
|
{tempNewPropertyOwner ? (
|
|
<div className="flex items-center gap-2 p-3 border rounded-md bg-green-50 dark:bg-green-950">
|
|
<Badge variant="secondary">New</Badge>
|
|
<span className="font-medium">{tempNewPropertyOwner.name}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setTempNewPropertyOwner(null)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Combobox
|
|
options={propertyOwners}
|
|
value={watch('property_owner_id') || ''}
|
|
onValueChange={(value) => {
|
|
const cleanValue = value || undefined;
|
|
setValue('property_owner_id', cleanValue);
|
|
setSelectedPropertyOwnerId(cleanValue || '');
|
|
}}
|
|
placeholder="Select property owner"
|
|
searchPlaceholder="Search property owners..."
|
|
emptyText="No property owners found"
|
|
loading={ownersLoading}
|
|
/>
|
|
)}
|
|
|
|
{!tempNewPropertyOwner && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => setIsPropertyOwnerModalOpen(true)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create New Property Owner
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contact Information */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="website_url">Website URL</Label>
|
|
<Input
|
|
id="website_url"
|
|
type="url"
|
|
{...register('website_url')}
|
|
placeholder="https://..."
|
|
/>
|
|
{errors.website_url && (
|
|
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">Phone Number</Label>
|
|
<Input
|
|
id="phone"
|
|
{...register('phone')}
|
|
placeholder="+1 (555) 123-4567"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
{...register('email')}
|
|
placeholder="contact@park.com"
|
|
/>
|
|
{errors.email && (
|
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submission Context - For Reviewers */}
|
|
<div className="space-y-4 border-t pt-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Badge variant="secondary" className="text-xs">
|
|
For Moderator Review
|
|
</Badge>
|
|
<p className="text-xs text-muted-foreground">
|
|
Help reviewers verify your submission
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="source_url" className="flex items-center gap-2">
|
|
Source URL
|
|
<span className="text-xs text-muted-foreground font-normal">
|
|
(Optional)
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
id="source_url"
|
|
type="url"
|
|
{...register('source_url')}
|
|
placeholder="https://example.com/article"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Where did you find this information? (e.g., official website, news article, press release)
|
|
</p>
|
|
{errors.source_url && (
|
|
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="submission_notes" className="flex items-center gap-2">
|
|
Notes for Reviewers
|
|
<span className="text-xs text-muted-foreground font-normal">
|
|
(Optional)
|
|
</span>
|
|
</Label>
|
|
<Textarea
|
|
id="submission_notes"
|
|
{...register('submission_notes')}
|
|
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via phone call with park', 'Soft opening date not yet announced')"
|
|
rows={3}
|
|
maxLength={1000}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{watch('submission_notes')?.length || 0}/1000 characters
|
|
</p>
|
|
{errors.submission_notes && (
|
|
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Images */}
|
|
<EntityMultiImageUploader
|
|
mode={isEditing ? 'edit' : 'create'}
|
|
value={watch('images') as ImageAssignments}
|
|
onChange={(images: ImageAssignments) => setValue('images', images)}
|
|
entityType="park"
|
|
entityId={isEditing ? initialData?.id : undefined}
|
|
currentBannerUrl={initialData?.banner_image_url}
|
|
currentCardUrl={initialData?.card_image_url}
|
|
/>
|
|
|
|
{/* Form Actions */}
|
|
<div className="flex gap-4 pt-6">
|
|
<Button
|
|
type="submit"
|
|
className="flex-1"
|
|
loading={isSubmitting}
|
|
loadingText="Saving..."
|
|
>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
{isEditing ? 'Update Park' : 'Create Park'}
|
|
</Button>
|
|
|
|
{onCancel && (
|
|
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
|
<X className="w-4 h-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Operator Modal */}
|
|
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<OperatorForm
|
|
initialData={tempNewOperator || undefined}
|
|
onSubmit={(data) => {
|
|
setTempNewOperator(data);
|
|
setIsOperatorModalOpen(false);
|
|
setValue('operator_id', 'temp-operator');
|
|
}}
|
|
onCancel={() => setIsOperatorModalOpen(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Property Owner Modal */}
|
|
<Dialog open={isPropertyOwnerModalOpen} onOpenChange={setIsPropertyOwnerModalOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<PropertyOwnerForm
|
|
initialData={tempNewPropertyOwner || undefined}
|
|
onSubmit={(data) => {
|
|
setTempNewPropertyOwner(data);
|
|
setIsPropertyOwnerModalOpen(false);
|
|
setValue('property_owner_id', 'temp-property-owner');
|
|
}}
|
|
onCancel={() => setIsPropertyOwnerModalOpen(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
} |