Files
flyer-crawler.projectium.com/src/pages/admin/components/ProfileManager.tsx

661 lines
29 KiB
TypeScript

// src/pages/admin/components/ProfileManager.tsx
import React, { useState, useEffect } from 'react';
import type { Profile, Address, UserProfile } from '../../../types';
import { useApi } from '../../../hooks/useApi';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
import { logger } from '../../../services/logger.client';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { ConfirmationModal } from '../../../components/ConfirmationModal'; // This path is correct
import { PasswordInput } from '../../../components/PasswordInput';
import { MapView } from '../../../components/MapView';
import type { AuthStatus } from '../../../hooks/useAuth';
import { AuthView } from './AuthView';
import { AddressForm } from './AddressForm';
import { useProfileAddress } from '../../../hooks/useProfileAddress';
export interface ProfileManagerProps {
isOpen: boolean;
onClose: () => void;
authStatus: AuthStatus;
userProfile: UserProfile | null; // Can be null for login/register
onProfileUpdate: (updatedProfile: Profile) => void;
onSignOut: () => void;
onLoginSuccess: (user: UserProfile, token: string, rememberMe: boolean) => void; // Add login handler
}
// --- API Hook Wrappers ---
// These wrappers adapt the apiClient functions (which expect an ApiOptions object)
// to the signature expected by the useApi hook (which passes a raw AbortSignal).
// They are defined outside the component to ensure they have a stable identity
// across re-renders, preventing infinite loops in useEffect hooks.
const updateAddressWrapper = (data: Partial<Address>, signal?: AbortSignal) =>
apiClient.updateUserAddress(data, { signal });
const updatePasswordWrapper = (password: string, signal?: AbortSignal) =>
apiClient.updateUserPassword(password, { signal });
const exportDataWrapper = (signal?: AbortSignal) => apiClient.exportUserData({ signal });
const deleteAccountWrapper = (password: string, signal?: AbortSignal) =>
apiClient.deleteUserAccount(password, { signal });
const updatePreferencesWrapper = (prefs: Partial<Profile['preferences']>, signal?: AbortSignal) =>
apiClient.updateUserPreferences(prefs, { signal });
const updateProfileWrapper = (data: Partial<Profile>, signal?: AbortSignal) =>
apiClient.updateUserProfile(data, { signal });
export const ProfileManager: React.FC<ProfileManagerProps> = ({
isOpen,
onClose,
authStatus,
userProfile,
onProfileUpdate,
onSignOut,
onLoginSuccess,
}) => {
const [activeTab, setActiveTab] = useState('profile');
// Profile state
const [fullName, setFullName] = useState(userProfile?.full_name || '');
const [avatarUrl, setAvatarUrl] = useState(userProfile?.avatar_url || '');
// Address logic is now encapsulated in this custom hook.
const { address, initialAddress, isGeocoding, handleAddressChange, handleManualGeocode } =
useProfileAddress(userProfile, isOpen);
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(
updateProfileWrapper,
);
const { execute: updateAddress, loading: addressLoading } = useApi<Address, [Partial<Address>]>(
updateAddressWrapper,
);
// Password state
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { execute: updatePassword, loading: passwordLoading } = useApi<unknown, [string]>(
updatePasswordWrapper,
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Data & Privacy state
const { execute: exportData, loading: exportLoading } = useApi<unknown, []>(exportDataWrapper);
const { execute: deleteAccount, loading: deleteLoading } = useApi<unknown, [string]>(
deleteAccountWrapper,
);
// Preferences state
const { execute: updatePreferences } = useApi<Profile, [Partial<Profile['preferences']>]>(
updatePreferencesWrapper,
);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [passwordForDelete, setPasswordForDelete] = useState('');
useEffect(() => {
// Only reset state when the modal is opened.
// Do not reset on profile changes, which can happen during sign-out.
logger.debug('[useEffect] Running effect due to change in isOpen or userProfile.', {
isOpen,
profileExists: !!userProfile,
});
if (isOpen && userProfile) {
// Ensure userProfile exists before setting state
logger.debug('[useEffect] Modal is open with a valid profile. Resetting component state.');
setFullName(userProfile.full_name || '');
setAvatarUrl(userProfile.avatar_url || '');
setActiveTab('profile');
setIsConfirmingDelete(false);
setPasswordForDelete('');
}
}, [isOpen, userProfile]); // Depend on isOpen and userProfile
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
logger.debug('[handleProfileSave] Save process started.');
if (!userProfile) {
notifyError('Cannot save profile, no user is logged in.');
logger.warn('[handleProfileSave] Aborted: No user is logged in.');
return;
}
// Determine if profile or address data has changed
const profileDataChanged =
fullName !== userProfile?.full_name || avatarUrl !== userProfile?.avatar_url;
const addressDataChanged = JSON.stringify(address) !== JSON.stringify(initialAddress);
// --- Start Debug Logging ---
logger.debug('[handleProfileSave] Checking for data changes.', {
profileDataChanged,
addressDataChanged,
currentFullName: fullName,
initialFullName: userProfile?.full_name,
currentAvatarUrl: avatarUrl,
initialAvatarUrl: userProfile?.avatar_url,
currentAddress: JSON.stringify(address),
initialAddress: JSON.stringify(initialAddress),
});
// --- End Debug Logging ---
if (!profileDataChanged && !addressDataChanged) {
notifySuccess('No changes to save.');
logger.debug('[handleProfileSave] No changes detected. Closing modal.');
onClose();
return;
}
// Create an array of promises for the API calls that need to be made.
// Because useApi() catches errors and returns null, we can safely use Promise.all.
const promisesToRun = [];
if (profileDataChanged) {
logger.debug('[handleProfileSave] Queuing profile update promise.');
promisesToRun.push(updateProfile({ full_name: fullName, avatar_url: avatarUrl }));
}
if (addressDataChanged) {
logger.debug('[handleProfileSave] Queuing address update promise.');
promisesToRun.push(updateAddress(address));
}
try {
logger.debug(`[handleProfileSave] Awaiting ${promisesToRun.length} promises...`);
// Use Promise.allSettled to handle partial successes gracefully.
const results = await Promise.allSettled(promisesToRun);
logger.debug('[handleProfileSave] Promise.allSettled finished.', { results });
let anyFailures = false;
let successfulProfileUpdate: Profile | null = null;
// Determine which promises succeeded or failed.
results.forEach((result, index) => {
const isProfilePromise = profileDataChanged && index === 0;
if (result.status === 'rejected' || (result.status === 'fulfilled' && !result.value)) {
anyFailures = true;
} else if (result.status === 'fulfilled' && isProfilePromise) {
successfulProfileUpdate = result.value as Profile;
}
});
// If the profile update itself was successful, notify the parent component.
if (successfulProfileUpdate) {
onProfileUpdate(successfulProfileUpdate);
}
// Only show the global success message and close the modal if everything worked.
if (!anyFailures) {
notifySuccess('Profile updated successfully!');
onClose();
} else {
logger.warn(
'[handleProfileSave] One or more operations failed. The useApi hook should have shown an error. The modal will remain open.',
);
}
} catch (error) {
// This catch block is a safeguard. In normal operation, the useApi hook
// should prevent any promises from rejecting.
logger.error(
{ err: error },
"[CRITICAL] An unexpected error was caught directly in handleProfileSave's catch block.",
);
notifyError(
`An unexpected critical error occurred: ${error instanceof Error ? error.message : String(error)}`,
);
}
// This log confirms the function has completed its execution.
logger.debug('[handleProfileSave] Save process finished.');
};
const handleOAuthLink = async (provider: 'google' | 'github') => {
// This will redirect the user to the OAuth provider to link the account.
// TODO: This is a placeholder. Implement OAuth account linking via the Passport.js backend.
if (!userProfile) {
return; // Should not be possible to see this button if not logged in
}
const errorMessage = `Account linking with ${provider} is not yet implemented.`; // This was a duplicate, fixed.
logger.warn(errorMessage, { userId: userProfile.user.user_id });
notifyError(errorMessage);
};
const handlePasswordUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
notifyError('Passwords do not match.');
return;
}
if (password.length < 6) {
notifyError('Password must be at least 6 characters long.');
return;
}
const result = await updatePassword(password);
if (result) {
notifySuccess('Password updated successfully!');
setPassword('');
setConfirmPassword('');
}
};
const handleExportData = async () => {
const userData = await exportData();
if (userData) {
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();
}
};
const handleDeleteAccount = async () => {
setIsDeleteModalOpen(false); // Close the confirmation modal
const result = await deleteAccount(passwordForDelete);
if (result) {
// useApi returns null on failure, so this check is sufficient.
notifySuccess('Account deleted successfully. You will be logged out shortly.');
setTimeout(() => {
onClose();
onSignOut();
}, 3000);
}
};
const handleToggleDarkMode = async (newMode: boolean) => {
const updatedProfile = await updatePreferences({ darkMode: newMode });
if (updatedProfile) {
onProfileUpdate(updatedProfile);
}
};
const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => {
const updatedProfile = await updatePreferences({ unitSystem: newSystem });
if (updatedProfile) {
onProfileUpdate(updatedProfile);
}
};
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"
>
<ConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteAccount} // Pass the handler directly
title="Confirm Account Deletion"
message={
<>
Are you absolutely sure you want to delete your account?
<strong className="block mt-2">This action is permanent and cannot be undone.</strong>
</>
}
confirmButtonText="Yes, Delete My Account"
/>
<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>
{authStatus === 'SIGNED_OUT' ? (
<AuthView onLoginSuccess={onLoginSuccess} onClose={onClose} />
) : (
// Wrap the authenticated view in a React Fragment to return a single element
<>
<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="mb-6">
<nav className="flex space-x-2" aria-label="Tabs">
<button
onClick={() => setActiveTab('profile')}
className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'profile' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Profile
</button>
<button
onClick={() => setActiveTab('security')}
className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'security' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Security
</button>
<button
onClick={() => setActiveTab('data')}
className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'data' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Data & Privacy
</button>
<button
onClick={() => setActiveTab('preferences')}
className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'preferences' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Preferences
</button>
</nav>
</div>
{activeTab === 'profile' && (
<form aria-label="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="border-t border-gray-200 dark:border-gray-700 pt-4">
<AddressForm
address={address}
onAddressChange={handleAddressChange}
onGeocode={handleManualGeocode}
isGeocoding={isGeocoding}
/>
</div>
{address.latitude && address.longitude && (
<div className="pt-4" data-testid="map-view-container">
<MapView latitude={address.latitude} longitude={address.longitude} />
</div>
)}
<div className="pt-2">
<button
type="submit"
disabled={profileLoading || addressLoading}
className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors"
>
{profileLoading || addressLoading ? (
<div className="w-5 h-5">
<LoadingSpinner />
</div>
) : (
'Save Profile'
)}
</button>
</div>
</form>
)}
{activeTab === 'security' && (
<form
data-testid="update-password-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>
<PasswordInput
id="newPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="mt-1"
showStrength
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Confirm New Password
</label>
<PasswordInput
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
className="mt-1"
/>
</div>
<div className="pt-2">
<button
type="submit"
disabled={passwordLoading}
className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors"
>
{passwordLoading ? (
<div className="w-5 h-5">
<LoadingSpinner />
</div>
) : (
'Update Password'
)}
</button>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-md font-semibold text-gray-800 dark:text-white">
Link Social Accounts
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-3">
Connect other accounts for easier sign-in.
</p>
<div className="space-y-3">
<button
type="button"
onClick={() => handleOAuthLink('google')}
className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<GoogleIcon className="w-5 h-5 mr-3" />
Link Google Account
</button>
<button
type="button"
onClick={() => handleOAuthLink('github')}
className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<GithubIcon className="w-5 h-5 mr-3" />
Link GitHub Account
</button>
</div>
</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-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold py-2 px-4 rounded-lg flex items-center justify-center border border-gray-300 dark:border-gray-600 shadow-sm transition-colors"
>
{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
type="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 shadow-sm transition-colors"
>
Delete My Account
</button>
) : (
<form
data-testid="delete-account-form"
onSubmit={(e) => {
e.preventDefault();
setIsDeleteModalOpen(true);
}}
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>
<PasswordInput
id="delete-password"
value={passwordForDelete}
onChange={(e) => setPasswordForDelete(e.target.value)}
required
placeholder="Enter your password"
/>
<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 shadow-sm transition-colors"
>
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 shadow-sm transition-colors"
>
{deleteLoading ? (
<div className="w-5 h-5">
<LoadingSpinner />
</div>
) : (
'Delete Account Permanently'
)}
</button>
</div>
</form>
)}
</div>
</div>
)}
{activeTab === 'preferences' && (
<div className="space-y-6">
<div>
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Theme</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Choose your preferred visual theme.
</p>
<div className="mt-3 flex items-center">
<label htmlFor="darkModeToggle" className="flex items-center cursor-pointer">
<div className="relative">
<input
type="checkbox"
id="darkModeToggle"
className="sr-only"
checked={userProfile?.preferences?.darkMode ?? false}
onChange={(e) => handleToggleDarkMode(e.target.checked)}
/>
<div className="block bg-gray-300 dark:bg-gray-600 w-14 h-8 rounded-full"></div>
<div className="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"></div>
</div>
<div className="ml-3 text-gray-700 dark:text-gray-300 font-medium">
Dark Mode
</div>
</label>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-md font-semibold text-gray-800 dark:text-white">
Unit System
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select your preferred system of measurement.
</p>
<div className="mt-3 flex space-x-4">
<label className="inline-flex items-center">
<input
type="radio"
className="form-radio text-brand-primary"
name="unitSystem"
value="imperial"
checked={userProfile?.preferences?.unitSystem === 'imperial'}
onChange={() => handleToggleUnitSystem('imperial')}
/>
<span className="ml-2 text-gray-700 dark:text-gray-300">
Imperial (e.g., lbs, oz)
</span>
</label>
<label className="inline-flex items-center">
<input
type="radio"
className="form-radio text-brand-primary"
name="unitSystem"
value="metric"
checked={userProfile?.preferences?.unitSystem === 'metric'}
onChange={() => handleToggleUnitSystem('metric')}
/>
<span className="ml-2 text-gray-700 dark:text-gray-300">
Metric (e.g., kg, g)
</span>
</label>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
);
};