diff --git a/src/components/admin/DesignerForm.tsx b/src/components/admin/DesignerForm.tsx index 4f489092..8433b28b 100644 --- a/src/components/admin/DesignerForm.tsx +++ b/src/components/admin/DesignerForm.tsx @@ -7,9 +7,11 @@ 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 { SlugField } from '@/components/ui/slug-field'; import { Ruler, Save, X } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; +import { useUserRole } from '@/hooks/useUserRole'; const designerSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -30,6 +32,7 @@ interface DesignerFormProps { } export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) { + const { isModerator } = useUserRole(); const { headquarters } = useCompanyHeadquarters(); const { @@ -51,20 +54,6 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; return ( @@ -83,10 +72,6 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter designer name" /> {errors.name && ( @@ -94,17 +79,12 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/ManufacturerForm.tsx b/src/components/admin/ManufacturerForm.tsx index c3f78005..4557c7d4 100644 --- a/src/components/admin/ManufacturerForm.tsx +++ b/src/components/admin/ManufacturerForm.tsx @@ -7,9 +7,11 @@ 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 { SlugField } from '@/components/ui/slug-field'; import { Building2, Save, X } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; +import { useUserRole } from '@/hooks/useUserRole'; const manufacturerSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -30,6 +32,7 @@ interface ManufacturerFormProps { } export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) { + const { isModerator } = useUserRole(); const { headquarters } = useCompanyHeadquarters(); const { @@ -51,20 +54,6 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; return ( @@ -83,10 +72,6 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter manufacturer name" /> {errors.name && ( @@ -94,17 +79,12 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/OperatorForm.tsx b/src/components/admin/OperatorForm.tsx index 4d9e6307..5e59bcd3 100644 --- a/src/components/admin/OperatorForm.tsx +++ b/src/components/admin/OperatorForm.tsx @@ -7,9 +7,11 @@ 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 { SlugField } from '@/components/ui/slug-field'; import { FerrisWheel, Save, X } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; +import { useUserRole } from '@/hooks/useUserRole'; const operatorSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -30,6 +32,7 @@ interface OperatorFormProps { } export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) { + const { isModerator } = useUserRole(); const { headquarters } = useCompanyHeadquarters(); const { @@ -51,20 +54,6 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; return ( @@ -83,10 +72,6 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter operator name" /> {errors.name && ( @@ -94,17 +79,12 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index d9161e09..32323306 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -10,16 +10,18 @@ import { Label } from '@/components/ui/label'; import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { DatePicker } from '@/components/ui/date-picker'; +import { SlugField } from '@/components/ui/slug-field'; import { toast } from '@/hooks/use-toast'; import { MapPin, Save, X, Plus } from 'lucide-react'; 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'; const parkSchema = z.object({ name: z.string().min(1, 'Park name is required'), - slug: z.string().min(1, 'Slug 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'), @@ -77,6 +79,7 @@ const statusOptions = [ ]; export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) { + const { isModerator } = useUserRole(); const [submitting, setSubmitting] = useState(false); const [bannerImage, setBannerImage] = useState(initialData?.banner_image_url || ''); const [cardImage, setCardImage] = useState(initialData?.card_image_url || ''); @@ -127,20 +130,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; const handleFormSubmit = async (data: ParkFormData) => { setSubmitting(true); @@ -213,10 +202,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter park name" /> {errors.name && ( @@ -224,17 +209,12 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/PropertyOwnerForm.tsx b/src/components/admin/PropertyOwnerForm.tsx index e0d73076..afdc14f3 100644 --- a/src/components/admin/PropertyOwnerForm.tsx +++ b/src/components/admin/PropertyOwnerForm.tsx @@ -7,9 +7,11 @@ 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 { SlugField } from '@/components/ui/slug-field'; import { Building2, Save, X } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; +import { useUserRole } from '@/hooks/useUserRole'; const propertyOwnerSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -30,6 +32,7 @@ interface PropertyOwnerFormProps { } export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) { + const { isModerator } = useUserRole(); const { headquarters } = useCompanyHeadquarters(); const { @@ -51,20 +54,6 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; return ( @@ -83,10 +72,6 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter property owner name" /> {errors.name && ( @@ -94,17 +79,12 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/RideForm.tsx b/src/components/admin/RideForm.tsx index 6fc35c6f..3e5989d8 100644 --- a/src/components/admin/RideForm.tsx +++ b/src/components/admin/RideForm.tsx @@ -13,10 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { DatePicker } from '@/components/ui/date-picker'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Combobox } from '@/components/ui/combobox'; +import { SlugField } from '@/components/ui/slug-field'; import { toast } from '@/hooks/use-toast'; import { Plus, Zap, Save, X } from 'lucide-react'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData'; +import { useUserRole } from '@/hooks/useUserRole'; import { ManufacturerForm } from './ManufacturerForm'; import { RideModelForm } from './RideModelForm'; import { @@ -127,6 +129,7 @@ const intensityLevels = [ ]; export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) { + const { isModerator } = useUserRole(); const [submitting, setSubmitting] = useState(false); const [bannerImageUrl, setBannerImageUrl] = useState(initialData?.banner_image_url || ''); const [bannerImageId, setBannerImageId] = useState(initialData?.banner_image_id || ''); @@ -200,20 +203,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: const selectedCategory = watch('category'); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; const handleFormSubmit = async (data: RideFormData) => { setSubmitting(true); @@ -301,10 +290,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="Enter ride name" /> {errors.name && ( @@ -312,17 +297,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Description */} diff --git a/src/components/admin/RideModelForm.tsx b/src/components/admin/RideModelForm.tsx index 278030bf..50b813e4 100644 --- a/src/components/admin/RideModelForm.tsx +++ b/src/components/admin/RideModelForm.tsx @@ -8,7 +8,9 @@ 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 { SlugField } from '@/components/ui/slug-field'; import { Layers, Save, X } from 'lucide-react'; +import { useUserRole } from '@/hooks/useUserRole'; const rideModelSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -45,6 +47,8 @@ export function RideModelForm({ onCancel, initialData }: RideModelFormProps) { + const { isModerator } = useUserRole(); + const { register, handleSubmit, @@ -63,20 +67,6 @@ export function RideModelForm({ } }); - const generateSlug = (name: string) => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); - }; - - const handleNameChange = (e: React.ChangeEvent) => { - const name = e.target.value; - const slug = generateSlug(name); - setValue('slug', slug); - }; return ( @@ -99,10 +89,6 @@ export function RideModelForm({ { - register('name').onChange(e); - handleNameChange(e); - }} placeholder="e.g. Mega Coaster, Sky Screamer" /> {errors.name && ( @@ -110,17 +96,12 @@ export function RideModelForm({ )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
+ setValue('slug', slug)} + isModerator={isModerator()} + /> {/* Category and Type */} diff --git a/src/components/ui/slug-field.tsx b/src/components/ui/slug-field.tsx new file mode 100644 index 00000000..84ef0f31 --- /dev/null +++ b/src/components/ui/slug-field.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, Lock } from 'lucide-react'; +import { generateSlugFromName } from '@/lib/slugUtils'; + +interface SlugFieldProps { + name: string; + slug: string; + onSlugChange: (slug: string) => void; + isModerator: boolean; + label?: string; + hideForNonModerators?: boolean; +} + +export function SlugField({ + name, + slug, + onSlugChange, + isModerator, + label = 'URL Slug', + hideForNonModerators = true +}: SlugFieldProps) { + const [isEditing, setIsEditing] = useState(false); + + // Auto-generate slug when name changes + useEffect(() => { + if (!isEditing && name) { + const generatedSlug = generateSlugFromName(name); + onSlugChange(generatedSlug); + } + }, [name, isEditing, onSlugChange]); + + const handleRegenerate = () => { + if (name) { + const generatedSlug = generateSlugFromName(name); + onSlugChange(generatedSlug); + setIsEditing(false); + } + }; + + // Hide completely for non-moderators if configured + if (!isModerator && hideForNonModerators) { + return null; + } + + return ( +
+
+ + {!isModerator && ( + + + Auto-generated + + )} + {isModerator && ( + + Moderator + + )} +
+ +
+ { + onSlugChange(e.target.value); + setIsEditing(true); + }} + placeholder="url-slug" + readOnly={!isModerator} + className={!isModerator ? 'bg-muted cursor-not-allowed' : ''} + /> + + {isModerator && ( + + )} +
+ + {!isModerator && ( +

+ URL will be automatically generated from the name +

+ )} + + {isModerator && slug && ( +

+ Preview: .../{slug} +

+ )} +
+ ); +} diff --git a/src/lib/slugUtils.ts b/src/lib/slugUtils.ts index 83d5139c..f1a78260 100644 --- a/src/lib/slugUtils.ts +++ b/src/lib/slugUtils.ts @@ -2,16 +2,28 @@ import { supabase } from '@/integrations/supabase/client'; /** * Generate a URL-safe slug from a name + * This is the canonical slug generation function used throughout the app */ export function generateSlugFromName(name: string): string { + if (!name) return ''; + return name .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') + .replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens .trim(); } +/** + * Validate that a user has permission to edit slugs + * Only moderators should be able to manually edit slugs + */ +export function canEditSlug(isModerator: boolean): boolean { + return isModerator; +} + /** * Ensure slug is unique by checking database and appending number if needed */