Compare commits

...

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
2468d3cc18 Enable admin-only stats and update subscriptions
Implement plan to fix database RPCs by migrating to photos table, update hooks to enable only on admin pages, switch real-time subscriptions to the photos table, and apply to remaining forms. Also disable analytics in development.
2025-11-11 23:49:56 +00:00
gpt-engineer-app[bot]
f4300de738 Apply blur validation and toasts to remaining forms
Extend forms with blur-based validation via FormFieldWrapper.validationMode, and replace inline toasts with centralized formToasts. Update ManufacturerForm, DesignerForm, OperatorForm, PropertyOwnerForm, RideModelForm, and related components to use the new toast helper and ensure data-error scroll behavior where applicable.
2025-11-11 23:44:48 +00:00
gpt-engineer-app[bot]
92e93bfc9d Enhance FormFieldWrapper with blur validation and toasts
Adds validation on blur mode to FormFieldWrapper, introduces animated validation states, and implements standardized form submission toasts via a new formToasts helper; updates ParkForm and RideForm to use the new toast system and to propagate error state with scroll-to-error support.
2025-11-11 23:43:01 +00:00
14 changed files with 500 additions and 53 deletions

View File

@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput'; import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader'; import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import type { UploadedImage } from '@/types/company'; import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation) // Zod output type (after transformation)
@@ -73,7 +74,7 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
<CardContent> <CardContent>
<form onSubmit={handleSubmit(async (data) => { <form onSubmit={handleSubmit(async (data) => {
if (!user) { if (!user) {
toast.error('You must be logged in to submit'); formToasts.error.generic('You must be logged in to submit');
return; return;
} }
@@ -93,9 +94,11 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
await onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Show success toast
if (!initialData?.id) { if (initialData?.id) {
toast.success('Designer submitted for review'); formToasts.success.update('Designer', data.name);
} else {
formToasts.success.create('Designer', data.name);
onCancel(); onCancel();
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -104,6 +107,9 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
metadata: { companyName: data.name } metadata: { companyName: data.name }
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -17,8 +17,9 @@ import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader'; import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input'; import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils'; import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import type { UploadedImage } from '@/types/company'; import type { UploadedImage } from '@/types/company';
@@ -77,7 +78,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
<CardContent> <CardContent>
<form onSubmit={handleSubmit(async (data) => { <form onSubmit={handleSubmit(async (data) => {
if (!user) { if (!user) {
toast.error('You must be logged in to submit'); formToasts.error.generic('You must be logged in to submit');
return; return;
} }
@@ -95,9 +96,11 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
await onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Show success toast
if (!initialData?.id) { if (initialData?.id) {
toast.success('Manufacturer submitted for review'); formToasts.success.update('Manufacturer', data.name);
} else {
formToasts.success.create('Manufacturer', data.name);
onCancel(); onCancel();
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -106,6 +109,9 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
metadata: { companyName: data.name } metadata: { companyName: data.name }
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput'; import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader'; import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import type { UploadedImage } from '@/types/company'; import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation) // Zod output type (after transformation)
@@ -73,7 +74,7 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
<CardContent> <CardContent>
<form onSubmit={handleSubmit(async (data) => { <form onSubmit={handleSubmit(async (data) => {
if (!user) { if (!user) {
toast.error('You must be logged in to submit'); formToasts.error.generic('You must be logged in to submit');
return; return;
} }
@@ -93,9 +94,11 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
await onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Show success toast
if (!initialData?.id) { if (initialData?.id) {
toast.success('Operator submitted for review'); formToasts.success.update('Operator', data.name);
} else {
formToasts.success.create('Operator', data.name);
onCancel(); onCancel();
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -104,6 +107,9 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
metadata: { companyName: data.name } metadata: { companyName: data.name }
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -17,6 +17,7 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-
import { SlugField } from '@/components/ui/slug-field'; import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react'; import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils'; import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -294,7 +295,16 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
await onSubmit(submissionData); await onSubmit(submissionData);
// Parent component handles success feedback // Show success toast
if (isModerator()) {
formToasts.success.moderatorApproval('Park', data.name);
} else if (isEditing) {
formToasts.success.update('Park', data.name);
} else {
formToasts.success.create('Park', data.name);
}
// Parent component handles modal closing/navigation
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
handleError(error, { handleError(error, {
@@ -308,6 +318,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
} }
}); });
// Show error toast
formToasts.error.generic(errorMessage);
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput'; import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader'; import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import type { UploadedImage } from '@/types/company'; import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation) // Zod output type (after transformation)
@@ -73,7 +74,7 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
<CardContent> <CardContent>
<form onSubmit={handleSubmit(async (data) => { <form onSubmit={handleSubmit(async (data) => {
if (!user) { if (!user) {
toast.error('You must be logged in to submit'); formToasts.error.generic('You must be logged in to submit');
return; return;
} }
@@ -93,9 +94,11 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
await onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Show success toast
if (!initialData?.id) { if (initialData?.id) {
toast.success('Property owner submitted for review'); formToasts.success.update('Property Owner', data.name);
} else {
formToasts.success.create('Property Owner', data.name);
onCancel(); onCancel();
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -104,6 +107,9 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
metadata: { companyName: data.name } metadata: { companyName: data.name }
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler'; import { handleError } from '@/lib/errorHandler';
import { formToasts } from '@/lib/formToasts';
import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react'; import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils'; import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useUnitPreferences } from '@/hooks/useUnitPreferences';
@@ -360,14 +361,14 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
// Pass clean data to parent with extended fields // Pass clean data to parent with extended fields
await onSubmit(metricData); await onSubmit(metricData);
toast({ // Show success toast
title: isEditing ? "Ride Updated" : "Submission Sent", if (isModerator()) {
description: isEditing formToasts.success.moderatorApproval('Ride', data.name);
? "The ride information has been updated successfully." } else if (isEditing) {
: tempNewManufacturer formToasts.success.update('Ride', data.name);
? "Ride, manufacturer, and model submitted for review" } else {
: "Ride submitted for review" formToasts.success.create('Ride', data.name);
}); }
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, { handleError(error, {
action: isEditing ? 'Update Ride' : 'Create Ride', action: isEditing ? 'Update Ride' : 'Create Ride',
@@ -378,6 +379,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
} }
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button';
import type { RideModelTechnicalSpec } from '@/types/database'; import type { RideModelTechnicalSpec } from '@/types/database';
import { getErrorMessage } from '@/lib/errorHandler'; import { getErrorMessage } from '@/lib/errorHandler';
import { handleError } from '@/lib/errorHandler'; import { handleError } from '@/lib/errorHandler';
import { toast } from 'sonner'; import { toast } from '@/hooks/use-toast';
import { formToasts } from '@/lib/formToasts';
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';
@@ -112,12 +113,21 @@ export function RideModelForm({
manufacturer_id: manufacturerId, manufacturer_id: manufacturerId,
_technical_specifications: technicalSpecs _technical_specifications: technicalSpecs
}); });
toast.success('Ride model submitted for review');
// Show success toast
if (initialData?.id) {
formToasts.success.update('Ride Model', data.name);
} else {
formToasts.success.create('Ride Model', data.name);
}
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, { handleError(error, {
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model' action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
}); });
// Show error toast
formToasts.error.generic(getErrorMessage(error));
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally { } finally {

View File

@@ -29,6 +29,11 @@ class AnalyticsErrorBoundary extends Component<
} }
export function AnalyticsWrapper() { export function AnalyticsWrapper() {
// Disable analytics in development to reduce console noise
if (import.meta.env.DEV) {
return null;
}
return ( return (
<AnalyticsErrorBoundary> <AnalyticsErrorBoundary>
<Analytics /> <Analytics />

View File

@@ -75,6 +75,12 @@ interface FormFieldWrapperProps {
/** Hide automatic hint */ /** Hide automatic hint */
hideHint?: boolean; hideHint?: boolean;
/** When to show validation feedback */
validationMode?: 'realtime' | 'onBlur';
/** Callback when field is blurred (for onBlur mode) */
onBlur?: () => void;
} }
/** /**
@@ -192,7 +198,10 @@ export function FormFieldWrapper({
textareaProps, textareaProps,
className, className,
hideHint = false, hideHint = false,
validationMode = 'realtime',
onBlur,
}: FormFieldWrapperProps) { }: FormFieldWrapperProps) {
const [hasBlurred, setHasBlurred] = React.useState(false);
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes'; const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
const autoHint = getAutoHint(fieldType); const autoHint = getAutoHint(fieldType);
const displayHint = hint || autoHint; const displayHint = hint || autoHint;
@@ -203,12 +212,27 @@ export function FormFieldWrapper({
const charCount = typeof value === 'string' ? value.length : 0; const charCount = typeof value === 'string' ? value.length : 0;
// Determine validation state // Determine validation state
const shouldShowValidation = validationMode === 'realtime' || (validationMode === 'onBlur' && hasBlurred);
const hasValue = value !== undefined && value !== null && value !== ''; const hasValue = value !== undefined && value !== null && value !== '';
const isValid = !error && hasValue; const isValid = shouldShowValidation && !error && hasValue;
const hasError = !!error; const hasError = shouldShowValidation && !!error;
// Blur handler
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setHasBlurred(true);
if (validationMode === 'onBlur' && onBlur) {
onBlur();
}
// Call original onBlur if provided
if ('value' in e.target && textareaProps?.onBlur) {
textareaProps.onBlur(e as React.FocusEvent<HTMLTextAreaElement>);
} else if (inputProps?.onBlur) {
inputProps.onBlur(e as React.FocusEvent<HTMLInputElement>);
}
};
return ( return (
<div className={cn("space-y-2", className)}> <div className={cn("space-y-2", className)} data-error={hasError ? "true" : undefined}>
{/* Label with optional terminology tooltip */} {/* Label with optional terminology tooltip */}
<Label htmlFor={id} className="flex items-center gap-2"> <Label htmlFor={id} className="flex items-center gap-2">
{termKey ? ( {termKey ? (
@@ -232,11 +256,13 @@ export function FormFieldWrapper({
<Textarea <Textarea
id={id} id={id}
className={cn( className={cn(
"pr-10", "pr-10 transition-all duration-300 ease-in-out",
error && "border-destructive", "focus:ring-2 focus:ring-primary/20 focus:border-primary",
isValid && "border-green-500/50" error && "border-destructive focus:ring-destructive/20",
isValid && "border-green-500/50 focus:ring-green-500/20"
)} )}
maxLength={maxLength} maxLength={maxLength}
onBlur={handleBlur}
{...textareaProps} {...textareaProps}
/> />
) : ( ) : (
@@ -244,23 +270,25 @@ export function FormFieldWrapper({
id={id} id={id}
type={inputType} type={inputType}
className={cn( className={cn(
"pr-10", "pr-10 transition-all duration-300 ease-in-out",
error && "border-destructive", "focus:ring-2 focus:ring-primary/20 focus:border-primary",
isValid && "border-green-500/50" error && "border-destructive focus:ring-destructive/20",
isValid && "border-green-500/50 focus:ring-green-500/20"
)} )}
maxLength={maxLength} maxLength={maxLength}
onBlur={handleBlur}
{...inputProps} {...inputProps}
/> />
)} )}
{/* Validation icon */} {/* Validation icon with animation */}
{(isValid || hasError) && ( {(isValid || hasError) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"> <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{isValid && ( {isValid && (
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-green-500 animate-fade-in" />
)} )}
{hasError && ( {hasError && (
<AlertCircle className="h-4 w-4 text-destructive" /> <AlertCircle className="h-4 w-4 text-destructive animate-fade-in" />
)} )}
</div> </div>
)} )}
@@ -268,7 +296,7 @@ export function FormFieldWrapper({
{/* Hint text (if not hidden and exists) */} {/* Hint text (if not hidden and exists) */}
{!hideHint && displayHint && !error && ( {!hideHint && displayHint && !error && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground animate-slide-in-down">
{displayHint} {displayHint}
{showCharCount && ` (${charCount}/${maxLength} characters)`} {showCharCount && ` (${charCount}/${maxLength} characters)`}
</p> </p>
@@ -281,9 +309,9 @@ export function FormFieldWrapper({
</p> </p>
)} )}
{/* Error message */} {/* Error message with animation */}
{error && ( {error && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive animate-slide-in-down">
{error} {error}
{showCharCount && ` (${charCount}/${maxLength})`} {showCharCount && ` (${charCount}/${maxLength})`}
</p> </p>
@@ -299,6 +327,7 @@ export const formFieldPresets = {
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({ websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
fieldType: 'url' as FormFieldType, fieldType: 'url' as FormFieldType,
label: 'Website URL', label: 'Website URL',
validationMode: 'onBlur',
...props, ...props,
}), }),

View File

@@ -1,9 +1,13 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; import { queryKeys } from '@/lib/queryKeys';
import type { DatabaseStatistics } from '@/types/database-stats'; import type { DatabaseStatistics } from '@/types/database-stats';
export function useAdminDatabaseStats() { export function useAdminDatabaseStats() {
const location = useLocation();
const isAdminPage = location.pathname.startsWith('/admin');
return useQuery({ return useQuery({
queryKey: queryKeys.admin.databaseStats(), queryKey: queryKeys.admin.databaseStats(),
queryFn: async () => { queryFn: async () => {
@@ -15,7 +19,8 @@ export function useAdminDatabaseStats() {
return data as unknown as DatabaseStatistics; return data as unknown as DatabaseStatistics;
}, },
enabled: isAdminPage, // Only run query on admin pages
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 60 * 1000, // Auto-refetch every 60 seconds refetchInterval: isAdminPage ? 60 * 1000 : false, // Only refetch on admin pages
}); });
} }

View File

@@ -1,10 +1,14 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; import { queryKeys } from '@/lib/queryKeys';
import type { RecentAddition } from '@/types/database-stats'; import type { RecentAddition } from '@/types/database-stats';
import { useEffect } from 'react'; import { useEffect } from 'react';
export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) { export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) {
const location = useLocation();
const isAdminPage = location.pathname.startsWith('/admin');
const query = useQuery({ const query = useQuery({
queryKey: queryKeys.admin.recentAdditions(limit), queryKey: queryKeys.admin.recentAdditions(limit),
queryFn: async () => { queryFn: async () => {
@@ -18,8 +22,9 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
return data as unknown as RecentAddition[]; return data as unknown as RecentAddition[];
}, },
enabled: isAdminPage, // Only run query on admin pages
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 2 * 60 * 1000, // 2 minutes
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds refetchInterval: isAdminPage ? 30 * 1000 : false, // Only refetch on admin pages
}); });
// Set up real-time subscriptions // Set up real-time subscriptions
@@ -51,7 +56,7 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
.subscribe(), .subscribe(),
supabase supabase
.channel('recent_additions_photos') .channel('recent_additions_photos')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'entity_photos' }, () => { .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'photos' }, () => {
query.refetch(); query.refetch();
}) })
.subscribe(), .subscribe(),

65
src/lib/formToasts.ts Normal file
View File

@@ -0,0 +1,65 @@
import { toast } from '@/hooks/use-toast';
/**
* Standardized toast notifications for form submissions
* Provides consistent success/error feedback across all forms
*/
export const formToasts = {
success: {
create: (entityType: string, entityName?: string) => {
toast({
title: '✓ Submission Created',
description: entityName
? `${entityName} has been submitted for review.`
: `${entityType} has been submitted for review.`,
variant: 'default',
});
},
update: (entityType: string, entityName?: string) => {
toast({
title: '✓ Update Submitted',
description: entityName
? `Changes to ${entityName} have been submitted for review.`
: `${entityType} update has been submitted for review.`,
variant: 'default',
});
},
moderatorApproval: (entityType: string, entityName?: string) => {
toast({
title: '✓ Published Successfully',
description: entityName
? `${entityName} is now live on the site.`
: `${entityType} is now live on the site.`,
variant: 'default',
});
},
},
error: {
validation: (fieldCount: number) => {
toast({
title: 'Validation Failed',
description: `Please fix ${fieldCount} error${fieldCount > 1 ? 's' : ''} before submitting.`,
variant: 'destructive',
});
},
network: () => {
toast({
title: 'Connection Error',
description: 'Unable to submit. Please check your connection and try again.',
variant: 'destructive',
});
},
generic: (error: string) => {
toast({
title: 'Submission Failed',
description: error,
variant: 'destructive',
});
},
},
};

View File

@@ -0,0 +1,272 @@
-- Fix get_database_statistics function to use correct table name 'photos' instead of 'entity_photos'
CREATE OR REPLACE FUNCTION public.get_database_statistics()
RETURNS jsonb
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_stats jsonb;
v_parks_total integer;
v_parks_active integer;
v_parks_historical integer;
v_parks_7d integer;
v_parks_30d integer;
v_rides_total integer;
v_rides_active integer;
v_rides_historical integer;
v_rides_7d integer;
v_rides_30d integer;
v_companies_total integer;
v_manufacturers integer;
v_operators integer;
v_designers integer;
v_companies_7d integer;
v_companies_30d integer;
v_ride_models_total integer;
v_ride_models_7d integer;
v_ride_models_30d integer;
v_locations_total integer;
v_timeline_events_total integer;
v_photos_total integer;
v_photos_7d integer;
v_photos_30d integer;
v_users_total integer;
v_users_active_30d integer;
v_submissions_pending integer;
v_submissions_approved integer;
v_submissions_rejected integer;
BEGIN
-- Parks statistics
SELECT COUNT(*) INTO v_parks_total FROM parks;
SELECT COUNT(*) INTO v_parks_active FROM parks WHERE status = 'operating';
SELECT COUNT(*) INTO v_parks_historical FROM parks WHERE status IN ('closed', 'historical');
SELECT COUNT(*) INTO v_parks_7d FROM parks WHERE created_at > NOW() - INTERVAL '7 days';
SELECT COUNT(*) INTO v_parks_30d FROM parks WHERE created_at > NOW() - INTERVAL '30 days';
-- Rides statistics
SELECT COUNT(*) INTO v_rides_total FROM rides;
SELECT COUNT(*) INTO v_rides_active FROM rides WHERE status = 'operating';
SELECT COUNT(*) INTO v_rides_historical FROM rides WHERE status IN ('closed', 'removed', 'relocated');
SELECT COUNT(*) INTO v_rides_7d FROM rides WHERE created_at > NOW() - INTERVAL '7 days';
SELECT COUNT(*) INTO v_rides_30d FROM rides WHERE created_at > NOW() - INTERVAL '30 days';
-- Companies statistics
SELECT COUNT(*) INTO v_companies_total FROM companies;
SELECT COUNT(*) INTO v_manufacturers FROM companies WHERE company_type = 'manufacturer';
SELECT COUNT(*) INTO v_operators FROM companies WHERE company_type = 'operator';
SELECT COUNT(*) INTO v_designers FROM companies WHERE company_type = 'designer';
SELECT COUNT(*) INTO v_companies_7d FROM companies WHERE created_at > NOW() - INTERVAL '7 days';
SELECT COUNT(*) INTO v_companies_30d FROM companies WHERE created_at > NOW() - INTERVAL '30 days';
-- Ride models statistics
SELECT COUNT(*) INTO v_ride_models_total FROM ride_models;
SELECT COUNT(*) INTO v_ride_models_7d FROM ride_models WHERE created_at > NOW() - INTERVAL '7 days';
SELECT COUNT(*) INTO v_ride_models_30d FROM ride_models WHERE created_at > NOW() - INTERVAL '30 days';
-- Locations statistics
SELECT COUNT(*) INTO v_locations_total FROM locations;
-- Timeline events statistics
SELECT COUNT(*) INTO v_timeline_events_total FROM entity_timeline_events;
-- Photos statistics - FIXED: using 'photos' table instead of 'entity_photos'
SELECT COUNT(*) INTO v_photos_total FROM photos;
SELECT COUNT(*) INTO v_photos_7d FROM photos WHERE created_at > NOW() - INTERVAL '7 days';
SELECT COUNT(*) INTO v_photos_30d FROM photos WHERE created_at > NOW() - INTERVAL '30 days';
-- Users statistics
SELECT COUNT(*) INTO v_users_total FROM profiles;
SELECT COUNT(*) INTO v_users_active_30d FROM profiles WHERE updated_at > NOW() - INTERVAL '30 days';
-- Submissions statistics
SELECT COUNT(*) INTO v_submissions_pending FROM content_submissions WHERE status = 'pending';
SELECT COUNT(*) INTO v_submissions_approved FROM content_submissions WHERE status = 'approved';
SELECT COUNT(*) INTO v_submissions_rejected FROM content_submissions WHERE status = 'rejected';
-- Build result JSON
v_stats := jsonb_build_object(
'parks', jsonb_build_object(
'total', v_parks_total,
'active', v_parks_active,
'historical', v_parks_historical,
'added_7d', v_parks_7d,
'added_30d', v_parks_30d
),
'rides', jsonb_build_object(
'total', v_rides_total,
'active', v_rides_active,
'historical', v_rides_historical,
'added_7d', v_rides_7d,
'added_30d', v_rides_30d
),
'companies', jsonb_build_object(
'total', v_companies_total,
'manufacturers', v_manufacturers,
'operators', v_operators,
'designers', v_designers,
'added_7d', v_companies_7d,
'added_30d', v_companies_30d
),
'ride_models', jsonb_build_object(
'total', v_ride_models_total,
'added_7d', v_ride_models_7d,
'added_30d', v_ride_models_30d
),
'locations', jsonb_build_object(
'total', v_locations_total
),
'timeline_events', jsonb_build_object(
'total', v_timeline_events_total
),
'photos', jsonb_build_object(
'total', v_photos_total,
'added_7d', v_photos_7d,
'added_30d', v_photos_30d
),
'users', jsonb_build_object(
'total', v_users_total,
'active_30d', v_users_active_30d
),
'submissions', jsonb_build_object(
'pending', v_submissions_pending,
'approved', v_submissions_approved,
'rejected', v_submissions_rejected
)
);
RETURN v_stats;
END;
$function$;
-- Fix get_recent_additions function to use correct table and column names
CREATE OR REPLACE FUNCTION public.get_recent_additions(limit_count integer DEFAULT 50)
RETURNS TABLE(entity_id uuid, entity_type text, entity_name text, entity_slug text, park_slug text, image_url text, created_at timestamp with time zone, created_by_id uuid, created_by_username text, created_by_avatar text)
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
BEGIN
RETURN QUERY
SELECT * FROM (
-- Parks
SELECT
p.id as entity_id,
'park'::text as entity_type,
p.name as entity_name,
p.slug as entity_slug,
NULL::text as park_slug,
p.card_image_url as image_url,
p.created_at,
p.created_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM parks p
LEFT JOIN profiles prof ON prof.user_id = p.created_by
UNION ALL
-- Rides
SELECT
r.id as entity_id,
'ride'::text as entity_type,
r.name as entity_name,
r.slug as entity_slug,
pk.slug as park_slug,
r.card_image_url as image_url,
r.created_at,
r.created_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM rides r
LEFT JOIN parks pk ON pk.id = r.park_id
LEFT JOIN profiles prof ON prof.user_id = r.created_by
UNION ALL
-- Companies
SELECT
c.id as entity_id,
'company'::text as entity_type,
c.name as entity_name,
c.slug as entity_slug,
NULL::text as park_slug,
c.card_image_url as image_url,
c.created_at,
c.created_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM companies c
LEFT JOIN profiles prof ON prof.user_id = c.created_by
UNION ALL
-- Ride Models
SELECT
rm.id as entity_id,
'ride_model'::text as entity_type,
rm.name as entity_name,
rm.slug as entity_slug,
NULL::text as park_slug,
rm.card_image_url as image_url,
rm.created_at,
rm.created_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM ride_models rm
LEFT JOIN profiles prof ON prof.user_id = rm.created_by
UNION ALL
-- Locations
SELECT
l.id as entity_id,
'location'::text as entity_type,
COALESCE(l.city || ', ' || l.country, l.country, 'Location') as entity_name,
NULL::text as entity_slug,
NULL::text as park_slug,
NULL::text as image_url,
l.created_at,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM locations l
UNION ALL
-- Timeline Events
SELECT
te.id as entity_id,
'timeline_event'::text as entity_type,
te.event_title as entity_name,
NULL::text as entity_slug,
NULL::text as park_slug,
NULL::text as image_url,
te.created_at,
te.created_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM entity_timeline_events te
LEFT JOIN profiles prof ON prof.user_id = te.created_by
UNION ALL
-- Photos - FIXED: using 'photos' table and correct column names
SELECT
p.id as entity_id,
'photo'::text as entity_type,
COALESCE(p.title, 'Photo') as entity_name,
NULL::text as entity_slug,
NULL::text as park_slug,
p.cloudflare_image_url as image_url,
p.created_at as created_at,
p.submitted_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM photos p
LEFT JOIN profiles prof ON prof.user_id = p.submitted_by
) combined
ORDER BY created_at DESC
LIMIT limit_count;
END;
$function$;

View File

@@ -104,11 +104,26 @@ export default {
"0%": { transform: "translateX(-100%)" }, "0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(100%)" }, "100%": { transform: "translateX(100%)" },
}, },
"fade-in": {
"0%": { opacity: "0", transform: "translateY(-4px)" },
"100%": { opacity: "1", transform: "translateY(0)" }
},
"fade-out": {
"0%": { opacity: "1", transform: "translateY(0)" },
"100%": { opacity: "0", transform: "translateY(-4px)" }
},
"slide-in-down": {
"0%": { opacity: "0", transform: "translateY(-8px)" },
"100%": { opacity: "1", transform: "translateY(0)" }
},
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
shimmer: "shimmer 2s infinite", shimmer: "shimmer 2s infinite",
"fade-in": "fade-in 0.2s ease-out",
"fade-out": "fade-out 0.2s ease-out",
"slide-in-down": "slide-in-down 0.3s ease-out",
}, },
}, },
}, },