mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 22:11:11 -05:00
Fix: Resolve image upload and form integration issues
This commit is contained in:
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
|
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
|
||||||
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 { SlugField } from '@/components/ui/slug-field';
|
||||||
@@ -31,27 +31,28 @@ const parkSchema = z.object({
|
|||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
email: z.string().email().optional().or(z.literal('')),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
operator_id: z.string().uuid().optional(),
|
operator_id: z.string().uuid().optional(),
|
||||||
property_owner_id: z.string().uuid().optional()
|
property_owner_id: z.string().uuid().optional(),
|
||||||
|
images: z.object({
|
||||||
|
uploaded: z.array(z.object({
|
||||||
|
url: z.string(),
|
||||||
|
cloudflare_id: z.string(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
})),
|
||||||
|
banner_assignment: z.number().optional(),
|
||||||
|
card_assignment: z.number().optional(),
|
||||||
|
}).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type ParkFormData = z.infer<typeof parkSchema>;
|
type ParkFormData = z.infer<typeof parkSchema>;
|
||||||
|
|
||||||
interface ParkFormProps {
|
interface ParkFormProps {
|
||||||
onSubmit: (data: ParkFormData & {
|
onSubmit: (data: ParkFormData & {
|
||||||
banner_image_url?: string;
|
|
||||||
card_image_url?: string;
|
|
||||||
banner_image_id?: string;
|
|
||||||
card_image_id?: string;
|
|
||||||
operator_id?: string;
|
operator_id?: string;
|
||||||
property_owner_id?: string;
|
property_owner_id?: string;
|
||||||
_compositeSubmission?: any;
|
_compositeSubmission?: any;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
initialData?: Partial<ParkFormData & {
|
initialData?: Partial<ParkFormData & {
|
||||||
banner_image_url?: string;
|
|
||||||
card_image_url?: string;
|
|
||||||
banner_image_id?: string;
|
|
||||||
card_image_id?: string;
|
|
||||||
operator_id?: string;
|
operator_id?: string;
|
||||||
property_owner_id?: string;
|
property_owner_id?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -81,10 +82,6 @@ const statusOptions = [
|
|||||||
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
|
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
|
||||||
const { isModerator } = useUserRole();
|
const { isModerator } = useUserRole();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || '');
|
|
||||||
const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || '');
|
|
||||||
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
|
|
||||||
const [cardImageId, setCardImageId] = useState<string>(initialData?.card_image_id || '');
|
|
||||||
|
|
||||||
// Operator state
|
// Operator state
|
||||||
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
|
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
|
||||||
@@ -100,12 +97,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
const { operators, loading: operatorsLoading } = useOperators();
|
const { operators, loading: operatorsLoading } = useOperators();
|
||||||
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
|
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
|
||||||
|
|
||||||
// Extract Cloudflare image ID from URL
|
|
||||||
const extractImageId = (url: string): string => {
|
|
||||||
const match = url.match(/\/([a-f0-9-]{36})\//);
|
|
||||||
return match ? match[1] : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -126,7 +117,8 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
phone: initialData?.phone || '',
|
phone: initialData?.phone || '',
|
||||||
email: initialData?.email || '',
|
email: initialData?.email || '',
|
||||||
operator_id: initialData?.operator_id || undefined,
|
operator_id: initialData?.operator_id || undefined,
|
||||||
property_owner_id: initialData?.property_owner_id || undefined
|
property_owner_id: initialData?.property_owner_id || undefined,
|
||||||
|
images: { uploaded: [] }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,13 +128,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
try {
|
try {
|
||||||
// Build composite submission if new entities were created
|
// Build composite submission if new entities were created
|
||||||
const submissionContent: any = {
|
const submissionContent: any = {
|
||||||
park: {
|
park: data,
|
||||||
...data,
|
|
||||||
banner_image_url: bannerImage || undefined,
|
|
||||||
card_image_url: cardImage || undefined,
|
|
||||||
banner_image_id: bannerImageId || undefined,
|
|
||||||
card_image_id: cardImageId || undefined
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add new operator if created
|
// Add new operator if created
|
||||||
@@ -159,10 +145,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
...data,
|
...data,
|
||||||
banner_image_url: bannerImage || undefined,
|
|
||||||
card_image_url: cardImage || undefined,
|
|
||||||
banner_image_id: bannerImageId || undefined,
|
|
||||||
card_image_id: cardImageId || undefined,
|
|
||||||
operator_id: tempNewOperator ? undefined : (selectedOperatorId || undefined),
|
operator_id: tempNewOperator ? undefined : (selectedOperatorId || undefined),
|
||||||
property_owner_id: tempNewPropertyOwner ? undefined : (selectedPropertyOwnerId || undefined),
|
property_owner_id: tempNewPropertyOwner ? undefined : (selectedPropertyOwnerId || undefined),
|
||||||
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
|
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
|
||||||
@@ -432,73 +414,12 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
<div className="space-y-6">
|
<EntityMultiImageUploader
|
||||||
<div className="space-y-2">
|
mode={isEditing ? 'edit' : 'create'}
|
||||||
<Label>Banner Image</Label>
|
value={watch('images')}
|
||||||
{bannerImage && (
|
onChange={(images: ImageAssignments) => setValue('images', images)}
|
||||||
<div className="mb-2">
|
entityType="park"
|
||||||
<img src={bannerImage} alt="Current banner" className="h-32 w-full object-cover rounded-md" />
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<UppyPhotoUpload
|
|
||||||
maxFiles={1}
|
|
||||||
variant="public"
|
|
||||||
onUploadComplete={(urls) => {
|
|
||||||
const url = urls[0];
|
|
||||||
if (url) {
|
|
||||||
setBannerImage(url);
|
|
||||||
const imageId = extractImageId(url);
|
|
||||||
if (imageId) {
|
|
||||||
setBannerImageId(imageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUploadError={(error) => {
|
|
||||||
toast({
|
|
||||||
title: "Upload Error",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
High-resolution banner image for the park detail page (recommended: 1200x400px)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Card Image</Label>
|
|
||||||
{cardImage && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<img src={cardImage} alt="Current card" className="h-32 w-full object-cover rounded-md" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<UppyPhotoUpload
|
|
||||||
maxFiles={1}
|
|
||||||
variant="public"
|
|
||||||
onUploadComplete={(urls) => {
|
|
||||||
const url = urls[0];
|
|
||||||
if (url) {
|
|
||||||
setCardImage(url);
|
|
||||||
const imageId = extractImageId(url);
|
|
||||||
if (imageId) {
|
|
||||||
setCardImageId(imageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUploadError={(error) => {
|
|
||||||
toast({
|
|
||||||
title: "Upload Error",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Square or rectangular image for park cards and listings (recommended: 400x300px)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
<div className="flex gap-4 pt-6">
|
<div className="flex gap-4 pt-6">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
|
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
|
||||||
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
@@ -61,27 +61,25 @@ const rideSchema = z.object({
|
|||||||
technical_specs: z.string().optional(),
|
technical_specs: z.string().optional(),
|
||||||
// Manufacturer and model
|
// Manufacturer and model
|
||||||
manufacturer_id: z.string().uuid().optional(),
|
manufacturer_id: z.string().uuid().optional(),
|
||||||
ride_model_id: z.string().uuid().optional()
|
ride_model_id: z.string().uuid().optional(),
|
||||||
|
// Images
|
||||||
|
images: z.object({
|
||||||
|
uploaded: z.array(z.object({
|
||||||
|
url: z.string(),
|
||||||
|
cloudflare_id: z.string(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
})),
|
||||||
|
banner_assignment: z.number().optional(),
|
||||||
|
card_assignment: z.number().optional(),
|
||||||
|
}).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type RideFormData = z.infer<typeof rideSchema>;
|
type RideFormData = z.infer<typeof rideSchema>;
|
||||||
|
|
||||||
interface RideFormProps {
|
interface RideFormProps {
|
||||||
onSubmit: (data: RideFormData & {
|
onSubmit: (data: RideFormData) => Promise<void>;
|
||||||
image_url?: string;
|
|
||||||
banner_image_url?: string;
|
|
||||||
banner_image_id?: string;
|
|
||||||
card_image_url?: string;
|
|
||||||
card_image_id?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
initialData?: Partial<RideFormData & {
|
initialData?: Partial<RideFormData>;
|
||||||
image_url?: string;
|
|
||||||
banner_image_url?: string;
|
|
||||||
banner_image_id?: string;
|
|
||||||
card_image_url?: string;
|
|
||||||
card_image_id?: string;
|
|
||||||
}>;
|
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +129,6 @@ const intensityLevels = [
|
|||||||
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
|
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
|
||||||
const { isModerator } = useUserRole();
|
const { isModerator } = useUserRole();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || '');
|
|
||||||
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
|
|
||||||
const [cardImageUrl, setCardImageUrl] = useState<string>(initialData?.card_image_url || '');
|
|
||||||
const [cardImageId, setCardImageId] = useState<string>(initialData?.card_image_id || '');
|
|
||||||
const { preferences } = useUnitPreferences();
|
const { preferences } = useUnitPreferences();
|
||||||
const measurementSystem = preferences.measurement_system;
|
const measurementSystem = preferences.measurement_system;
|
||||||
|
|
||||||
@@ -197,7 +191,8 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
coaster_stats: initialData?.coaster_stats || '',
|
coaster_stats: initialData?.coaster_stats || '',
|
||||||
technical_specs: initialData?.technical_specs || '',
|
technical_specs: initialData?.technical_specs || '',
|
||||||
manufacturer_id: initialData?.manufacturer_id || undefined,
|
manufacturer_id: initialData?.manufacturer_id || undefined,
|
||||||
ride_model_id: initialData?.ride_model_id || undefined
|
ride_model_id: initialData?.ride_model_id || undefined,
|
||||||
|
images: { uploaded: [] }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,11 +219,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
: undefined,
|
: undefined,
|
||||||
drop_height_meters: data.drop_height_meters
|
drop_height_meters: data.drop_height_meters
|
||||||
? convertDistanceToMetric(data.drop_height_meters, measurementSystem)
|
? convertDistanceToMetric(data.drop_height_meters, measurementSystem)
|
||||||
: undefined,
|
: undefined
|
||||||
banner_image_url: bannerImageUrl || undefined,
|
|
||||||
banner_image_id: bannerImageId || undefined,
|
|
||||||
card_image_url: cardImageUrl || undefined,
|
|
||||||
card_image_id: cardImageId || undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build composite submission if new entities were created
|
// Build composite submission if new entities were created
|
||||||
@@ -714,63 +705,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
<div className="space-y-6">
|
<EntityMultiImageUploader
|
||||||
<h3 className="text-lg font-semibold">Images</h3>
|
mode={isEditing ? 'edit' : 'create'}
|
||||||
|
value={watch('images') || { uploaded: [] }}
|
||||||
<div className="space-y-2">
|
onChange={(images: ImageAssignments) => setValue('images', images)}
|
||||||
<Label>Banner Image</Label>
|
entityType="ride"
|
||||||
<UppyPhotoUpload
|
/>
|
||||||
maxFiles={1}
|
|
||||||
variant="public"
|
|
||||||
onUploadComplete={(urls) => {
|
|
||||||
if (urls[0]) {
|
|
||||||
setBannerImageUrl(urls[0]);
|
|
||||||
// Extract image ID from Cloudflare URL (format: https://imagedelivery.net/<hash>/<id>/<variant>)
|
|
||||||
const idMatch = urls[0].match(/\/([^/]+)\/public$/);
|
|
||||||
if (idMatch) {
|
|
||||||
setBannerImageId(idMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showPreview={true}
|
|
||||||
/>
|
|
||||||
{bannerImageUrl && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<img src={bannerImageUrl} alt="Banner preview" className="w-full h-32 object-cover rounded" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
High-resolution banner image for the ride detail page (recommended: 1200x400px)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Card Image</Label>
|
|
||||||
<UppyPhotoUpload
|
|
||||||
maxFiles={1}
|
|
||||||
variant="public"
|
|
||||||
onUploadComplete={(urls) => {
|
|
||||||
if (urls[0]) {
|
|
||||||
setCardImageUrl(urls[0]);
|
|
||||||
// Extract image ID from Cloudflare URL
|
|
||||||
const idMatch = urls[0].match(/\/([^/]+)\/public$/);
|
|
||||||
if (idMatch) {
|
|
||||||
setCardImageId(idMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showPreview={true}
|
|
||||||
/>
|
|
||||||
{cardImageUrl && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<img src={cardImageUrl} alt="Card preview" className="w-full h-32 object-cover rounded" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Square or rectangular image for ride cards and listings (recommended: 400x300px)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
<div className="flex gap-4 pt-6">
|
<div className="flex gap-4 pt-6">
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function EntityMultiImageUploader({
|
|||||||
onUploadComplete={handleUploadComplete}
|
onUploadComplete={handleUploadComplete}
|
||||||
maxFiles={maxImages - value.uploaded.length}
|
maxFiles={maxImages - value.uploaded.length}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
allowedFileTypes={['image/jpeg', 'image/jpg', 'image/png', 'image/webp']}
|
allowedFileTypes={['image/*']}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -77,15 +77,20 @@ export function UppyPhotoUpload({
|
|||||||
return `File "${file.name}" exceeds ${maxSizeMB}MB limit`;
|
return `File "${file.name}" exceeds ${maxSizeMB}MB limit`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = allowedFileTypes.map(type =>
|
// Check if file type is allowed
|
||||||
type.replace('*', '').replace('/', '')
|
// Support both wildcard (image/*) and specific types (image/jpeg, image/png)
|
||||||
);
|
const isWildcardMatch = allowedFileTypes.some(type => {
|
||||||
|
if (type.includes('*')) {
|
||||||
if (!allowedTypes.includes('image') && !allowedFileTypes.includes('image/*')) {
|
const prefix = type.split('/')[0];
|
||||||
const fileType = file.type.split('/')[0];
|
return file.type.startsWith(prefix + '/');
|
||||||
if (!allowedTypes.includes(fileType)) {
|
|
||||||
return `File type "${file.type}" is not allowed`;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isExactMatch = allowedFileTypes.includes(file.type);
|
||||||
|
|
||||||
|
if (!isWildcardMatch && !isExactMatch) {
|
||||||
|
return `File type "${file.type}" is not allowed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -667,10 +667,6 @@ export default function ParkDetail() {
|
|||||||
website_url: park?.website_url,
|
website_url: park?.website_url,
|
||||||
phone: park?.phone,
|
phone: park?.phone,
|
||||||
email: park?.email,
|
email: park?.email,
|
||||||
banner_image_url: park?.banner_image_url,
|
|
||||||
banner_image_id: park?.banner_image_id,
|
|
||||||
card_image_url: park?.card_image_url,
|
|
||||||
card_image_id: park?.card_image_id,
|
|
||||||
operator_id: park?.operator?.id,
|
operator_id: park?.operator?.id,
|
||||||
property_owner_id: park?.property_owner?.id
|
property_owner_id: park?.property_owner?.id
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -770,10 +770,6 @@ export default function RideDetail() {
|
|||||||
intensity_level: ride.intensity_level,
|
intensity_level: ride.intensity_level,
|
||||||
drop_height_meters: ride.drop_height_meters,
|
drop_height_meters: ride.drop_height_meters,
|
||||||
max_g_force: ride.max_g_force,
|
max_g_force: ride.max_g_force,
|
||||||
banner_image_url: ride.banner_image_url,
|
|
||||||
banner_image_id: ride.banner_image_id,
|
|
||||||
card_image_url: ride.card_image_url,
|
|
||||||
card_image_id: ride.card_image_id,
|
|
||||||
manufacturer_id: ride.manufacturer?.id,
|
manufacturer_id: ride.manufacturer?.id,
|
||||||
ride_model_id: ride.ride_model?.id
|
ride_model_id: ride.ride_model?.id
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user