570 lines
24 KiB
TypeScript
570 lines
24 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { useAuth } from "../../context/AuthContext";
|
|
import { CustomLoader, CustomModal, CustomInput, CustomButton } from "../../components/custom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { authApi } from "../authentication/AuthApi";
|
|
import { Key, Pencil } from "lucide-react";
|
|
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
|
|
import { useTheme } from "../../context/ThemeContext";
|
|
import { paletteApi } from "../theme/PaletteApi";
|
|
import type { ColorPalette } from "../theme/ThemeTypes";
|
|
|
|
type ProfileForm = {
|
|
firstName: string;
|
|
lastName: string;
|
|
phoneNumber: string;
|
|
};
|
|
|
|
const formatDate: FormatDateFunction = (value: string) => {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
const formatName: FormatNameFunction = (firstName: string, lastName?: string | null) =>
|
|
[firstName, lastName].filter(Boolean).join(" ");
|
|
|
|
const renderStatusBadge: RenderStatusBadgeFunction = (status?: string) => (
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ${status === "active"
|
|
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
}`}
|
|
>
|
|
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "Unknown"}
|
|
</span>
|
|
);
|
|
|
|
const ProfilePage: React.FC = () => {
|
|
const { t } = useTranslation(['profile', 'common']);
|
|
const { user, isLoading, refreshUser } = useAuth();
|
|
|
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
|
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
const [passwordError, setPasswordError] = useState("");
|
|
const [passwordSuccess, setPasswordSuccess] = useState("");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [profileForm, setProfileForm] = useState<ProfileForm>({
|
|
firstName: "",
|
|
lastName: "",
|
|
phoneNumber: "",
|
|
});
|
|
const [profileError, setProfileError] = useState("");
|
|
const [profileSuccess, setProfileSuccess] = useState("");
|
|
const [isProfileSubmitting, setIsProfileSubmitting] = useState(false);
|
|
|
|
// Theme Logic
|
|
const { currentPalette, setTheme } = useTheme();
|
|
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
|
|
const [isThemesLoading, setIsThemesLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchPalettes = async () => {
|
|
setIsThemesLoading(true);
|
|
try {
|
|
const data = await paletteApi.getAllPalettes();
|
|
setPalettes(data);
|
|
} catch (error) {
|
|
console.error("Failed to fetch palettes", error);
|
|
} finally {
|
|
setIsThemesLoading(false);
|
|
}
|
|
};
|
|
fetchPalettes();
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<CustomLoader />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-6 text-sm text-yellow-800">
|
|
{t('messages.loadError')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fullName = formatName(user.first_name, user.last_name);
|
|
const initials =
|
|
user.first_name && user.last_name
|
|
? `${user.first_name[0]}${user.last_name[0]}`
|
|
: user.first_name
|
|
? user.first_name[0]
|
|
: "U";
|
|
|
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
setPasswordForm((prev) => ({ ...prev, [name]: value }));
|
|
setPasswordError("");
|
|
};
|
|
|
|
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setPasswordError("");
|
|
setPasswordSuccess("");
|
|
|
|
// Client-side validation
|
|
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
|
|
setPasswordError(t('messages.requiredFields'));
|
|
return;
|
|
}
|
|
|
|
if (passwordForm.newPassword.length < 8) {
|
|
setPasswordError(t('messages.passwordLength'));
|
|
return;
|
|
}
|
|
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
|
setPasswordError(t('messages.passwordMismatch'));
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
const response = await authApi.resetPassword(
|
|
passwordForm.currentPassword,
|
|
passwordForm.newPassword
|
|
);
|
|
setPasswordSuccess(response.message || t('messages.passwordSuccess'));
|
|
setPasswordForm({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
|
|
// Close modal after 1.5 seconds
|
|
setTimeout(() => {
|
|
setIsPasswordModalOpen(false);
|
|
setPasswordSuccess("");
|
|
}, 1500);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to update password";
|
|
setPasswordError(errorMessage);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordModalClose = () => {
|
|
setIsPasswordModalOpen(false);
|
|
setPasswordForm({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
setPasswordError("");
|
|
setPasswordSuccess("");
|
|
};
|
|
|
|
// Edit Profile handlers
|
|
const handleOpenEditModal = () => {
|
|
if (user) {
|
|
setProfileForm({
|
|
firstName: user.first_name || "",
|
|
lastName: user.last_name || "",
|
|
phoneNumber: user.phone_number || "",
|
|
});
|
|
}
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
setProfileForm((prev) => ({ ...prev, [name]: value }));
|
|
setProfileError("");
|
|
};
|
|
|
|
const handleProfileSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setProfileError("");
|
|
setProfileSuccess("");
|
|
|
|
if (!user) return;
|
|
|
|
if (!profileForm.firstName.trim()) {
|
|
setProfileError(t('messages.firstNameRequired'));
|
|
return;
|
|
}
|
|
|
|
setIsProfileSubmitting(true);
|
|
|
|
try {
|
|
await authApi.updateProfile(user.id, {
|
|
first_name: profileForm.firstName.trim(),
|
|
last_name: profileForm.lastName.trim() || undefined,
|
|
phone_number: profileForm.phoneNumber.trim() || undefined,
|
|
});
|
|
setProfileSuccess(t('messages.profileSuccess'));
|
|
|
|
// Refresh user data
|
|
if (refreshUser) {
|
|
await refreshUser();
|
|
}
|
|
|
|
// Close modal after 1.5 seconds
|
|
setTimeout(() => {
|
|
setIsEditModalOpen(false);
|
|
setProfileSuccess("");
|
|
}, 1500);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to update profile";
|
|
setProfileError(errorMessage);
|
|
} finally {
|
|
setIsProfileSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleEditModalClose = () => {
|
|
setIsEditModalOpen(false);
|
|
setProfileError("");
|
|
setProfileSuccess("");
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
{t('subTitle')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Profile Card */}
|
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
|
{/* User Header Section */}
|
|
<div className="border-b border-(--card-border) bg-(--table-row-hover) px-6 py-8">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
{/* User Info */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-indigo-600 text-2xl font-bold text-white shadow-lg">
|
|
{initials}
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-(--text-primary)">{fullName}</h2>
|
|
<p className="text-sm text-(--text-secondary)">{user.email}</p>
|
|
<div className="mt-2">{renderStatusBadge(user.status)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="md:self-center flex flex-col gap-2">
|
|
<CustomButton
|
|
variant="outlined"
|
|
onClick={() => setIsPasswordModalOpen(true)}
|
|
>
|
|
<Key size={16} className="mr-2" />
|
|
{t('buttons.changePassword')}
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="outlined"
|
|
onClick={handleOpenEditModal}
|
|
>
|
|
<Pencil size={16} className="mr-2" />
|
|
{t('buttons.editProfile')}
|
|
</CustomButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Details Section */}
|
|
<div className="p-6">
|
|
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
|
{t('sections.accountInfo')}
|
|
</h3>
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
{/* First Name */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.firstName')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.first_name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Last Name */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.lastName')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.last_name || "--"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.email')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.email}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Phone Number */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.phone')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.phone_number || "--"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tenant */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.tenant')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.tenant_name || "--"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Role */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.role')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{user.role?.role_name || "--"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Account Created */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.accountCreated')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{formatDate(user.created_at)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Last Updated */}
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
|
{t('fields.lastUpdated')}
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
{formatDate(user.updated_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Appearance / Theme Section */}
|
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
|
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
|
{t('sections.appearance')}
|
|
</h3>
|
|
<p className="text-sm text-[var(--text-secondary)] mb-6">
|
|
{t('sections.appearanceDesc')}
|
|
</p>
|
|
|
|
{isThemesLoading ? (
|
|
<div className="flex justify-center py-4">
|
|
<CustomLoader />
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{palettes.map((palette) => (
|
|
<button
|
|
key={palette.id}
|
|
onClick={() => setTheme(palette)}
|
|
className={`
|
|
relative flex items-center p-4 border rounded-xl text-left transition-all hover:shadow-md
|
|
${currentPalette?.id === palette.id
|
|
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
|
|
: "border-[var(--card-border)] bg-[var(--card-bg)] hover:border-[var(--text-secondary)]"
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex-1">
|
|
<h4 className={`font-medium ${currentPalette?.id === palette.id ? "text-blue-700" : "text-[var(--text-primary)]"}`}>
|
|
{palette.name}
|
|
</h4>
|
|
{palette.description && (
|
|
<p className={`text-xs mt-1 ${currentPalette?.id === palette.id ? "text-blue-600" : "text-[var(--text-secondary)]"}`}>
|
|
{palette.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Color Preview Circles */}
|
|
<div className="flex -space-x-2 overflow-hidden ml-4">
|
|
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.sidebar_bg }} />
|
|
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.primary }} />
|
|
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.background }} />
|
|
</div>
|
|
|
|
{currentPalette?.id === palette.id && (
|
|
<div className="absolute top-0 right-0 -mt-2 -mr-2 bg-blue-500 text-white rounded-full p-1 shadow-sm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Change Password Modal */}
|
|
<CustomModal
|
|
isOpen={isPasswordModalOpen}
|
|
onClose={handlePasswordModalClose}
|
|
title={t('modals.changePasswordTitle')}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<CustomButton
|
|
variant="outlined"
|
|
onClick={handlePasswordModalClose}
|
|
disabled={isSubmitting}
|
|
>
|
|
{t('common:actions.cancel')}
|
|
</CustomButton>
|
|
<CustomButton
|
|
type="submit"
|
|
form="change-password-form"
|
|
variant="primary"
|
|
loading={isSubmitting}
|
|
>
|
|
{t('buttons.updatePassword')}
|
|
</CustomButton>
|
|
</>
|
|
}
|
|
>
|
|
<form id="change-password-form" onSubmit={handlePasswordSubmit} className="space-y-4">
|
|
<CustomInput
|
|
label={t('modals.currentPassword')}
|
|
name="currentPassword"
|
|
type="password"
|
|
value={passwordForm.currentPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
placeholder={t('modals.currentPasswordPlaceholder')}
|
|
/>
|
|
<CustomInput
|
|
label={t('modals.newPassword')}
|
|
name="newPassword"
|
|
type="password"
|
|
value={passwordForm.newPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
placeholder={t('modals.newPasswordPlaceholder')}
|
|
/>
|
|
<CustomInput
|
|
label={t('modals.confirmPassword')}
|
|
name="confirmPassword"
|
|
type="password"
|
|
value={passwordForm.confirmPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
placeholder={t('modals.confirmPasswordPlaceholder')}
|
|
/>
|
|
|
|
{passwordError && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
|
{passwordError}
|
|
</div>
|
|
)}
|
|
|
|
{passwordSuccess && (
|
|
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
|
|
{passwordSuccess}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</CustomModal>
|
|
|
|
{/* Edit Profile Modal */}
|
|
<CustomModal
|
|
isOpen={isEditModalOpen}
|
|
onClose={handleEditModalClose}
|
|
title={t('modals.editProfileTitle')}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<CustomButton
|
|
variant="outlined"
|
|
onClick={handleEditModalClose}
|
|
disabled={isProfileSubmitting}
|
|
>
|
|
{t('common:actions.cancel')}
|
|
</CustomButton>
|
|
<CustomButton
|
|
type="submit"
|
|
form="edit-profile-form"
|
|
variant="primary"
|
|
loading={isProfileSubmitting}
|
|
>
|
|
{t('buttons.saveChanges')}
|
|
</CustomButton>
|
|
</>
|
|
}
|
|
>
|
|
<form id="edit-profile-form" onSubmit={handleProfileSubmit} className="space-y-4">
|
|
<CustomInput
|
|
label={t('fields.firstName')}
|
|
name="firstName"
|
|
value={profileForm.firstName}
|
|
onChange={handleProfileChange}
|
|
required
|
|
placeholder={t('modals.firstNamePlaceholder')}
|
|
/>
|
|
<CustomInput
|
|
label={t('fields.lastName')}
|
|
name="lastName"
|
|
value={profileForm.lastName}
|
|
onChange={handleProfileChange}
|
|
placeholder={t('modals.lastNamePlaceholder')}
|
|
/>
|
|
<CustomInput
|
|
label={t('fields.phone')}
|
|
name="phoneNumber"
|
|
type="tel"
|
|
value={profileForm.phoneNumber}
|
|
onChange={handleProfileChange}
|
|
placeholder={t('modals.phonePlaceholder')}
|
|
/>
|
|
|
|
{profileError && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
|
{profileError}
|
|
</div>
|
|
)}
|
|
|
|
{profileSuccess && (
|
|
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
|
|
{profileSuccess}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</CustomModal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProfilePage;
|