mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Refactor user settings implementation
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
316
src/components/settings/AccountProfileTab.tsx
Normal file
316
src/components/settings/AccountProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
src/components/settings/DataExportTab.tsx
Normal file
290
src/components/settings/DataExportTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
386
src/components/settings/LocationTab.tsx
Normal file
386
src/components/settings/LocationTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
src/components/settings/NotificationsTab.tsx
Normal file
355
src/components/settings/NotificationsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
356
src/components/settings/PrivacyTab.tsx
Normal file
356
src/components/settings/PrivacyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
src/components/settings/SecurityTab.tsx
Normal file
309
src/components/settings/SecurityTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/settings/SimplePhotoUpload.tsx
Normal file
81
src/components/settings/SimplePhotoUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
131
src/pages/UserSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user