Files
flyer-crawler.projectium.com/components/ProfileManager.tsx

267 lines
14 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Session } from '@supabase/supabase-js';
import type { Profile } from '../types';
import { supabase, updateUserProfile, updateUserPassword, exportUserData, deleteUserAccount } from '../services/supabaseClient';
import { LoadingSpinner } from './LoadingSpinner';
import { XMarkIcon } from './icons/XMarkIcon';
interface ProfileManagerProps {
isOpen: boolean;
onClose: () => void;
session: Session;
profile: Profile;
onProfileUpdate: (updatedProfile: Profile) => void;
}
export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose, session, profile, onProfileUpdate }) => {
const [activeTab, setActiveTab] = useState('profile');
// Profile state
const [fullName, setFullName] = useState(profile.full_name || '');
const [avatarUrl, setAvatarUrl] = useState(profile.avatar_url || '');
const [profileLoading, setProfileLoading] = useState(false);
const [profileMessage, setProfileMessage] = useState('');
// Password state
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
// Data & Privacy state
const [exportLoading, setExportLoading] = useState(false);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [passwordForDelete, setPasswordForDelete] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState('');
useEffect(() => {
if (isOpen) {
// Reset state when modal opens
setFullName(profile.full_name || '');
setAvatarUrl(profile.avatar_url || '');
setActiveTab('profile');
setIsConfirmingDelete(false);
setPasswordForDelete('');
setDeleteError('');
setPasswordError('');
setPasswordMessage('');
}
}, [isOpen, profile]);
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
setProfileLoading(true);
setProfileMessage('');
try {
const updatedProfile = await updateUserProfile(session.user.id, {
full_name: fullName,
avatar_url: avatarUrl
});
onProfileUpdate(updatedProfile);
setProfileMessage('Profile updated successfully!');
} catch (error: any) {
setProfileMessage(error.message);
} finally {
setProfileLoading(false);
setTimeout(() => setProfileMessage(''), 3000);
}
};
const handlePasswordUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setPasswordError("Passwords do not match.");
return;
}
if (password.length < 6) {
setPasswordError("Password must be at least 6 characters long.");
return;
}
setPasswordLoading(true);
setPasswordError('');
setPasswordMessage('');
try {
await updateUserPassword(password);
setPasswordMessage("Password updated successfully!");
setPassword('');
setConfirmPassword('');
} catch (error: any) {
setPasswordError(error.message);
} finally {
setPasswordLoading(false);
setTimeout(() => {
setPasswordMessage('');
setPasswordError('');
}, 4000);
}
};
const handleExportData = async () => {
setExportLoading(true);
try {
const userData = await exportUserData(session.user.id);
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
const link = document.createElement("a");
link.href = jsonString;
link.download = `flyer-crawler-data-export-${new Date().toISOString().split('T')[0]}.json`;
link.click();
} catch (error: any) {
console.error("Failed to export data:", error);
alert(`Error exporting data: ${error.message}`);
} finally {
setExportLoading(false);
}
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setDeleteError('');
try {
await deleteUserAccount(passwordForDelete);
alert("Your account and all associated data have been permanently deleted.");
// The onAuthStateChange listener in App.tsx will handle the UI update
await supabase.auth.signOut();
onClose();
} catch (error: any) {
setDeleteError(error.message);
} finally {
setDeleteLoading(false);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
onClick={onClose}
aria-modal="true"
role="dialog"
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg relative"
onClick={e => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
aria-label="Close profile manager"
>
<XMarkIcon className="w-6 h-6" />
</button>
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">My Account</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Manage your profile, preferences, and security.</p>
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
<button onClick={() => setActiveTab('profile')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
Profile
</button>
<button onClick={() => setActiveTab('security')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'security' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
Security
</button>
<button onClick={() => setActiveTab('data')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'data' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
Data & Privacy
</button>
</nav>
</div>
{activeTab === 'profile' && (
<form onSubmit={handleProfileSave} className="space-y-4">
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
<input id="fullName" type="text" value={fullName} onChange={e => setFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
<div>
<label htmlFor="avatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label>
<input id="avatarUrl" type="url" value={avatarUrl} onChange={e => setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
<div className="pt-2">
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
</button>
{profileMessage && <p className="text-sm text-green-600 dark:text-green-400 text-center mt-2">{profileMessage}</p>}
</div>
</form>
)}
{activeTab === 'security' && (
<form onSubmit={handlePasswordUpdate} className="space-y-4">
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
<input id="newPassword" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••••" required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
<input id="confirmPassword" type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} placeholder="••••••••" required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
<div className="pt-2">
<button type="submit" disabled={passwordLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{passwordLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Update Password'}
</button>
{passwordError && <p className="text-sm text-red-600 dark:text-red-400 text-center mt-2">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-green-600 dark:text-green-400 text-center mt-2">{passwordMessage}</p>}
</div>
</form>
)}
{activeTab === 'data' && (
<div className="space-y-6">
<div>
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Export Your Data</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Download a JSON file of your profile, watched items, and shopping lists.</p>
<button onClick={handleExportData} disabled={exportLoading} className="mt-3 w-full sm:w-auto bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center">
{exportLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Export My Data'}
</button>
</div>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<div className="p-4 border border-red-500/50 dark:border-red-400/50 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h3 className="text-md font-semibold text-red-800 dark:text-red-300">Danger Zone</h3>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">This action is permanent and cannot be undone. All your data will be erased.</p>
{!isConfirmingDelete ? (
<button onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
Delete My Account
</button>
) : (
<form onSubmit={handleDeleteAccount} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50">
<p className="text-sm font-medium text-gray-800 dark:text-white">To confirm, please enter your current password.</p>
<div>
<label htmlFor="delete-password" className="sr-only">Current Password</label>
<input
id="delete-password"
type="password"
value={passwordForDelete}
onChange={e => setPasswordForDelete(e.target.value)}
required
placeholder="Enter your password"
className="block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm"
/>
</div>
{deleteError && <p className="text-xs text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{deleteError}</p>}
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0">
<button type="button" onClick={() => setIsConfirmingDelete(false)} className="flex-1 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg">
Cancel
</button>
<button type="submit" disabled={deleteLoading || !passwordForDelete} className="flex-1 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-bold py-2 px-4 rounded-lg flex justify-center">
{deleteLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Delete Account Permanently'}
</button>
</div>
</form>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};