Files
Checkmate/client/src/Pages/Account/components/ProfilePanel.jsx
T
2026-01-12 23:57:39 +00:00

430 lines
11 KiB
React

import { useTheme } from "@emotion/react";
import { useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import Avatar from "@/Components/v1/Avatar/index.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import ImageUpload from "@/Components/v1/Inputs/ImageUpload/index.jsx";
import { newOrChangedCredentials } from "../../../Validation/validation.js";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice.js";
import { createToast } from "../../../Utils/toastUtils.jsx";
import { logger } from "../../../Utils/Logger.js";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
import Dialog from "@/Components/v1/Dialog/index.jsx";
import { useTranslation } from "react-i18next";
/**
* ProfilePanel component displays a form for editing user profile information
* and allows for actions like updating profile picture, credentials,
* and deleting account.
*
* @returns {JSX.Element}
*/
const ProfilePanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const SPACING_GAP = theme.spacing(12);
//redux state
const { user, isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-first-name": "firstName",
"edit-last-name": "lastName",
// Disabled for now, will revisit in the future
// "edit-email": "email",
};
// Local state for form data, errors, and file handling
const [localData, setLocalData] = useState({
firstName: user.firstName,
lastName: user.lastName,
// email: user.email, // Disabled for now
});
const [errors, setErrors] = useState({});
const [file, setFile] = useState();
// Handles input field changes and performs validation
const handleChange = (event) => {
errors["unchanged"] && clearError("unchanged");
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
validateField({ [name]: value }, newOrChangedCredentials, name);
};
// Validates input against provided schema and updates error state
const validateField = (toValidate, schema, name = "picture") => {
const { error } = schema.validate(toValidate, { abortEarly: false });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
if (error) return true;
};
// Clears specific error from errors state
const clearError = (err) => {
setErrors((prev) => {
const updatedErrors = { ...prev };
if (updatedErrors[err]) delete updatedErrors[err];
return updatedErrors;
});
};
// Resets picture-related states and clears interval
const removePicture = () => {
errors["picture"] && clearError("picture");
setFile(undefined);
setLocalData((prev) => ({
...prev,
file: undefined,
deleteProfileImage: true,
}));
};
// Opens the picture update modal
const openPictureModal = () => {
setIsOpen("picture");
setFile(undefined);
};
// Closes the picture update modal and resets related states
const closePictureModal = () => {
if (errors["picture"]) clearError("picture");
setFile(undefined);
setIsOpen("");
};
// Updates profile image displayed on UI
const handleUpdatePicture = () => {
setLocalData((prev) => ({
...prev,
file: file?.src,
deleteProfileImage: false,
}));
setIsOpen("");
if (errors["unchanged"]) clearError("unchanged");
};
// Handles form submission to update user profile
const handleSaveProfile = async (event) => {
event.preventDefault();
const nameChanged =
localData.firstName !== user.firstName || localData.lastName !== user.lastName;
const avatarChanged =
localData.deleteProfileImage === true ||
(localData.file && localData.file !== `data:image/png;base64,${user.avatarImage}`);
if (!nameChanged && !avatarChanged) {
createToast({
body: "Unable to update profile — no changes detected.",
});
setErrors({ unchanged: "unable to update profile" });
return;
}
const action = await dispatch(update({ localData }));
if (action.payload.success) {
createToast({
body: "Your profile data was changed successfully.",
});
} else {
createToast({
body: "There was an error updating your profile data.",
});
}
};
// Removes current profile image from UI
const handleDeletePicture = () => {
setLocalData((prev) => ({
...prev,
deleteProfileImage: true,
}));
setFile(undefined);
errors["unchanged"] && clearError("unchanged");
};
// Initiates the account deletion process
const handleDeleteAccount = async () => {
const action = await dispatch(deleteUser());
if (action.payload.success) {
dispatch(clearAuthState());
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
};
// Modal state and control functions
const [isOpen, setIsOpen] = useState("");
const isModalOpen = (name) => isOpen === name;
return (
<TabPanel
value="profile"
sx={{
"& h1, & p, & input": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Stack
component="form"
noValidate
spellCheck="false"
gap={SPACING_GAP}
>
<Stack
direction="row"
gap={SPACING_GAP}
>
{/* This 0.9 is a bit magic numbering, refactor */}
<Box flex={0.9}>
<Typography component="h1">{t("FirstName")}</Typography>
</Box>
<TextInput
id="edit-first-name"
value={localData.firstName}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]] ? true : false}
helperText={errors[idToName["edit-first-name"]]}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Box flex={0.9}>
<Typography component="h1">{t("LastName")}</Typography>
</Box>
<TextInput
id="edit-last-name"
placeholder="Enter your last name"
autoComplete="family-name"
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]] ? true : false}
helperText={errors[idToName["edit-last-name"]]}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Stack flex={0.9}>
<Typography component="h1">{t("auth.common.inputs.email.label")}</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
{t("EmailDescriptionText")}
</Typography>
</Stack>
<TextInput
id="edit-email"
value={user.email}
placeholder="Enter your email"
autoComplete="email"
onChange={() => logger.warn("disabled")}
disabled={true}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Stack flex={0.9}>
<Typography component="h1">{t("YourPhoto")}</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
{t("PhotoDescriptionText")}
</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
flex={1}
gap={"8px"}
>
<Avatar
src={
localData?.deleteProfileImage
? "/static/images/avatar/2.jpg"
: localData?.file
? localData.file
: ""
}
sx={{ marginRight: "8px" }}
/>
<Button
variant="contained" // CAIO_REIVEW
color="error"
onClick={handleDeletePicture}
>
{t("delete")}
</Button>
<Button
variant="contained" // CAIO_REVIEW
color="accent"
onClick={openPictureModal}
>
{t("update")}
</Button>
</Stack>
</Stack>
<Divider
aria-hidden="true"
width="0"
sx={{
marginY: theme.spacing(1),
}}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Box width="fit-content">
<Button
variant="contained"
color="accent"
onClick={handleSaveProfile}
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && !errors?.picture && true}
sx={{ px: theme.spacing(12) }}
>
{t("save")}
</Button>
</Box>
</Stack>
</Stack>
<Divider
aria-hidden="true"
sx={{
marginY: theme.spacing(20),
borderColor: theme.palette.primary.lowContrast,
}}
/>
{!user.role.includes("demo") && (
<Box
component="form"
noValidate
spellCheck="false"
>
<Box mb={theme.spacing(6)}>
<Typography component="h1">{t("DeleteAccountTitle")}</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
{t("DeleteDescriptionText")}
</Typography>
</Box>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen("delete")}
>
{t("DeleteAccountButton")}
</Button>
</Box>
)}
<Dialog
open={isModalOpen("delete")}
theme={theme}
title={t("DeleteWarningTitle")}
description={t("DeleteAccountWarning")}
onCancel={() => setIsOpen("")}
confirmationButtonLabel={t("DeleteAccountButton")}
onConfirm={handleDeleteAccount}
isLoading={isLoading}
/>
<GenericDialog
title={"Upload Image"}
open={isModalOpen("picture")}
onClose={closePictureModal}
theme={theme}
>
<ImageUpload
src={
file?.src
? file.src
: localData?.deleteProfileImage
? ""
: localData?.file
? localData.file
: user?.avatarImage
? `data:image/png;base64,${user.avatarImage}`
: ""
}
onChange={(newFile) => {
if (newFile) {
setFile(newFile);
clearError("unchanged");
}
}}
previewIsRound
maxSize={3 * 1024 * 1024}
/>
<Stack
direction="row"
mt={theme.spacing(10)}
gap={theme.spacing(5)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={removePicture}
>
{t("remove")}
</Button>
<Button
variant="contained"
color="accent"
onClick={handleUpdatePicture}
disabled={!!errors.picture || !file?.src}
>
{t("update")}
</Button>
</Stack>
</GenericDialog>
</TabPanel>
);
};
ProfilePanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default ProfilePanel;