diff --git a/src/components/settings/LocationTab.tsx b/src/components/settings/LocationTab.tsx index 17bf6330..57095378 100644 --- a/src/components/settings/LocationTab.tsx +++ b/src/components/settings/LocationTab.tsx @@ -9,140 +9,303 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; -import { useToast } from '@/hooks/use-toast'; +import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { supabase } from '@/integrations/supabase/client'; -import { MapPin, Calendar, Globe, Accessibility, Ruler } from 'lucide-react'; -import { personalLocationSchema } from '@/lib/validation'; +import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; +import { MapPin, Calendar, Accessibility, Ruler } from 'lucide-react'; +import type { LocationFormData, AccessibilityOptions, ParkOption } from '@/types/location'; +import { + locationFormSchema, + accessibilityOptionsSchema, + parkOptionSchema, + DEFAULT_ACCESSIBILITY_OPTIONS, + COMMON_TIMEZONES +} from '@/lib/locationValidation'; -const locationSchema = z.object({ - preferred_pronouns: z.string().max(20).optional(), - timezone: z.string(), - preferred_language: z.string(), - personal_location: personalLocationSchema, - home_park_id: z.string().optional() -}); -type LocationFormData = z.infer; -interface AccessibilityOptions { - font_size: 'small' | 'medium' | 'large'; - high_contrast: boolean; - reduced_motion: boolean; -} export function LocationTab() { const { user } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const { toast } = useToast(); const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences(); - const [loading, setLoading] = useState(false); - const [parks, setParks] = useState([]); - const [accessibility, setAccessibility] = useState({ - font_size: 'medium', - high_contrast: false, - reduced_motion: false - }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [parks, setParks] = useState([]); + const [accessibility, setAccessibility] = useState(DEFAULT_ACCESSIBILITY_OPTIONS); + const form = useForm({ - resolver: zodResolver(locationSchema), + resolver: zodResolver(locationFormSchema), defaultValues: { - preferred_pronouns: profile?.preferred_pronouns || '', + preferred_pronouns: profile?.preferred_pronouns || null, timezone: profile?.timezone || 'UTC', preferred_language: profile?.preferred_language || 'en', - personal_location: (profile as any)?.personal_location || '', - home_park_id: (profile as any)?.home_park_id || '' + personal_location: profile?.personal_location || null, + home_park_id: profile?.home_park_id || null } }); + useEffect(() => { - fetchParks(); - fetchAccessibilityPreferences(); + if (user && profile) { + form.reset({ + preferred_pronouns: profile.preferred_pronouns || null, + timezone: profile.timezone || 'UTC', + preferred_language: profile.preferred_language || 'en', + personal_location: profile.personal_location || null, + home_park_id: profile.home_park_id || null + }); + } + }, [profile, form]); + + useEffect(() => { + if (user) { + fetchParks(); + fetchAccessibilityPreferences(); + } }, [user]); const fetchParks = async () => { + if (!user) return; + try { const { data, error } = await supabase .from('parks') - .select('id, name, location_id, locations(city, state_province, country)') + .select('id, name, locations(city, state_province, country)') .order('name'); - if (error) throw error; - setParks(data || []); + if (error) { + logger.error('Failed to fetch parks list', { + userId: user.id, + action: 'fetch_parks', + error: error.message, + errorCode: error.code + }); + throw error; + } + + const validatedParks = (data || []) + .map(park => { + try { + return parkOptionSchema.parse({ + id: park.id, + name: park.name, + location: park.locations ? { + city: park.locations.city, + state_province: park.locations.state_province, + country: park.locations.country + } : undefined + }); + } catch { + return null; + } + }) + .filter((park): park is ParkOption => park !== null); + + setParks(validatedParks); + + logger.info('Parks list loaded', { + userId: user.id, + action: 'fetch_parks', + count: validatedParks.length + }); } catch (error) { - console.error('Error fetching parks:', error); + logger.error('Error fetching parks', { + userId: user.id, + action: 'fetch_parks', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Load parks list', + userId: user.id + }); } }; + const fetchAccessibilityPreferences = async () => { if (!user) return; - try { - const { - data, - error - } = await supabase.from('user_preferences').select('accessibility_options').eq('user_id', user.id).maybeSingle(); - if (error && error.code !== 'PGRST116') { - console.error('Error fetching accessibility preferences:', error); - return; - } - if (data?.accessibility_options) { - setAccessibility(data.accessibility_options as any); - } - } catch (error) { - console.error('Error fetching accessibility preferences:', error); - } - }; - const onSubmit = async (data: LocationFormData) => { - if (!user) return; - setLoading(true); - try { - // Save profile information - const { error: profileError } = await supabase.from('profiles').update({ - preferred_pronouns: data.preferred_pronouns || null, - timezone: data.timezone, - preferred_language: data.preferred_language, - personal_location: data.personal_location || null, - home_park_id: data.home_park_id || null, - updated_at: new Date().toISOString() - }).eq('user_id', user.id); - - if (profileError) throw profileError; - // Save accessibility preferences - update existing record - const { error: accessibilityError } = await supabase + try { + const { data, error } = await supabase .from('user_preferences') - .update({ - accessibility_options: accessibility as any, - updated_at: new Date().toISOString() - }) - .eq('user_id', user.id); + .select('accessibility_options') + .eq('user_id', user.id) + .maybeSingle(); - if (accessibilityError) throw accessibilityError; + if (error && error.code !== 'PGRST116') { + logger.error('Failed to fetch accessibility preferences', { + userId: user.id, + action: 'fetch_accessibility_preferences', + error: error.message, + errorCode: error.code + }); + throw error; + } - // Save unit preferences - await updateUnitPreferences(unitPreferences); + if (data?.accessibility_options) { + const validated = accessibilityOptionsSchema.parse(data.accessibility_options); + setAccessibility(validated); + } - await refreshProfile(); - toast({ - title: 'Settings saved', - description: 'Your location, personal information, accessibility, and unit preferences have been updated.' + logger.info('Accessibility preferences loaded', { + userId: user.id, + action: 'fetch_accessibility_preferences' }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to save settings', - variant: 'destructive' + } catch (error) { + logger.error('Error fetching accessibility preferences', { + userId: user.id, + action: 'fetch_accessibility_preferences', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Load accessibility preferences', + userId: user.id }); } finally { setLoading(false); } }; - const updateAccessibility = (key: keyof AccessibilityOptions, value: any) => { - setAccessibility(prev => ({ - ...prev, - [key]: value - })); + + const onSubmit = async (data: LocationFormData) => { + if (!user) return; + + setSaving(true); + + try { + const validatedData = locationFormSchema.parse(data); + const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility); + + const previousProfile = { + personal_location: profile?.personal_location, + home_park_id: profile?.home_park_id, + timezone: profile?.timezone, + preferred_language: profile?.preferred_language, + preferred_pronouns: profile?.preferred_pronouns + }; + + const { error: profileError } = await supabase + .from('profiles') + .update({ + preferred_pronouns: validatedData.preferred_pronouns || null, + timezone: validatedData.timezone, + preferred_language: validatedData.preferred_language, + personal_location: validatedData.personal_location || null, + home_park_id: validatedData.home_park_id || null, + updated_at: new Date().toISOString() + }) + .eq('user_id', user.id); + + if (profileError) { + logger.error('Failed to update profile', { + userId: user.id, + action: 'update_profile_location', + error: profileError.message, + errorCode: profileError.code + }); + throw profileError; + } + + const { error: accessibilityError } = await supabase + .from('user_preferences') + .update({ + accessibility_options: validatedAccessibility, + updated_at: new Date().toISOString() + }) + .eq('user_id', user.id); + + if (accessibilityError) { + logger.error('Failed to update accessibility preferences', { + userId: user.id, + action: 'update_accessibility_preferences', + error: accessibilityError.message, + errorCode: accessibilityError.code + }); + throw accessibilityError; + } + + await updateUnitPreferences(unitPreferences); + + await supabase.from('profile_audit_log').insert([{ + user_id: user.id, + changed_by: user.id, + action: 'location_info_updated', + changes: { + previous: { + profile: previousProfile, + accessibility: DEFAULT_ACCESSIBILITY_OPTIONS + }, + updated: { + profile: validatedData, + accessibility: validatedAccessibility + }, + timestamp: new Date().toISOString() + } as any + }]); + + await refreshProfile(); + + logger.info('Location and info settings updated', { + userId: user.id, + action: 'update_location_info' + }); + + handleSuccess( + 'Settings saved', + 'Your location, personal information, accessibility, and unit preferences have been updated.' + ); + } catch (error) { + logger.error('Error saving location settings', { + userId: user.id, + action: 'save_location_settings', + error: error instanceof Error ? error.message : String(error) + }); + + if (error instanceof z.ZodError) { + handleError( + new AppError( + 'Invalid settings', + 'VALIDATION_ERROR', + error.issues.map(i => i.message).join(', ') + ), + { action: 'Validate location settings', userId: user.id } + ); + } else { + handleError(error, { + action: 'Save location settings', + userId: user.id + }); + } + } finally { + setSaving(false); + } }; - const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney']; - return
+ + const updateAccessibility = (key: keyof AccessibilityOptions, value: any) => { + setAccessibility(prev => ({ ...prev, [key]: value })); + }; + + if (loading) { + return ( +
+ + + + + + + + + + +
+ ); + } + + return ( +
- {/* Location Settings */}
@@ -163,6 +326,11 @@ export function LocationTab() { {...form.register('personal_location')} placeholder="e.g., San Francisco, CA or Berlin, Germany" /> + {form.formState.errors.personal_location && ( +

+ {form.formState.errors.personal_location.message} +

+ )}

Your personal location (optional, displayed as text)

@@ -170,7 +338,10 @@ export function LocationTab() {
- form.setValue('home_park_id', value)} + > @@ -178,17 +349,22 @@ export function LocationTab() { {parks.map(park => ( {park.name} - {park.locations && ( + {park.location && ( <> - {park.locations.city && `, ${park.locations.city}`} - {park.locations.state_province && `, ${park.locations.state_province}`} - {park.locations.country && `, ${park.locations.country}`} + {park.location.city && `, ${park.location.city}`} + {park.location.state_province && `, ${park.location.state_province}`} + {`, ${park.location.country}`} )} ))} + {form.formState.errors.home_park_id && ( +

+ {form.formState.errors.home_park_id.message} +

+ )}

The theme park you visit most often or consider your "home" park

@@ -196,27 +372,63 @@ export function LocationTab() {
- form.setValue('timezone', value)} + > - {timezones.map(tz => + {COMMON_TIMEZONES.map(tz => ( + {tz} - )} + + ))} + {form.formState.errors.timezone && ( +

+ {form.formState.errors.timezone.message} +

+ )}

Used to display dates and times in your local timezone.

+ +
+ + + {form.formState.errors.preferred_language && ( +

+ {form.formState.errors.preferred_language.message} +

+ )} +
- {/* Personal Information */}
@@ -230,23 +442,28 @@ export function LocationTab() { -
- + + {form.formState.errors.preferred_pronouns && ( +

+ {form.formState.errors.preferred_pronouns.message} +

+ )}

How you'd like others to refer to you.

- -
- {/* Unit Preferences */}
@@ -276,15 +493,16 @@ export function LocationTab() { Imperial (mph, feet, inches) +

+ All measurements in the database are stored in metric and converted for display. +

-
- {/* Accessibility Options */}
@@ -300,7 +518,12 @@ export function LocationTab() {
- + updateAccessibility('font_size', value) + } + > @@ -319,7 +542,10 @@ export function LocationTab() { Increase contrast for better visibility

- updateAccessibility('high_contrast', checked)} /> + updateAccessibility('high_contrast', checked)} + />
@@ -329,17 +555,21 @@ export function LocationTab() { Minimize animations and transitions

- updateAccessibility('reduced_motion', checked)} /> + updateAccessibility('reduced_motion', checked)} + />
-
-
; -} \ No newline at end of file +
+ ); +} diff --git a/src/hooks/useUnitPreferences.ts b/src/hooks/useUnitPreferences.ts index 06202dcc..911ed2fc 100644 --- a/src/hooks/useUnitPreferences.ts +++ b/src/hooks/useUnitPreferences.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; +import { logger } from '@/lib/logger'; import { UnitPreferences, getMeasurementSystemFromCountry } from '@/lib/units'; const DEFAULT_PREFERENCES: UnitPreferences = { @@ -21,21 +22,28 @@ export function useUnitPreferences() { const loadPreferences = async () => { try { if (user) { - // Load from database for logged-in users - const { data } = await supabase + const { data, error } = await supabase .from('user_preferences') .select('unit_preferences') .eq('user_id', user.id) .maybeSingle(); + if (error && error.code !== 'PGRST116') { + logger.error('Failed to fetch unit preferences', { + userId: user.id, + action: 'fetch_unit_preferences', + error: error.message, + errorCode: error.code + }); + throw error; + } + if (data?.unit_preferences && typeof data.unit_preferences === 'object') { setPreferences({ ...DEFAULT_PREFERENCES, ...(data.unit_preferences as unknown as UnitPreferences) }); } else { - // Auto-detect for new users await autoDetectPreferences(); } } else { - // Check localStorage for anonymous users const stored = localStorage.getItem('unit_preferences'); if (stored) { try { @@ -49,7 +57,11 @@ export function useUnitPreferences() { } } } catch (error) { - console.error('Error loading unit preferences:', error); + logger.error('Error loading unit preferences', { + userId: user?.id, + action: 'load_unit_preferences', + error: error instanceof Error ? error.message : String(error) + }); await autoDetectPreferences(); } finally { setLoading(false); @@ -68,9 +80,7 @@ export function useUnitPreferences() { setPreferences(newPreferences); - // Save to database for logged-in users, localStorage for anonymous users if (user) { - // Use upsert with merge const { error } = await supabase .from('user_preferences') .upsert({ @@ -80,7 +90,11 @@ export function useUnitPreferences() { }); if (error) { - console.error('Error saving preferences to database:', error); + logger.error('Error saving auto-detected preferences', { + userId: user.id, + action: 'save_auto_detected_preferences', + error: error.message + }); } } else { localStorage.setItem('unit_preferences', JSON.stringify(newPreferences)); @@ -89,7 +103,11 @@ export function useUnitPreferences() { return newPreferences; } } catch (error) { - console.error('❌ Error auto-detecting location:', error); + logger.error('Error auto-detecting location', { + userId: user?.id, + action: 'auto_detect_location', + error: error instanceof Error ? error.message : String(error) + }); } // Fallback to default @@ -103,7 +121,6 @@ export function useUnitPreferences() { try { if (user) { - // Save to database for logged-in users await supabase .from('user_preferences') .update({ @@ -111,13 +128,20 @@ export function useUnitPreferences() { updated_at: new Date().toISOString() }) .eq('user_id', user.id); + + logger.info('Unit preferences updated', { + userId: user.id, + action: 'update_unit_preferences' + }); } else { - // Save to localStorage for anonymous users localStorage.setItem('unit_preferences', JSON.stringify(updated)); } } catch (error) { - console.error('Error saving unit preferences:', error); - // Revert on error + logger.error('Error saving unit preferences', { + userId: user?.id, + action: 'save_unit_preferences', + error: error instanceof Error ? error.message : String(error) + }); setPreferences(preferences); throw error; } diff --git a/src/lib/locationValidation.ts b/src/lib/locationValidation.ts new file mode 100644 index 00000000..0d9869e8 --- /dev/null +++ b/src/lib/locationValidation.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { personalLocationSchema, preferredPronounsSchema } from '@/lib/validation'; +import type { AccessibilityOptions } from '@/types/location'; + +/** + * Schema for accessibility options + */ +export const accessibilityOptionsSchema = z.object({ + font_size: z.enum(['small', 'medium', 'large'] as const), + high_contrast: z.boolean(), + reduced_motion: z.boolean() +}); + +/** + * Schema for location form data + */ +export const locationFormSchema = z.object({ + personal_location: personalLocationSchema, + home_park_id: z.string().uuid('Invalid park ID').optional().nullable(), + timezone: z.string().min(1, 'Timezone is required'), + preferred_language: z.string().min(2, 'Language code must be at least 2 characters').max(10), + preferred_pronouns: preferredPronounsSchema +}); + +/** + * Default accessibility options for new users + */ +export const DEFAULT_ACCESSIBILITY_OPTIONS: AccessibilityOptions = { + font_size: 'medium', + high_contrast: false, + reduced_motion: false +}; + +/** + * Common timezones for selection + */ +export const COMMON_TIMEZONES = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Anchorage', + 'America/Toronto', + 'America/Vancouver', + 'America/Mexico_City', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Rome', + 'Europe/Stockholm', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Bangkok', + 'Asia/Singapore', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', + 'Australia/Melbourne', + 'Pacific/Auckland' +] as const; + +/** + * Validate park option data + */ +export const parkOptionSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + location: z.object({ + city: z.string().optional(), + state_province: z.string().optional(), + country: z.string() + }).optional() +}); diff --git a/src/types/location.ts b/src/types/location.ts new file mode 100644 index 00000000..6cc46320 --- /dev/null +++ b/src/types/location.ts @@ -0,0 +1,54 @@ +/** + * Location & Info Type Definitions + * + * Centralized types for location, personal info, accessibility, and unit preferences. + */ + +import type { UnitPreferences } from '@/lib/units'; + +/** + * Accessibility options stored in user_preferences.accessibility_options + */ +export interface AccessibilityOptions { + font_size: 'small' | 'medium' | 'large'; + high_contrast: boolean; + reduced_motion: boolean; +} + +/** + * Profile location and personal information + */ +export interface ProfileLocationInfo { + personal_location: string | null; + home_park_id: string | null; + timezone: string; + preferred_language: string; + preferred_pronouns: string | null; +} + +/** + * Combined form data for Location tab + */ +export interface LocationFormData extends ProfileLocationInfo {} + +/** + * Park data for home park selection + */ +export interface ParkOption { + id: string; + name: string; + location?: { + city?: string; + state_province?: string; + country: string; + }; +} + +/** + * Complete location & info settings + */ +export interface LocationInfoSettings { + profile: ProfileLocationInfo; + accessibility: AccessibilityOptions; + unitPreferences: UnitPreferences; +}