mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 11:11:14 -05:00
Refactor: Implement app-wide slug generation
This commit is contained in:
@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { Ruler, Save, X } from 'lucide-react';
|
import { Ruler, Save, X } from 'lucide-react';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const designerSchema = z.object({
|
const designerSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -30,6 +32,7 @@ interface DesignerFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) {
|
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const { headquarters } = useCompanyHeadquarters();
|
const { headquarters } = useCompanyHeadquarters();
|
||||||
|
|
||||||
const {
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,10 +72,6 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter designer name"
|
placeholder="Enter designer name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -94,17 +79,12 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="designer-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { Building2, Save, X } from 'lucide-react';
|
import { Building2, Save, X } from 'lucide-react';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const manufacturerSchema = z.object({
|
const manufacturerSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -30,6 +32,7 @@ interface ManufacturerFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) {
|
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const { headquarters } = useCompanyHeadquarters();
|
const { headquarters } = useCompanyHeadquarters();
|
||||||
|
|
||||||
const {
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,10 +72,6 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter manufacturer name"
|
placeholder="Enter manufacturer name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -94,17 +79,12 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="manufacturer-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { FerrisWheel, Save, X } from 'lucide-react';
|
import { FerrisWheel, Save, X } from 'lucide-react';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const operatorSchema = z.object({
|
const operatorSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -30,6 +32,7 @@ interface OperatorFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) {
|
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const { headquarters } = useCompanyHeadquarters();
|
const { headquarters } = useCompanyHeadquarters();
|
||||||
|
|
||||||
const {
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,10 +72,6 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter operator name"
|
placeholder="Enter operator name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -94,17 +79,12 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="operator-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
|
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { MapPin, Save, X, Plus } from 'lucide-react';
|
import { MapPin, Save, X, Plus } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
|
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const parkSchema = z.object({
|
const parkSchema = z.object({
|
||||||
name: z.string().min(1, 'Park name is required'),
|
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(),
|
description: z.string().optional(),
|
||||||
park_type: z.string().min(1, 'Park type is required'),
|
park_type: z.string().min(1, 'Park type is required'),
|
||||||
status: z.string().min(1, 'Status 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) {
|
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || '');
|
const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || '');
|
||||||
const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || '');
|
const [cardImage, setCardImage] = useState<string>(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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: ParkFormData) => {
|
const handleFormSubmit = async (data: ParkFormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -213,10 +202,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter park name"
|
placeholder="Enter park name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -224,17 +209,12 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="park-url-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { Building2, Save, X } from 'lucide-react';
|
import { Building2, Save, X } from 'lucide-react';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const propertyOwnerSchema = z.object({
|
const propertyOwnerSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -30,6 +32,7 @@ interface PropertyOwnerFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) {
|
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const { headquarters } = useCompanyHeadquarters();
|
const { headquarters } = useCompanyHeadquarters();
|
||||||
|
|
||||||
const {
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,10 +72,6 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter property owner name"
|
placeholder="Enter property owner name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -94,17 +79,12 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="property-owner-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Plus, Zap, Save, X } from 'lucide-react';
|
import { Plus, Zap, Save, X } from 'lucide-react';
|
||||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||||
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { ManufacturerForm } from './ManufacturerForm';
|
import { ManufacturerForm } from './ManufacturerForm';
|
||||||
import { RideModelForm } from './RideModelForm';
|
import { RideModelForm } from './RideModelForm';
|
||||||
import {
|
import {
|
||||||
@@ -127,6 +129,7 @@ const intensityLevels = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
|
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || '');
|
const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || '');
|
||||||
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
|
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
|
||||||
@@ -200,20 +203,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
const selectedCategory = watch('category');
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: RideFormData) => {
|
const handleFormSubmit = async (data: RideFormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -301,10 +290,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="Enter ride name"
|
placeholder="Enter ride name"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -312,17 +297,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="ride-url-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { Layers, Save, X } from 'lucide-react';
|
import { Layers, Save, X } from 'lucide-react';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
const rideModelSchema = z.object({
|
const rideModelSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -45,6 +47,8 @@ export function RideModelForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
initialData
|
initialData
|
||||||
}: RideModelFormProps) {
|
}: RideModelFormProps) {
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
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<HTMLInputElement>) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = generateSlug(name);
|
|
||||||
setValue('slug', slug);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -99,10 +89,6 @@ export function RideModelForm({
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
onChange={(e) => {
|
|
||||||
register('name').onChange(e);
|
|
||||||
handleNameChange(e);
|
|
||||||
}}
|
|
||||||
placeholder="e.g. Mega Coaster, Sky Screamer"
|
placeholder="e.g. Mega Coaster, Sky Screamer"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -110,17 +96,12 @@ export function RideModelForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SlugField
|
||||||
<Label htmlFor="slug">URL Slug *</Label>
|
name={watch('name')}
|
||||||
<Input
|
slug={watch('slug')}
|
||||||
id="slug"
|
onSlugChange={(slug) => setValue('slug', slug)}
|
||||||
{...register('slug')}
|
isModerator={isModerator()}
|
||||||
placeholder="model-slug"
|
/>
|
||||||
/>
|
|
||||||
{errors.slug && (
|
|
||||||
<p className="text-sm text-destructive">{errors.slug.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category and Type */}
|
{/* Category and Type */}
|
||||||
|
|||||||
105
src/components/ui/slug-field.tsx
Normal file
105
src/components/ui/slug-field.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="slug">{label}</Label>
|
||||||
|
{!isModerator && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Lock className="w-3 h-3 mr-1" />
|
||||||
|
Auto-generated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isModerator && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Moderator
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSlugChange(e.target.value);
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
placeholder="url-slug"
|
||||||
|
readOnly={!isModerator}
|
||||||
|
className={!isModerator ? 'bg-muted cursor-not-allowed' : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isModerator && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
title="Regenerate from name"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isModerator && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
URL will be automatically generated from the name
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isModerator && slug && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Preview: <span className="font-mono">.../{slug}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,28 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a URL-safe slug from a name
|
* 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 {
|
export function generateSlugFromName(name: string): string {
|
||||||
|
if (!name) return '';
|
||||||
|
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric except spaces and hyphens
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||||
|
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||||
.trim();
|
.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
|
* Ensure slug is unique by checking database and appending number if needed
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user