Merge pull request 'feat(settings): implement user profile page and notification handler' (#45) from update-profile into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_panel/pulls/45
Reviewed-by: Sina Nakhostin <s.nakhostin@ravanertebat.com>
This commit is contained in:
Sina Nakhostin
2025-10-11 17:15:19 +03:30
17 changed files with 302 additions and 49 deletions
+25
View File
@@ -14,3 +14,28 @@ firebase.initializeApp({
}); });
const messaging = firebase.messaging(); const messaging = firebase.messaging();
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data.url || "/";
event.waitUntil(
clients
.matchAll({
type: "window",
includeUncontrolled: true,
})
.then((clientList) => {
for (const client of clientList) {
if (client.url === urlToOpen && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
}),
);
});
@@ -1,6 +1,3 @@
import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
export function ContactForm() { export function ContactForm() {
+160 -18
View File
@@ -1,23 +1,165 @@
import type { Metadata } from "next"; "use client";
import { PersonalInfoForm } from "./_components/personal-info"; import Loading from "@/app/loading";
import { UploadPhotoForm } from "./_components/upload-photo"; import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
export const metadata: Metadata = { import { ShowcaseSection } from "@/components/Layouts/showcase-section";
title: "Settings Page", import FormFooter from "@/components/ui/FormFooter";
}; import { AdminRoles } from "@/constants/enums";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { useProfileQuery, useUpdateProfileQuery } from "@/hooks/queries";
import { TUpdateProfileCredentials, UpdateProfileCredentials } from "@/lib/api";
import { getEnumAsArray } from "@/utils/shared";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { ZodSafeParseResult } from "zod";
export default function SettingsPage() { export default function SettingsPage() {
const { data, isLoading } = useProfileQuery();
const { mutate } = useUpdateProfileQuery();
const { register, reset, handleSubmit } = useForm<TUpdateProfileCredentials>({
mode: "onChange",
defaultValues: data,
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const updateUser = (data: TUpdateProfileCredentials) => {
const validation: ZodSafeParseResult<TUpdateProfileCredentials> =
UpdateProfileCredentials.safeParse(data);
if (validation.success) {
mutate({ credentials: validation.data });
} else {
console.error(validation.error);
}
};
if (isLoading) return <Loading />;
return ( return (
<div>Settings</div> <ShowcaseSection title="Update Profile">
// <div className="mx-auto w-full max-w-[1080px]"> <form
// <div className="grid grid-cols-5 gap-8"> className="grid grid-cols-2 gap-2"
// <div className="col-span-5 xl:col-span-3"> onSubmit={handleSubmit(updateUser)}
// <PersonalInfoForm /> >
// </div> <InputGroup
// <div className="col-span-5 xl:col-span-2"> {...register("full_name", {
// <UploadPhotoForm /> minLength: {
// </div> value: 2,
// </div> message: FormErrorMessages.minLength(2),
// </div> },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Full name"
name="full_name"
placeholder="Full name"
type="text"
className="col-span-1"
/>
<InputGroup
{...register("username", {
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 30,
message: FormErrorMessages.maxLength(30),
},
pattern: {
value: REGEX.USERNAME,
message: FormErrorMessages.invalidPattern,
},
})}
label="Username"
className="col-span-1"
name="username"
placeholder="Username"
type="text"
/>
<InputGroup
{...register("email", {
pattern: {
value: REGEX.EMAIL,
message: FormErrorMessages.invalidEmail,
},
})}
label="Email"
className="col-span-1"
name="email"
placeholder="Email"
type="email"
/>
<InputGroup
{...register("phone", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
pattern: {
value: REGEX.PHONE,
message: FormErrorMessages.invalidPattern,
},
})}
label="Phone"
className="col-span-1"
name="phone"
placeholder="Phone"
type="text"
/>
<InputGroup
{...register("department", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Department"
className="col-span-1"
name="department"
placeholder="Department"
type="text"
/>
<InputGroup
{...register("position", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Position"
className="col-span-1"
name="position"
placeholder="position"
type="text"
/>
<Select
label="Role"
{...register("role", {
required: { message: FormErrorMessages.required, value: true },
})}
name="role"
items={getEnumAsArray(AdminRoles)}
placeholder="Select Role"
/>
<FormFooter />
</form>
</ShowcaseSection>
); );
} }
+1 -2
View File
@@ -16,8 +16,7 @@ export default function SigninWithPassword() {
const [passwordFieldInputType, setPasswordFieldInputType] = useState< const [passwordFieldInputType, setPasswordFieldInputType] = useState<
"password" | "text" "password" | "text"
>("password"); >("password");
const { handleSubmit, control, reset, register } = const { handleSubmit, reset, register } = useForm<ILoginCredentials>();
useForm<ILoginCredentials>();
const { mutate } = useLoginQuery(reset); const { mutate } = useLoginQuery(reset);
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => { const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
@@ -60,7 +60,7 @@ const InputGroup = <T extends FieldValues>({
<div className={className}> <div className={className}>
<label <label
htmlFor={id} htmlFor={id}
className="text-body-sm font-medium text-dark dark:text-white" className="text-body-sm font-medium capitalize text-dark dark:text-white"
> >
{label} {label}
{required && <span className="ml-1 select-none text-error">*</span>} {required && <span className="ml-1 select-none text-error">*</span>}
@@ -93,7 +93,7 @@ const InputGroup = <T extends FieldValues>({
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
})} })}
className={cn( className={cn(
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary", "w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition placeholder:capitalize focus:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
// Add error styles conditionally // Add error styles conditionally
error && "border-red focus:border-red dark:border-red", error && "border-red focus:border-red dark:border-red",
type === "file" type === "file"
+2 -1
View File
@@ -2,6 +2,7 @@
import { ChevronUpIcon } from "@/assets/icons"; import { ChevronUpIcon } from "@/assets/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ILabelValue } from "@/types/shared";
import { useId, useState, type ChangeEvent } from "react"; import { useId, useState, type ChangeEvent } from "react";
import { import {
type FieldErrors, type FieldErrors,
@@ -14,7 +15,7 @@ import {
type PropsType<T extends FieldValues> = { type PropsType<T extends FieldValues> = {
name: Path<T>; name: Path<T>;
label: string; label: string;
items: { value: string; label: string }[]; items: ILabelValue[];
prefixIcon?: React.ReactNode; prefixIcon?: React.ReactNode;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void; onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
className?: string; className?: string;
@@ -110,7 +110,7 @@ const NotificationHistoryTable = () => {
setParams((currentParams) => ({ setParams((currentParams) => ({
...currentParams, ...currentParams,
offset: e.selected * (metadata?.limit ?? 10), offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 1000, limit: metadata?.limit ?? 10,
})); }));
}} }}
/> />
+6 -1
View File
@@ -2,6 +2,7 @@
import InputGroup from "@/components/FormElements/InputGroup"; import InputGroup from "@/components/FormElements/InputGroup";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts"; import { FormErrorMessages } from "@/constants/Texts";
import { ICreateAdminCredentials } from "@/lib/api"; import { ICreateAdminCredentials } from "@/lib/api";
import { FC } from "react"; import { FC } from "react";
@@ -12,7 +13,7 @@ interface IProps {
id?: string; id?: string;
} }
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => { const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
const { errors, handleSubmit, register, router, onSubmit } = const { errors, handleSubmit, register, onSubmit } =
useCreateAdminPresenter({ useCreateAdminPresenter({
editMode, editMode,
defaultValues, defaultValues,
@@ -162,6 +163,10 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
value: 20, value: 20,
message: FormErrorMessages.maxLength(20), message: FormErrorMessages.maxLength(20),
}, },
pattern:{
value:REGEX.PHONE,
message:FormErrorMessages.invalidPattern
},
})} })}
name="phone" name="phone"
/> />
@@ -10,6 +10,8 @@ import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts"; import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api"; import { CreateCustomerCredentials } from "@/lib/api";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter"; import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
import { getEnumAsArray } from "@/utils/shared";
import { AdminRoles } from "@/constants/enums";
interface IProps { interface IProps {
editMode?: boolean; editMode?: boolean;
@@ -97,16 +99,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
})} })}
name="role" name="role"
items={[ items={getEnumAsArray(AdminRoles)}
{
label: "Admin",
value: "admin",
},
{
label: "Analyst",
value: "analyst",
},
]}
placeholder="Select Role" placeholder="Select Role"
/> />
</div> </div>
+4
View File
@@ -35,3 +35,7 @@ export enum NotificationTypes {
ALERT = "alert", ALERT = "alert",
REJECT = "reject", REJECT = "reject",
} }
export enum AdminRoles {
ADMIN = "admin",
ANALYST = "analyst",
}
+1
View File
@@ -2,4 +2,5 @@ export const REGEX = {
PHONE: /^[+\d]+$/, PHONE: /^[+\d]+$/,
USERNAME: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/, USERNAME: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/,
URL: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/, URL: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
EMAIL: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
}; };
+9 -5
View File
@@ -3,7 +3,9 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import React, { import React, {
createContext, createContext,
Dispatch,
ReactNode, ReactNode,
SetStateAction,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@@ -13,18 +15,19 @@ import React, {
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { ILoginResponse, UserSchema } from "../lib/api/types"; import { ILoginResponse, IUser, UserSchema } from "../lib/api/types";
import { COOKIE_KEYS } from "../lib/shared/cookies"; import { COOKIE_KEYS } from "../lib/shared/cookies";
type User = z.infer<typeof UserSchema>;
interface UserContextType { interface UserContextType {
user: User | null; user: IUser | null;
accessToken: string | null; accessToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
setAuthState: (data: ILoginResponse) => void; setAuthState: (data: ILoginResponse) => void;
logout: () => void; logout: () => void;
setUser:Dispatch<SetStateAction<IUser|null>>
} }
const UserContext = createContext<UserContextType | undefined>(undefined); const UserContext = createContext<UserContextType | undefined>(undefined);
@@ -34,7 +37,7 @@ interface UserProviderProps {
} }
export const UserProvider: React.FC<UserProviderProps> = ({ children }) => { export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<IUser | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const router = useRouter(); const router = useRouter();
@@ -88,8 +91,9 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
isLoading, isLoading,
setAuthState, setAuthState,
logout, logout,
setUser,
}), }),
[user, accessToken, isLoading, setAuthState, logout], [user, accessToken, isLoading, setAuthState, logout, setUser],
); );
return <UserContext.Provider value={value}>{children}</UserContext.Provider>; return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
+23 -3
View File
@@ -1,7 +1,8 @@
import { API_ENDPOINTS, profileService } from "@/lib/api"; import { useUser } from "@/contexts";
import { useQuery } from "@tanstack/react-query"; import { API_ENDPOINTS, IUser, profileService } from "@/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { toast } from "react-toastify";
export const useProfileQuery = () => { export const useProfileQuery = () => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"], () => [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"],
@@ -12,3 +13,22 @@ export const useProfileQuery = () => {
queryFn: () => profileService.getProfile(), queryFn: () => profileService.getProfile(),
}); });
}; };
export const useUpdateProfileQuery = () => {
const { setUser } = useUser();
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.PROFILE.UPDATE, "UPDATE_USER_PROFILE"],
[],
);
return useMutation({
mutationKey,
mutationFn: profileService.updateProfile,
onSuccess: (response: IUser) => {
toast.success("Profile updated successfully");
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"],
});
setUser(response);
},
});
};
+1
View File
@@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
}, },
PROFILE: { PROFILE: {
READ: "profile", READ: "profile",
UPDATE: "profile",
}, },
COMPANIES: { COMPANIES: {
READ_ALL: "companies", READ_ALL: "companies",
+34 -3
View File
@@ -1,15 +1,46 @@
import { parseWithSchema } from "@/utils/shared";
import api from "../axios"; import api from "../axios";
import { API_ENDPOINTS } from "../endpoints"; import { API_ENDPOINTS } from "../endpoints";
import { CurrentUserProfileSchema } from "../types"; import {
CurrentUserProfileSchema,
IUser,
TCurrentUser,
TUpdateProfileCredentials,
UserSchema,
} from "../types";
export const profileService = { export const profileService = {
getProfile: async () => { getProfile: async () => {
try { try {
const response = (await api.get(API_ENDPOINTS.PROFILE.READ)).data; const response = (await api.get(API_ENDPOINTS.PROFILE.READ)).data;
const userData = parseWithSchema(
return CurrentUserProfileSchema.parse(response.data); CurrentUserProfileSchema,
response.data,
"Profile Service: Get Profile",
);
return userData;
} catch (error) { } catch (error) {
console.error("ERROR caught in Profile Service: Get Profile", error); console.error("ERROR caught in Profile Service: Get Profile", error);
throw error; throw error;
} }
}, },
updateProfile: async ({
credentials,
}: {
credentials: TUpdateProfileCredentials;
}): Promise<IUser> => {
try {
const response = (
await api.put(API_ENDPOINTS.PROFILE.UPDATE, credentials)
).data;
return parseWithSchema(
UserSchema,
response.data,
"Profile Service: Update Profile",
);
} catch (error) {
console.error("ERROR caught in Profile Service: Update Profile", error);
throw error;
}
},
}; };
+14
View File
@@ -18,4 +18,18 @@ export const CurrentUserProfileSchema = z.object({
updated_by: z.string().optional(), updated_by: z.string().optional(),
username: z.string().optional(), username: z.string().optional(),
}); });
export const UpdateProfileCredentials = z.object({
department: z.string(),
email: z.string(),
full_name: z.string(),
phone: z.string(),
position: z.string(),
profile_image: z.string(),
role: z.string(),
username: z.string(),
});
export type TUpdateProfileCredentials = z.infer<
typeof UpdateProfileCredentials
>;
export type TCurrentUser = z.infer<typeof CurrentUserProfileSchema>; export type TCurrentUser = z.infer<typeof CurrentUserProfileSchema>;
+16
View File
@@ -1,5 +1,6 @@
import { ILabelValue } from "@/types/shared"; import { ILabelValue } from "@/types/shared";
import * as moment from "moment"; import * as moment from "moment";
import { z } from "zod";
export const unixToDate = ({ export const unixToDate = ({
hasTime = false, hasTime = false,
unix, unix,
@@ -82,3 +83,18 @@ export const truncateString = (
} }
return str.slice(0, startLength) + "..." + str.slice(-endLength); return str.slice(0, startLength) + "..." + str.slice(-endLength);
}; };
export function parseWithSchema<T>(
schema: z.ZodSchema<T>,
data: unknown,
errorContext: string,
): T {
const parsedData = schema.safeParse(data);
if (parsedData.success) {
return parsedData.data;
} else {
console.error(`ERROR caught in ${errorContext}`);
throw new Error(`ERROR caught in ${errorContext}`);
}
}