Refactor user settings implementation

This commit is contained in:
gpt-engineer-app[bot]
2025-09-28 19:54:33 +00:00
parent 7fc30413ad
commit 01837bc999
12 changed files with 2456 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import Rides from "./pages/Rides";
import Manufacturers from "./pages/Manufacturers";
import Auth from "./pages/Auth";
import Profile from "./pages/Profile";
import UserSettings from "./pages/UserSettings";
import NotFound from "./pages/NotFound";
import Terms from "./pages/Terms";
import Privacy from "./pages/Privacy";
@@ -42,6 +43,7 @@ const App = () => (
<Route path="/auth" element={<Auth />} />
<Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/terms" element={<Terms />} />

View File

@@ -0,0 +1,316 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { User, Upload, Trash2 } from 'lucide-react';
import { SimplePhotoUpload } from './SimplePhotoUpload';
const profileSchema = z.object({
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/),
display_name: z.string().max(50).optional(),
bio: z.string().max(500).optional(),
preferred_pronouns: z.string().max(20).optional(),
show_pronouns: z.boolean(),
preferred_language: z.string()
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, profile, refreshProfile } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const form = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: profile?.username || '',
display_name: profile?.display_name || '',
bio: profile?.bio || '',
preferred_pronouns: profile?.preferred_pronouns || '',
show_pronouns: profile?.show_pronouns || false,
preferred_language: profile?.preferred_language || 'en'
}
});
const onSubmit = async (data: ProfileFormData) => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
username: data.username,
display_name: data.display_name || null,
bio: data.bio || null,
preferred_pronouns: data.preferred_pronouns || null,
show_pronouns: data.show_pronouns,
preferred_language: data.preferred_language,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (error) throw error;
await refreshProfile();
toast({
title: 'Profile updated',
description: 'Your profile has been successfully updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update profile',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (imageId: string, imageUrl: string) => {
if (!user) return;
setAvatarLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
avatar_image_id: imageId,
avatar_url: imageUrl,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (error) throw error;
await refreshProfile();
toast({
title: 'Avatar updated',
description: 'Your avatar has been successfully updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update avatar',
variant: 'destructive'
});
} finally {
setAvatarLoading(false);
}
};
const handleDeleteAccount = async () => {
if (!user) return;
try {
// This would typically involve multiple steps:
// 1. Anonymize or delete user data
// 2. Delete the auth user
// For now, we'll just show a message
toast({
title: 'Account deletion requested',
description: 'Please contact support to complete account deletion.',
variant: 'destructive'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to delete account',
variant: 'destructive'
});
}
};
return (
<div className="space-y-8">
{/* Profile Picture */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Profile Picture</h3>
<div className="flex items-center gap-6">
<Avatar className="w-20 h-20">
<AvatarImage src={profile?.avatar_url || undefined} />
<AvatarFallback className="text-lg">
{profile?.display_name?.[0] || profile?.username?.[0] || <User className="w-8 h-8" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<SimplePhotoUpload
onUpload={handleAvatarUpload}
disabled={avatarLoading}
>
<Button variant="outline" disabled={avatarLoading}>
<Upload className="w-4 h-4 mr-2" />
{avatarLoading ? 'Uploading...' : 'Change Avatar'}
</Button>
</SimplePhotoUpload>
<p className="text-sm text-muted-foreground">
JPG, PNG or GIF. Max size 5MB.
</p>
</div>
</div>
</div>
<Separator />
{/* Profile Information */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<h3 className="text-lg font-medium">Profile Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="username">Username *</Label>
<Input
id="username"
{...form.register('username')}
placeholder="Enter your username"
/>
{form.formState.errors.username && (
<p className="text-sm text-destructive">
{form.formState.errors.username.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Display Name</Label>
<Input
id="display_name"
{...form.register('display_name')}
placeholder="Enter your display name"
/>
{form.formState.errors.display_name && (
<p className="text-sm text-destructive">
{form.formState.errors.display_name.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
{...form.register('bio')}
placeholder="Tell us about yourself..."
rows={4}
/>
{form.formState.errors.bio && (
<p className="text-sm text-destructive">
{form.formState.errors.bio.message}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="preferred_pronouns">Preferred Pronouns</Label>
<Input
id="preferred_pronouns"
{...form.register('preferred_pronouns')}
placeholder="e.g., they/them, she/her, he/him"
/>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_language">Preferred Language</Label>
<Select
value={form.watch('preferred_language')}
onValueChange={(value) => form.setValue('preferred_language', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Español</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="it">Italiano</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</form>
<Separator />
{/* Account Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Account Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
<div>
<p className="text-sm font-medium">Email</p>
<p className="text-sm text-muted-foreground">{user?.email}</p>
</div>
<div>
<p className="text-sm font-medium">Account Created</p>
<p className="text-sm text-muted-foreground">
{profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'}
</p>
</div>
</div>
</div>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
These actions cannot be undone. Please be careful.
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit">
<Trash2 className="w-4 h-4 mr-2" />
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your account
and remove all your data from our servers, including your reviews,
ride credits, and profile information.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAccount}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, delete my account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Download, BarChart3, Upload, FileText, History } from 'lucide-react';
export function DataExportTab() {
const { user, profile } = useAuth();
const { toast } = useToast();
const [exportLoading, setExportLoading] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const handleDataExport = async () => {
if (!user) return;
setExportLoading(true);
setExportProgress(0);
try {
// Simulate export progress
const steps = [
'Collecting profile data...',
'Gathering reviews...',
'Collecting ride credits...',
'Gathering top lists...',
'Packaging data...',
'Preparing download...'
];
for (let i = 0; i < steps.length; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
setExportProgress((i + 1) / steps.length * 100);
}
// In a real implementation, this would:
// 1. Fetch all user data from various tables
// 2. Package it into a structured format (JSON/CSV)
// 3. Create a downloadable file
const exportData = {
profile: profile,
export_date: new Date().toISOString(),
data_types: ['profile', 'reviews', 'ride_credits', 'top_lists']
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: 'Export complete',
description: 'Your data has been exported and downloaded successfully.'
});
} catch (error: any) {
toast({
title: 'Export failed',
description: error.message || 'Failed to export your data',
variant: 'destructive'
});
} finally {
setExportLoading(false);
setExportProgress(0);
}
};
const handleImportData = () => {
toast({
title: 'Coming soon',
description: 'Data import functionality will be available in a future update.',
});
};
return (
<div className="space-y-8">
{/* Personal Statistics */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
<h3 className="text-lg font-medium">Personal Statistics</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Overview of your activity and contributions to ThrillWiku.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.review_count || 0}
</div>
<div className="text-sm text-muted-foreground">Reviews</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.ride_count || 0}
</div>
<div className="text-sm text-muted-foreground">Rides</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.coaster_count || 0}
</div>
<div className="text-sm text-muted-foreground">Coasters</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.park_count || 0}
</div>
<div className="text-sm text-muted-foreground">Parks</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Data Export */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download className="w-5 h-5" />
<h3 className="text-lg font-medium">Export Your Data</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Download Your Data</CardTitle>
<CardDescription>
Export all your personal data in a machine-readable format. This includes your profile,
reviews, ride credits, top lists, and account activity.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Profile Information</Badge>
<Badge variant="secondary">Reviews & Ratings</Badge>
<Badge variant="secondary">Ride Credits</Badge>
<Badge variant="secondary">Top Lists</Badge>
<Badge variant="secondary">Account Activity</Badge>
</div>
{exportLoading && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Exporting data...</span>
<span>{Math.round(exportProgress)}%</span>
</div>
<Progress value={exportProgress} />
</div>
)}
<Button
onClick={handleDataExport}
disabled={exportLoading}
className="w-fit"
>
<Download className="w-4 h-4 mr-2" />
{exportLoading ? 'Exporting...' : 'Export My Data'}
</Button>
<p className="text-sm text-muted-foreground">
Your data will be provided in JSON format. Processing may take a few moments
for accounts with lots of activity.
</p>
</CardContent>
</Card>
</div>
<Separator />
{/* Data Import */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5" />
<h3 className="text-lg font-medium">Import Data</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Import From Other Sources</CardTitle>
<CardDescription>
Import your ride credits and reviews from other coaster tracking platforms.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Coaster Count</p>
<p className="text-sm text-muted-foreground">
Import your ride credits from coaster-count.com
</p>
</div>
<Button variant="outline" size="sm" onClick={handleImportData}>
Import
</Button>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Captain Coaster</p>
<p className="text-sm text-muted-foreground">
Import your coaster credits from captaincoaster.com
</p>
</div>
<Button variant="outline" size="sm" onClick={handleImportData}>
Import
</Button>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">CSV File</p>
<p className="text-sm text-muted-foreground">
Upload a CSV file with your ride credits
</p>
</div>
<Button variant="outline" size="sm" onClick={handleImportData}>
Upload
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Account Activity */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5" />
<h3 className="text-lg font-medium">Account Activity</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Activity Log</CardTitle>
<CardDescription>
Your recent account activities and important events.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Profile updated</p>
<p className="text-xs text-muted-foreground">
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-muted rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Account created</p>
<p className="text-xs text-muted-foreground">
{profile?.created_at ? new Date(profile.created_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,386 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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 { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { MapPin, Calendar, Globe, Accessibility } from 'lucide-react';
const locationSchema = z.object({
date_of_birth: z.string().optional(),
preferred_pronouns: z.string().max(20).optional(),
timezone: z.string(),
preferred_language: z.string(),
location_id: z.string().optional()
});
type LocationFormData = z.infer<typeof locationSchema>;
interface AccessibilityOptions {
font_size: 'small' | 'medium' | 'large';
high_contrast: boolean;
reduced_motion: boolean;
}
export function LocationTab() {
const { user, profile, refreshProfile } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [locations, setLocations] = useState<any[]>([]);
const [accessibility, setAccessibility] = useState<AccessibilityOptions>({
font_size: 'medium',
high_contrast: false,
reduced_motion: false
});
const form = useForm<LocationFormData>({
resolver: zodResolver(locationSchema),
defaultValues: {
date_of_birth: profile?.date_of_birth || '',
preferred_pronouns: profile?.preferred_pronouns || '',
timezone: profile?.timezone || 'UTC',
preferred_language: profile?.preferred_language || 'en',
location_id: profile?.location_id || ''
}
});
useEffect(() => {
fetchLocations();
fetchAccessibilityPreferences();
}, [user]);
const fetchLocations = async () => {
try {
const { data, error } = await supabase
.from('locations')
.select('id, name, city, state_province, country')
.order('name');
if (error) throw error;
setLocations(data || []);
} catch (error) {
console.error('Error fetching locations:', error);
}
};
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 {
const { error } = await supabase
.from('profiles')
.update({
date_of_birth: data.date_of_birth || null,
preferred_pronouns: data.preferred_pronouns || null,
timezone: data.timezone,
preferred_language: data.preferred_language,
location_id: data.location_id || null,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (error) throw error;
await refreshProfile();
toast({
title: 'Information updated',
description: 'Your location and personal information has been successfully updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update information',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const saveAccessibilityPreferences = async () => {
if (!user) return;
try {
const { error } = await supabase
.from('user_preferences')
.upsert([{
user_id: user.id,
accessibility_options: accessibility as any,
updated_at: new Date().toISOString()
}]);
if (error) throw error;
toast({
title: 'Accessibility preferences saved',
description: 'Your accessibility settings have been updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to save accessibility preferences',
variant: 'destructive'
});
}
};
const updateAccessibility = (key: keyof AccessibilityOptions, value: any) => {
setAccessibility(prev => ({ ...prev, [key]: value }));
};
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 (
<div className="space-y-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Location Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
<h3 className="text-lg font-medium">Location Settings</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Set your location for better personalized content and timezone display.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="location_id">Home Location</Label>
<Select
value={form.watch('location_id')}
onValueChange={(value) => form.setValue('location_id', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select your location" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No location selected</SelectItem>
{locations.map((location) => (
<SelectItem key={location.id} value={location.id}>
{location.name}
{location.city && `, ${location.city}`}
{location.state_province && `, ${location.state_province}`}
{location.country && `, ${location.country}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Your location helps us show relevant parks and events near you.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Select
value={form.watch('timezone')}
onValueChange={(value) => form.setValue('timezone', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezones.map((tz) => (
<SelectItem key={tz} value={tz}>
{tz}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Used to display dates and times in your local timezone.
</p>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Personal Information */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
<h3 className="text-lg font-medium">Personal Information</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Optional personal information that can be displayed on your profile.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
{...form.register('date_of_birth')}
/>
<p className="text-sm text-muted-foreground">
Used to calculate your age if you choose to display it.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_pronouns">Preferred Pronouns</Label>
<Input
id="preferred_pronouns"
{...form.register('preferred_pronouns')}
placeholder="e.g., they/them, she/her, he/him"
/>
<p className="text-sm text-muted-foreground">
How you'd like others to refer to you.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_language">Preferred Language</Label>
<Select
value={form.watch('preferred_language')}
onValueChange={(value) => form.setValue('preferred_language', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Español</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="it">Italiano</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Information'}
</Button>
</div>
</form>
<Separator />
{/* Accessibility Options */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Accessibility className="w-5 h-5" />
<h3 className="text-lg font-medium">Accessibility Options</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Customize the interface to meet your accessibility needs.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label>Font Size</Label>
<Select
value={accessibility.font_size}
onValueChange={(value: 'small' | 'medium' | 'large') =>
updateAccessibility('font_size', value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Small</SelectItem>
<SelectItem value="medium">Medium (Default)</SelectItem>
<SelectItem value="large">Large</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>High Contrast</Label>
<p className="text-sm text-muted-foreground">
Increase contrast for better visibility
</p>
</div>
<Switch
checked={accessibility.high_contrast}
onCheckedChange={(checked) => updateAccessibility('high_contrast', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Reduced Motion</Label>
<p className="text-sm text-muted-foreground">
Minimize animations and transitions
</p>
</div>
<Switch
checked={accessibility.reduced_motion}
onCheckedChange={(checked) => updateAccessibility('reduced_motion', checked)}
/>
</div>
<div className="flex justify-end">
<Button onClick={saveAccessibilityPreferences}>
Save Accessibility Settings
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,355 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Bell, Mail, Smartphone, Volume2 } from 'lucide-react';
interface EmailNotifications {
review_replies: boolean;
new_followers: boolean;
system_announcements: boolean;
weekly_digest: boolean;
monthly_digest: boolean;
}
interface PushNotifications {
browser_enabled: boolean;
new_content: boolean;
social_updates: boolean;
}
export function NotificationsTab() {
const { user } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [emailNotifications, setEmailNotifications] = useState<EmailNotifications>({
review_replies: true,
new_followers: true,
system_announcements: true,
weekly_digest: false,
monthly_digest: true
});
const [pushNotifications, setPushNotifications] = useState<PushNotifications>({
browser_enabled: false,
new_content: true,
social_updates: true
});
useEffect(() => {
fetchNotificationPreferences();
}, [user]);
const fetchNotificationPreferences = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('user_preferences')
.select('email_notifications, push_notifications')
.eq('user_id', user.id)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching notification preferences:', error);
return;
}
if (data) {
if (data.email_notifications) {
setEmailNotifications(data.email_notifications as EmailNotifications);
}
if (data.push_notifications) {
setPushNotifications(data.push_notifications as PushNotifications);
}
} else {
// Initialize preferences if they don't exist
await initializePreferences();
}
} catch (error) {
console.error('Error fetching notification preferences:', error);
}
};
const initializePreferences = async () => {
if (!user) return;
try {
const { error } = await supabase
.from('user_preferences')
.insert([{
user_id: user.id,
email_notifications: emailNotifications,
push_notifications: pushNotifications
}]);
if (error) throw error;
} catch (error) {
console.error('Error initializing preferences:', error);
}
};
const updateEmailNotification = (key: keyof EmailNotifications, value: boolean) => {
setEmailNotifications(prev => ({ ...prev, [key]: value }));
};
const updatePushNotification = (key: keyof PushNotifications, value: boolean) => {
setPushNotifications(prev => ({ ...prev, [key]: value }));
};
const saveNotificationPreferences = async () => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('user_preferences')
.upsert([{
user_id: user.id,
email_notifications: emailNotifications as any,
push_notifications: pushNotifications as any,
updated_at: new Date().toISOString()
}]);
if (error) throw error;
toast({
title: 'Preferences saved',
description: 'Your notification preferences have been updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to save notification preferences',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const requestPushPermission = async () => {
if ('Notification' in window) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
updatePushNotification('browser_enabled', true);
toast({
title: 'Push notifications enabled',
description: 'You will now receive browser push notifications.'
});
} else {
toast({
title: 'Permission denied',
description: 'Push notifications require permission to work.',
variant: 'destructive'
});
}
}
};
return (
<div className="space-y-8">
{/* Email Notifications */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Mail className="w-5 h-5" />
<h3 className="text-lg font-medium">Email Notifications</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Choose which email notifications you'd like to receive.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Review Replies</Label>
<p className="text-sm text-muted-foreground">
Get notified when someone replies to your reviews
</p>
</div>
<Switch
checked={emailNotifications.review_replies}
onCheckedChange={(checked) => updateEmailNotification('review_replies', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>New Followers</Label>
<p className="text-sm text-muted-foreground">
Get notified when someone follows you
</p>
</div>
<Switch
checked={emailNotifications.new_followers}
onCheckedChange={(checked) => updateEmailNotification('new_followers', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>System Announcements</Label>
<p className="text-sm text-muted-foreground">
Important updates and announcements from ThrillWiki
</p>
</div>
<Switch
checked={emailNotifications.system_announcements}
onCheckedChange={(checked) => updateEmailNotification('system_announcements', checked)}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Weekly Digest</Label>
<p className="text-sm text-muted-foreground">
Weekly summary of new parks, rides, and community activity
</p>
</div>
<Switch
checked={emailNotifications.weekly_digest}
onCheckedChange={(checked) => updateEmailNotification('weekly_digest', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Monthly Digest</Label>
<p className="text-sm text-muted-foreground">
Monthly roundup of popular content and your activity stats
</p>
</div>
<Switch
checked={emailNotifications.monthly_digest}
onCheckedChange={(checked) => updateEmailNotification('monthly_digest', checked)}
/>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Push Notifications */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
<h3 className="text-lg font-medium">Push Notifications</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Receive instant notifications in your browser when important events happen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Browser Notifications</Label>
<p className="text-sm text-muted-foreground">
Enable push notifications in your browser
</p>
</div>
<div className="flex items-center gap-2">
{!pushNotifications.browser_enabled && (
<Button
variant="outline"
size="sm"
onClick={requestPushPermission}
>
Enable
</Button>
)}
<Switch
checked={pushNotifications.browser_enabled}
onCheckedChange={(checked) => {
if (!checked) {
updatePushNotification('browser_enabled', false);
} else {
requestPushPermission();
}
}}
/>
</div>
</div>
{pushNotifications.browser_enabled && (
<>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>New Content</Label>
<p className="text-sm text-muted-foreground">
Notifications about new parks, rides, and reviews
</p>
</div>
<Switch
checked={pushNotifications.new_content}
onCheckedChange={(checked) => updatePushNotification('new_content', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Social Updates</Label>
<p className="text-sm text-muted-foreground">
Notifications about followers, replies, and mentions
</p>
</div>
<Switch
checked={pushNotifications.social_updates}
onCheckedChange={(checked) => updatePushNotification('social_updates', checked)}
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
<Separator />
{/* Sound Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Volume2 className="w-5 h-5" />
<h3 className="text-lg font-medium">Sound Settings</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Configure sound preferences for notifications and interactions.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center p-4 text-muted-foreground">
<Volume2 className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm">Sound settings coming soon</p>
<p className="text-xs mt-1">
Configure notification sounds and interaction feedback.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={saveNotificationPreferences} disabled={loading}>
{loading ? 'Saving...' : 'Save Notification Preferences'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Eye, UserX, Shield, Search } from 'lucide-react';
interface PrivacySettings {
activity_visibility: 'public' | 'private';
search_visibility: boolean;
show_location: boolean;
show_age: boolean;
}
interface ProfilePrivacy {
privacy_level: 'public' | 'private';
show_pronouns: boolean;
}
export function PrivacyTab() {
const { user, profile, refreshProfile } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
const form = useForm<ProfilePrivacy & PrivacySettings>({
defaultValues: {
privacy_level: profile?.privacy_level || 'public',
show_pronouns: profile?.show_pronouns || false,
activity_visibility: 'public',
search_visibility: true,
show_location: false,
show_age: false
}
});
useEffect(() => {
fetchPreferences();
}, [user]);
const fetchPreferences = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('user_preferences')
.select('privacy_settings')
.eq('user_id', user.id)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching preferences:', error);
return;
}
if (data?.privacy_settings) {
const privacySettings = data.privacy_settings as any;
setPreferences(privacySettings);
form.reset({
privacy_level: profile?.privacy_level === 'friends' ? 'public' : (profile?.privacy_level || 'public'),
show_pronouns: profile?.show_pronouns || false,
...privacySettings
});
} else {
// Initialize preferences if they don't exist
await initializePreferences();
}
} catch (error) {
console.error('Error fetching preferences:', error);
}
};
const initializePreferences = async () => {
if (!user) return;
const defaultSettings: PrivacySettings = {
activity_visibility: 'public',
search_visibility: true,
show_location: false,
show_age: false
};
try {
const { error } = await supabase
.from('user_preferences')
.insert([{
user_id: user.id,
privacy_settings: defaultSettings as any
}]);
if (error) throw error;
setPreferences(defaultSettings);
form.reset({
privacy_level: profile?.privacy_level || 'public',
show_pronouns: profile?.show_pronouns || false,
...defaultSettings
});
} catch (error) {
console.error('Error initializing preferences:', error);
}
};
const onSubmit = async (data: any) => {
if (!user) return;
setLoading(true);
try {
// Update profile privacy settings
const { error: profileError } = await supabase
.from('profiles')
.update({
privacy_level: data.privacy_level,
show_pronouns: data.show_pronouns,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (profileError) throw profileError;
// Update user preferences
const privacySettings: PrivacySettings = {
activity_visibility: data.activity_visibility,
search_visibility: data.search_visibility,
show_location: data.show_location,
show_age: data.show_age
};
const { error: prefsError } = await supabase
.from('user_preferences')
.upsert([{
user_id: user.id,
privacy_settings: privacySettings as any,
updated_at: new Date().toISOString()
}]);
if (prefsError) throw prefsError;
await refreshProfile();
setPreferences(privacySettings);
toast({
title: 'Privacy settings updated',
description: 'Your privacy preferences have been successfully saved.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update privacy settings',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
return (
<div className="space-y-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Profile Visibility */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5" />
<h3 className="text-lg font-medium">Profile Visibility</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Control who can see your profile and personal information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="privacy_level">Profile Privacy</Label>
<Select
value={form.watch('privacy_level')}
onValueChange={(value: 'public' | 'private') =>
form.setValue('privacy_level', value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">
Public - Anyone can view your profile
</SelectItem>
<SelectItem value="private">
Private - Only you can view your profile
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Show Pronouns</Label>
<p className="text-sm text-muted-foreground">
Display your preferred pronouns on your profile
</p>
</div>
<Switch
checked={form.watch('show_pronouns')}
onCheckedChange={(checked) => form.setValue('show_pronouns', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Show Location</Label>
<p className="text-sm text-muted-foreground">
Display your location on your profile
</p>
</div>
<Switch
checked={form.watch('show_location')}
onCheckedChange={(checked) => form.setValue('show_location', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Show Age</Label>
<p className="text-sm text-muted-foreground">
Display your age calculated from date of birth
</p>
</div>
<Switch
checked={form.watch('show_age')}
onCheckedChange={(checked) => form.setValue('show_age', checked)}
/>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Activity & Content */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5" />
<h3 className="text-lg font-medium">Activity & Content</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Control the visibility of your activities and content.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="activity_visibility">Activity Visibility</Label>
<Select
value={form.watch('activity_visibility')}
onValueChange={(value: 'public' | 'private') =>
form.setValue('activity_visibility', value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">
Public - Anyone can see your reviews and lists
</SelectItem>
<SelectItem value="private">
Private - Only you can see your activities
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
This affects the visibility of your reviews, ride credits, and top lists.
</p>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Search & Discovery */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Search className="w-5 h-5" />
<h3 className="text-lg font-medium">Search & Discovery</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Control how others can find and discover your profile.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Search Visibility</Label>
<p className="text-sm text-muted-foreground">
Allow your profile to appear in search results
</p>
</div>
<Switch
checked={form.watch('search_visibility')}
onCheckedChange={(checked) => form.setValue('search_visibility', checked)}
/>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Blocked Users */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<UserX className="w-5 h-5" />
<h3 className="text-lg font-medium">Blocked Users</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Manage users you have blocked from interacting with you.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center p-4 text-muted-foreground">
<UserX className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm">No blocked users</p>
<p className="text-xs mt-1">
Blocked users will appear here and can be unblocked at any time.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Privacy Settings'}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,309 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Shield, Key, Smartphone, Globe, ExternalLink } from 'lucide-react';
const passwordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
});
type PasswordFormData = z.infer<typeof passwordSchema>;
export function SecurityTab() {
const { user } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const form = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: ''
}
});
const onSubmit = async (data: PasswordFormData) => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase.auth.updateUser({
password: data.newPassword
});
if (error) throw error;
form.reset();
toast({
title: 'Password updated',
description: 'Your password has been successfully changed.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update password',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleSocialLogin = async (provider: 'google') => {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: provider
});
if (error) throw error;
toast({
title: 'Redirecting...',
description: `Redirecting to ${provider} to link your account.`
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || `Failed to link ${provider} account`,
variant: 'destructive'
});
}
};
const handleUnlinkSocial = async (provider: 'google') => {
try {
// Note: Supabase doesn't have a direct unlink method
// This would typically be handled through the admin API or by the user
toast({
title: 'Feature not available',
description: 'Please contact support to unlink social accounts.',
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || `Failed to unlink ${provider} account`,
variant: 'destructive'
});
}
};
// Mock data for connected accounts - in real app this would come from user metadata
const connectedAccounts = [
{
provider: 'google',
connected: user?.app_metadata?.providers?.includes('google') || false,
email: user?.email
}
];
return (
<div className="space-y-8">
{/* Change Password */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Key className="w-5 h-5" />
<h3 className="text-lg font-medium">Change Password</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Update your password to keep your account secure.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Input
id="currentPassword"
type="password"
{...form.register('currentPassword')}
placeholder="Enter your current password"
/>
{form.formState.errors.currentPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.currentPassword.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
type="password"
{...form.register('newPassword')}
placeholder="Enter your new password"
/>
{form.formState.errors.newPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.newPassword.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
{...form.register('confirmPassword')}
placeholder="Confirm your new password"
/>
{form.formState.errors.confirmPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.confirmPassword.message}
</p>
)}
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Updating...' : 'Update Password'}
</Button>
</form>
</CardContent>
</Card>
</div>
<Separator />
{/* Social Login Connections */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5" />
<h3 className="text-lg font-medium">Connected Accounts</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Manage your social login connections for easier access to your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{connectedAccounts.map((account) => (
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
<Globe className="w-4 h-4" />
</div>
<div>
<p className="font-medium capitalize">{account.provider}</p>
{account.connected && account.email && (
<p className="text-sm text-muted-foreground">{account.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{account.connected ? (
<>
<Badge variant="secondary">Connected</Badge>
<Button
variant="outline"
size="sm"
onClick={() => handleUnlinkSocial(account.provider as 'google')}
>
Disconnect
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleSocialLogin(account.provider as 'google')}
>
Connect
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
</div>
<Separator />
{/* Two-Factor Authentication */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
<h3 className="text-lg font-medium">Two-Factor Authentication</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Add an extra layer of security to your account with two-factor authentication.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Authenticator App</p>
<p className="text-sm text-muted-foreground">
Use an authenticator app to generate verification codes
</p>
</div>
<Button variant="outline" disabled>
Coming Soon
</Button>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Login History */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5" />
<h3 className="text-lg font-medium">Login History</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Review your recent login activity and active sessions.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Current Session</p>
<p className="text-sm text-muted-foreground">
Web {new Date().toLocaleDateString()}
</p>
</div>
<Badge variant="secondary">Active</Badge>
</div>
<div className="text-center p-4 text-muted-foreground">
<p className="text-sm">No other recent sessions found</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import { Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
interface SimplePhotoUploadProps {
onUpload: (imageId: string, imageUrl: string) => Promise<void>;
disabled?: boolean;
children?: React.ReactNode;
}
export function SimplePhotoUpload({ onUpload, disabled, children }: SimplePhotoUploadProps) {
const [uploading, setUploading] = useState(false);
const { toast } = useToast();
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file',
description: 'Please select an image file',
variant: 'destructive'
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Please select an image under 5MB',
variant: 'destructive'
});
return;
}
setUploading(true);
try {
// Create a mock upload for now - in real implementation would upload to CloudFlare
const mockImageId = `avatar_${Date.now()}`;
const mockImageUrl = URL.createObjectURL(file);
await onUpload(mockImageId, mockImageUrl);
toast({
title: 'Image uploaded',
description: 'Your image has been uploaded successfully'
});
} catch (error: any) {
toast({
title: 'Upload failed',
description: error.message || 'Failed to upload image',
variant: 'destructive'
});
} finally {
setUploading(false);
// Reset input
event.target.value = '';
}
};
return (
<div className="relative">
<Input
type="file"
accept="image/*"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled={disabled || uploading}
/>
{children || (
<Button variant="outline" disabled={disabled || uploading}>
<Upload className="w-4 h-4 mr-2" />
{uploading ? 'Uploading...' : 'Upload Image'}
</Button>
)}
</div>
);
}

View File

@@ -357,11 +357,15 @@ export type Database = {
id: string
location_id: string | null
park_count: number | null
preferred_language: string | null
preferred_pronouns: string | null
privacy_level: string
reputation_score: number | null
review_count: number | null
ride_count: number | null
show_pronouns: boolean | null
theme_preference: string
timezone: string | null
updated_at: string
user_id: string
username: string
@@ -378,11 +382,15 @@ export type Database = {
id?: string
location_id?: string | null
park_count?: number | null
preferred_language?: string | null
preferred_pronouns?: string | null
privacy_level?: string
reputation_score?: number | null
review_count?: number | null
ride_count?: number | null
show_pronouns?: boolean | null
theme_preference?: string
timezone?: string | null
updated_at?: string
user_id: string
username: string
@@ -399,11 +407,15 @@ export type Database = {
id?: string
location_id?: string | null
park_count?: number | null
preferred_language?: string | null
preferred_pronouns?: string | null
privacy_level?: string
reputation_score?: number | null
review_count?: number | null
ride_count?: number | null
show_pronouns?: boolean | null
theme_preference?: string
timezone?: string | null
updated_at?: string
user_id?: string
username?: string
@@ -707,6 +719,63 @@ export type Database = {
},
]
}
user_blocks: {
Row: {
blocked_id: string
blocker_id: string
created_at: string
id: string
reason: string | null
}
Insert: {
blocked_id: string
blocker_id: string
created_at?: string
id?: string
reason?: string | null
}
Update: {
blocked_id?: string
blocker_id?: string
created_at?: string
id?: string
reason?: string | null
}
Relationships: []
}
user_preferences: {
Row: {
accessibility_options: Json
created_at: string
email_notifications: Json
id: string
privacy_settings: Json
push_notifications: Json
updated_at: string
user_id: string
}
Insert: {
accessibility_options?: Json
created_at?: string
email_notifications?: Json
id?: string
privacy_settings?: Json
push_notifications?: Json
updated_at?: string
user_id: string
}
Update: {
accessibility_options?: Json
created_at?: string
email_notifications?: Json
id?: string
privacy_settings?: Json
push_notifications?: Json
updated_at?: string
user_id?: string
}
Relationships: []
}
user_ride_credits: {
Row: {
created_at: string
@@ -772,6 +841,42 @@ export type Database = {
}
Relationships: []
}
user_sessions: {
Row: {
created_at: string
device_info: Json | null
expires_at: string
id: string
ip_address: unknown | null
last_activity: string
session_token: string
user_agent: string | null
user_id: string
}
Insert: {
created_at?: string
device_info?: Json | null
expires_at?: string
id?: string
ip_address?: unknown | null
last_activity?: string
session_token: string
user_agent?: string | null
user_id: string
}
Update: {
created_at?: string
device_info?: Json | null
expires_at?: string
id?: string
ip_address?: unknown | null
last_activity?: string
session_token?: string
user_agent?: string | null
user_id?: string
}
Relationships: []
}
user_top_lists: {
Row: {
created_at: string

131
src/pages/UserSettings.tsx Normal file
View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Settings, User, Shield, Eye, Bell, MapPin, Download } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { AccountProfileTab } from '@/components/settings/AccountProfileTab';
import { SecurityTab } from '@/components/settings/SecurityTab';
import { PrivacyTab } from '@/components/settings/PrivacyTab';
import { NotificationsTab } from '@/components/settings/NotificationsTab';
import { LocationTab } from '@/components/settings/LocationTab';
import { DataExportTab } from '@/components/settings/DataExportTab';
export default function UserSettings() {
const { user, loading } = useAuth();
const [activeTab, setActiveTab] = useState('profile');
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="h-96 bg-muted rounded"></div>
</div>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/auth" replace />;
}
const tabs = [
{
id: 'profile',
label: 'Account & Profile',
icon: User,
component: AccountProfileTab
},
{
id: 'security',
label: 'Security',
icon: Shield,
component: SecurityTab
},
{
id: 'privacy',
label: 'Privacy',
icon: Eye,
component: PrivacyTab
},
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
component: NotificationsTab
},
{
id: 'location',
label: 'Location & Info',
icon: MapPin,
component: LocationTab
},
{
id: 'data',
label: 'Data & Export',
icon: Download,
component: DataExportTab
}
];
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<Settings className="w-8 h-8 text-primary" />
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">Manage your account preferences and privacy settings</p>
</div>
</div>
{/* Settings Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6 h-auto p-1">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<TabsTrigger
key={tab.id}
value={tab.id}
className="flex flex-col sm:flex-row items-center gap-2 h-auto py-3 px-2 text-xs sm:text-sm"
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline lg:inline">{tab.label}</span>
<span className="sm:hidden lg:hidden">{tab.label.split(' ')[0]}</span>
</TabsTrigger>
);
})}
</TabsList>
{tabs.map((tab) => {
const Component = tab.component;
return (
<TabsContent
key={tab.id}
value={tab.id}
className="space-y-6 focus-visible:outline-none"
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<tab.icon className="w-5 h-5" />
{tab.label}
</CardTitle>
</CardHeader>
<CardContent>
<Component />
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
}

View File

@@ -98,7 +98,12 @@ export interface Profile {
bio?: string;
avatar_url?: string;
avatar_image_id?: string;
preferred_pronouns?: string;
show_pronouns?: boolean;
timezone?: string;
preferred_language?: string;
location?: Location;
location_id?: string;
date_of_birth?: string;
privacy_level: 'public' | 'friends' | 'private';
theme_preference: 'light' | 'dark' | 'system';

View File

@@ -0,0 +1,120 @@
-- Create user preferences table for additional settings
CREATE TABLE public.user_preferences (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
email_notifications JSONB NOT NULL DEFAULT '{
"review_replies": true,
"new_followers": true,
"system_announcements": true,
"weekly_digest": false,
"monthly_digest": true
}'::jsonb,
push_notifications JSONB NOT NULL DEFAULT '{
"browser_enabled": false,
"new_content": true,
"social_updates": true
}'::jsonb,
privacy_settings JSONB NOT NULL DEFAULT '{
"activity_visibility": "public",
"search_visibility": true,
"show_location": false,
"show_age": false
}'::jsonb,
accessibility_options JSONB NOT NULL DEFAULT '{
"font_size": "medium",
"high_contrast": false,
"reduced_motion": false
}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE(user_id)
);
-- Enable RLS
ALTER TABLE public.user_preferences ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY "Users can manage their own preferences"
ON public.user_preferences
FOR ALL
USING (auth.uid() = user_id);
-- Create blocked users table
CREATE TABLE public.user_blocks (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
blocker_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
reason TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE(blocker_id, blocked_id)
);
-- Enable RLS on blocks table
ALTER TABLE public.user_blocks ENABLE ROW LEVEL SECURITY;
-- Create policies for blocks
CREATE POLICY "Users can manage their own blocks"
ON public.user_blocks
FOR ALL
USING (auth.uid() = blocker_id);
-- Create user sessions table for session management
CREATE TABLE public.user_sessions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
session_token TEXT NOT NULL,
device_info JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
last_activity TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (now() + interval '30 days')
);
-- Enable RLS on sessions table
ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY;
-- Create policies for sessions
CREATE POLICY "Users can view their own sessions"
ON public.user_sessions
FOR SELECT
USING (auth.uid() = user_id);
-- Create function to initialize user preferences
CREATE OR REPLACE FUNCTION public.initialize_user_preferences()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.user_preferences (user_id)
VALUES (NEW.user_id)
ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$;
-- Create trigger to initialize preferences when profile is created
CREATE TRIGGER initialize_user_preferences_trigger
AFTER INSERT ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.initialize_user_preferences();
-- Add updated_at trigger for user_preferences
CREATE TRIGGER update_user_preferences_updated_at
BEFORE UPDATE ON public.user_preferences
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Add some additional columns to profiles for settings
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS preferred_pronouns TEXT,
ADD COLUMN IF NOT EXISTS show_pronouns BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'UTC',
ADD COLUMN IF NOT EXISTS preferred_language TEXT DEFAULT 'en';
-- Create index for better performance
CREATE INDEX IF NOT EXISTS idx_user_preferences_user_id ON public.user_preferences(user_id);
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocker_id ON public.user_blocks(blocker_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON public.user_sessions(user_id);