diff --git a/.replit b/.replit index fc81a45d..7ad070ef 100644 --- a/.replit +++ b/.replit @@ -33,3 +33,7 @@ outputType = "webview" [[ports]] localPort = 5000 externalPort = 80 + +[[ports]] +localPort = 45171 +externalPort = 3000 diff --git a/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt b/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt new file mode 100644 index 00000000..36f8cb07 --- /dev/null +++ b/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt @@ -0,0 +1,47 @@ +🔴 Critical Issues +1. Hot Module Reload (HMR) Failures +Your browser console shows HMR errors for several components: + +ManufacturerRides.tsx and ManufacturerModels.tsx +Version-related components (VersionIndicator, VersionComparisonDialog, EntityVersionHistory) +Impact: During development, changes to these files won't refresh automatically, requiring full page reloads. + +2. Fast Refresh Incompatibility +Two exports are breaking React Fast Refresh: + +useSidebar in src/components/ui/sidebar.tsx +uploadPendingImages in src/components/upload/EntityMultiImageUploader.tsx +Impact: Components using these will cause full page reloads on changes instead of hot updates. + +⚠️ High Priority Issues +3. Potential Race Conditions +useEntityVersions hook: Uses a request counter but could still have timing issues when multiple requests are in flight +useModerationQueue hook: Lacks explicit request cancellation, could show stale data if network is slow +Impact: Users might see outdated information or experience data inconsistencies. + +4. Memory Leaks +PhotoUpload component: Object URLs might not be revoked if upload errors occur +useEntityVersions hook: Supabase realtime subscriptions might not cleanup properly on unmount +useAuth hook: Timeouts for Novu updates might not be cleared when component unmounts +Impact: Memory usage grows over time, eventually slowing down or crashing the browser. + +5. Missing Null/Undefined Safety Checks +PhotoModal: Accessing currentPhoto without checking if photos array is empty +EntityHistoryTimeline: Accessing event config without verifying it exists +useEntityVersions: Assumes Supabase data is always an array +EntityEditPreview: Accessing nested image data without structure validation +Impact: Runtime errors that crash components when data is missing. + +🟡 Medium Priority Issues +6. Inconsistent Error Handling +Error handling varies across components: + +Some use console.error() (silent for users) +Others use toast.error() (visible notifications) +File upload errors don't show which specific file failed +Impact: Users don't always know when something went wrong. + +7. State Management Issues +useSearch hook: Uses JSON.stringify() for memoization which fails with functions/Symbols +AutocompleteSearch: Search dropdown state not properly tied to loading state +EntityEditPreview: Deep object comparison issues for detecting changes \ No newline at end of file diff --git a/src/components/history/EntityHistoryTimeline.tsx b/src/components/history/EntityHistoryTimeline.tsx index 0b4db2fe..c57299b8 100644 --- a/src/components/history/EntityHistoryTimeline.tsx +++ b/src/components/history/EntityHistoryTimeline.tsx @@ -26,6 +26,9 @@ const eventTypeConfig: Record {sortedEvents.map((event, index) => { - const config = eventTypeConfig[event.type]; + // Safety check: verify event.type exists in eventTypeConfig, use fallback if not + const config = event.type && eventTypeConfig[event.type] + ? eventTypeConfig[event.type] + : defaultEventConfig; const Icon = config.icon; return ( @@ -105,14 +111,25 @@ export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimel } function formatEventDate(dateString: string): string { + // Safety check: validate dateString exists and is a string + if (!dateString || typeof dateString !== 'string') { + return 'Unknown date'; + } + try { // Handle year-only dates if (/^\d{4}$/.test(dateString)) { return dateString; } - // Handle full dates + // Validate date string before creating Date object const date = new Date(dateString); + + // Check if date is valid + if (isNaN(date.getTime())) { + return dateString; + } + return format(date, 'MMMM d, yyyy'); } catch { return dateString; diff --git a/src/components/moderation/EntityEditPreview.tsx b/src/components/moderation/EntityEditPreview.tsx index 6a522501..74313505 100644 --- a/src/components/moderation/EntityEditPreview.tsx +++ b/src/components/moderation/EntityEditPreview.tsx @@ -11,6 +11,36 @@ interface EntityEditPreviewProps { entityName?: string; } +/** + * Deep equality check for detecting changes in nested objects/arrays + */ +const deepEqual = (a: any, b: any): boolean => { + // Handle null/undefined cases + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + + // Handle primitives and functions + if (typeof a !== 'object') return a === b; + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => deepEqual(item, b[index])); + } + + // One is array, other is not + if (Array.isArray(a) !== Array.isArray(b)) return false; + + // Handle objects + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => deepEqual(a[key], b[key])); +}; + interface ImageAssignments { uploaded: Array<{ url: string; @@ -51,7 +81,10 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti .eq('submission_id', submissionId) .order('order_index', { ascending: true }); - if (error) throw error; + if (error) { + console.error('EntityEditPreview.fetchSubmissionItems: Failed to fetch submission items:', error); + throw error; + } if (items && items.length > 0) { const firstItem = items[0]; @@ -75,21 +108,35 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti if (data.images) { const images: ImageAssignments = data.images; + // Safety check: verify uploaded array exists and is valid + if (!images.uploaded || !Array.isArray(images.uploaded)) { + // Invalid images data structure, skip image processing + return; + } + // Extract banner image if (images.banner_assignment !== null && images.banner_assignment !== undefined) { - const bannerImg = images.uploaded[images.banner_assignment]; - if (bannerImg) { - setBannerImageUrl(bannerImg.url); - changed.push('banner_image'); + // Safety check: verify index is within bounds + if (images.banner_assignment >= 0 && images.banner_assignment < images.uploaded.length) { + const bannerImg = images.uploaded[images.banner_assignment]; + // Validate nested image data + if (bannerImg && bannerImg.url) { + setBannerImageUrl(bannerImg.url); + changed.push('banner_image'); + } } } // Extract card image if (images.card_assignment !== null && images.card_assignment !== undefined) { - const cardImg = images.uploaded[images.card_assignment]; - if (cardImg) { - setCardImageUrl(cardImg.url); - changed.push('card_image'); + // Safety check: verify index is within bounds + if (images.card_assignment >= 0 && images.card_assignment < images.uploaded.length) { + const cardImg = images.uploaded[images.card_assignment]; + // Validate nested image data + if (cardImg && cardImg.url) { + setCardImageUrl(cardImg.url); + changed.push('card_image'); + } } } } @@ -99,9 +146,12 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti const originalData = firstItem.original_data as any; const excludeFields = ['images', 'updated_at', 'created_at']; Object.keys(data).forEach(key => { - if (!excludeFields.includes(key) && - data[key] !== originalData[key]) { - changed.push(key); + if (!excludeFields.includes(key)) { + // Use deep equality check for objects and arrays + const isEqual = deepEqual(data[key], originalData[key]); + if (!isEqual) { + changed.push(key); + } } }); } @@ -109,7 +159,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti setChangedFields(changed); } } catch (error) { - console.error('Error fetching submission items:', error); + console.error('EntityEditPreview.fetchSubmissionItems: Error fetching submission items:', error); } finally { setLoading(false); } diff --git a/src/components/moderation/PhotoModal.tsx b/src/components/moderation/PhotoModal.tsx index 2db85d5a..5a5ee770 100644 --- a/src/components/moderation/PhotoModal.tsx +++ b/src/components/moderation/PhotoModal.tsx @@ -23,7 +23,19 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal const [touchEnd, setTouchEnd] = useState(null); const imageRef = useRef(null); - const currentPhoto = photos[currentIndex]; + // Safety check: ensure photos array exists and is not empty + if (!photos || photos.length === 0) { + return null; + } + + // Clamp currentIndex to valid bounds + const safeIndex = Math.max(0, Math.min(currentIndex, photos.length - 1)); + const currentPhoto = photos[safeIndex]; + + // Early return if currentPhoto is undefined + if (!currentPhoto) { + return null; + } // Minimum swipe distance (in px) const minSwipeDistance = 50; @@ -100,7 +112,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal )} {photos.length > 1 && ( - {currentIndex + 1} of {photos.length} + {safeIndex + 1} of {photos.length} )} @@ -111,7 +123,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal - {isOpen && displayItems.length > 0 && ( + {isOpen && (displayItems.length > 0 || loading) && ( {query.length === 0 && showRecentSearches && suggestions.length > 0 && ( @@ -316,7 +316,7 @@ export function AutocompleteSearch({ > )} - {displayItems.map((item, index) => ( + {displayItems.length > 0 && displayItems.map((item, index) => ( handleResultClick(item)} @@ -364,10 +364,11 @@ export function AutocompleteSearch({ {loading && ( + Searching... )} - {query.length > 0 && results.length > 0 && ( + {query.length > 0 && results.length > 0 && !loading && ( <> void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +export const SidebarContext = createContext(null); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index beb1a591..502bfcca 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -11,6 +11,8 @@ import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SidebarContext, type SidebarContext as SidebarContextType } from "@/components/ui/sidebar-context"; +import { useSidebar } from "@/hooks/useSidebar"; const SIDEBAR_COOKIE_NAME = "sidebar:state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; @@ -19,27 +21,6 @@ const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; -type SidebarContext = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { @@ -92,7 +73,7 @@ const SidebarProvider = React.forwardRef< // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed"; - const contextValue = React.useMemo( + const contextValue = React.useMemo( () => ({ state, open, @@ -633,5 +614,7 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, - useSidebar, }; + +// Re-export useSidebar from the hooks file for backwards compatibility +export { useSidebar } from "@/hooks/useSidebar"; diff --git a/src/components/upload/EntityMultiImageUploader.tsx b/src/components/upload/EntityMultiImageUploader.tsx index bc4f67be..f71c98a3 100644 --- a/src/components/upload/EntityMultiImageUploader.tsx +++ b/src/components/upload/EntityMultiImageUploader.tsx @@ -393,53 +393,3 @@ export function EntityMultiImageUploader({ ); } - -// Helper function to upload all local files -export async function uploadPendingImages(images: UploadedImage[]): Promise { - const uploadedImages: UploadedImage[] = []; - - for (const image of images) { - if (image.isLocal && image.file) { - try { - // Get upload URL from Cloudflare - const { data: uploadData, error: uploadError } = await fetch('/api/upload-image', { - method: 'POST', - }).then(res => res.json()); - - if (uploadError) { - throw new Error('Failed to get upload URL'); - } - - // Upload to Cloudflare - const formData = new FormData(); - formData.append('file', image.file); - - const uploadResponse = await fetch(uploadData.uploadURL, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - throw new Error('Failed to upload image'); - } - - const result = await uploadResponse.json(); - - // Return uploaded image with Cloudflare data - uploadedImages.push({ - url: result.result.variants[0], - cloudflare_id: result.result.id, - caption: image.caption, - }); - } catch (error) { - console.error('Error uploading image:', error); - throw error; - } - } else { - // Already uploaded, keep as is - uploadedImages.push(image); - } - } - - return uploadedImages; -} diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index 290ae63e..4820d71c 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -67,8 +67,18 @@ export function useEntityVersions(entityType: string, entityId: string) { // Only continue if this is still the latest request if (currentRequestId !== requestCounterRef.current) return; + // Safety check: verify data is an array before processing + if (!Array.isArray(data)) { + if (isMountedRef.current && currentRequestId === requestCounterRef.current) { + setVersions([]); + setCurrentVersion(null); + setLoading(false); + } + return; + } + // Fetch profiles separately - const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])]; + const userIds = [...new Set(data.map(v => v.changed_by).filter(Boolean))]; const { data: profiles } = await supabase .from('profiles') .select('user_id, username, avatar_url') @@ -77,8 +87,11 @@ export function useEntityVersions(entityType: string, entityId: string) { // Check again if this is still the latest request if (currentRequestId !== requestCounterRef.current) return; - const versionsWithProfiles = data?.map(v => { - const profile = profiles?.find(p => p.user_id === v.changed_by); + // Safety check: verify profiles array exists before filtering + const profilesArray = Array.isArray(profiles) ? profiles : []; + + const versionsWithProfiles = data.map(v => { + const profile = profilesArray.find(p => p.user_id === v.changed_by); return { ...v, changer_profile: profile || { @@ -90,14 +103,16 @@ export function useEntityVersions(entityType: string, entityId: string) { // Only update state if component is still mounted and this is still the latest request if (isMountedRef.current && currentRequestId === requestCounterRef.current) { - setVersions(versionsWithProfiles || []); - setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null); + setVersions(versionsWithProfiles); + setCurrentVersion(versionsWithProfiles.find(v => v.is_current) || null); setLoading(false); } } catch (error: any) { console.error('Error fetching versions:', error); if (isMountedRef.current) { - toast.error('Failed to load version history'); + // Safe error message access with fallback + const errorMessage = error?.message || 'Failed to load version history'; + toast.error(errorMessage); setLoading(false); } } @@ -114,12 +129,15 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; if (isMountedRef.current) { - setFieldHistory(data as FieldChange[] || []); + // Safety check: ensure data is an array + const fieldChanges = Array.isArray(data) ? data as FieldChange[] : []; + setFieldHistory(fieldChanges); } } catch (error: any) { console.error('Error fetching field history:', error); if (isMountedRef.current) { - toast.error('Failed to load field history'); + const errorMessage = error?.message || 'Failed to load field history'; + toast.error(errorMessage); } } }; @@ -137,7 +155,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error comparing versions:', error); if (isMountedRef.current) { - toast.error('Failed to compare versions'); + const errorMessage = error?.message || 'Failed to compare versions'; + toast.error(errorMessage); } return null; } @@ -166,7 +185,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error rolling back version:', error); if (isMountedRef.current) { - toast.error('Failed to rollback version'); + const errorMessage = error?.message || 'Failed to rollback version'; + toast.error(errorMessage); } return null; } @@ -195,7 +215,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error creating version:', error); if (isMountedRef.current) { - toast.error('Failed to create version'); + const errorMessage = error?.message || 'Failed to create version'; + toast.error(errorMessage); } return null; } diff --git a/src/hooks/useSidebar.ts b/src/hooks/useSidebar.ts new file mode 100644 index 00000000..035997ea --- /dev/null +++ b/src/hooks/useSidebar.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { SidebarContext } from "@/components/ui/sidebar-context"; + +export function useSidebar() { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index 5c3549f9..9f58abba 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -23,13 +23,16 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise => { if (image.isLocal && image.file) { + const fileName = image.file.name; + // Step 1: Get upload URL from our Supabase Edge Function const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', { body: { action: 'get-upload-url' } }); if (urlError || !uploadUrlData?.uploadURL) { - throw new Error(`Failed to get upload URL for image ${index + 1}: ${urlError?.message || 'Unknown error'}`); + console.error(`imageUploadHelper.uploadPendingImages: Failed to get upload URL for "${fileName}":`, urlError); + throw new Error(`Failed to get upload URL for "${fileName}": ${urlError?.message || 'Unknown error'}`); } // Step 2: Upload file directly to Cloudflare @@ -43,13 +46,15 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise 0) { if (newlyUploadedImageIds.length > 0) { - console.error(`Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); + console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); // Attempt cleanup in parallel but don't throw if it fails await Promise.allSettled( @@ -107,7 +112,7 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise { - console.error(`Failed to cleanup image ${imageId}:`, cleanupError); + console.error(`imageUploadHelper.uploadPendingImages: Failed to cleanup image ${imageId}:`, cleanupError); }) ) );
- {currentIndex + 1} of {photos.length} + {safeIndex + 1} of {photos.length}