Files
saas_frontend/src/application/profile/ProfilePage.tsx
T
2026-01-19 12:16:25 +05:30

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;