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();
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";
export function ContactForm() {
+160 -18
View File
@@ -1,23 +1,165 @@
import type { Metadata } from "next";
import { PersonalInfoForm } from "./_components/personal-info";
import { UploadPhotoForm } from "./_components/upload-photo";
export const metadata: Metadata = {
title: "Settings Page",
};
"use client";
import Loading from "@/app/loading";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
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() {
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 (
<div>Settings</div>
// <div className="mx-auto w-full max-w-[1080px]">
// <div className="grid grid-cols-5 gap-8">
// <div className="col-span-5 xl:col-span-3">
// <PersonalInfoForm />
// </div>
// <div className="col-span-5 xl:col-span-2">
// <UploadPhotoForm />
// </div>
// </div>
// </div>
<ShowcaseSection title="Update Profile">
<form
className="grid grid-cols-2 gap-2"
onSubmit={handleSubmit(updateUser)}
>
<InputGroup
{...register("full_name", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
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<
"password" | "text"
>("password");
const { handleSubmit, control, reset, register } =
useForm<ILoginCredentials>();
const { handleSubmit, reset, register } = useForm<ILoginCredentials>();
const { mutate } = useLoginQuery(reset);
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
@@ -60,7 +60,7 @@ const InputGroup = <T extends FieldValues>({
<div className={className}>
<label
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}
{required && <span className="ml-1 select-none text-error">*</span>}
@@ -93,7 +93,7 @@ const InputGroup = <T extends FieldValues>({
defaultValue: props.defaultValue,
})}
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
error && "border-red focus:border-red dark:border-red",
type === "file"
+2 -1
View File
@@ -2,6 +2,7 @@
import { ChevronUpIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { ILabelValue } from "@/types/shared";
import { useId, useState, type ChangeEvent } from "react";
import {
type FieldErrors,
@@ -14,7 +15,7 @@ import {
type PropsType<T extends FieldValues> = {
name: Path<T>;
label: string;
items: { value: string; label: string }[];
items: ILabelValue[];
prefixIcon?: React.ReactNode;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
className?: string;
@@ -110,7 +110,7 @@ const NotificationHistoryTable = () => {
setParams((currentParams) => ({
...currentParams,
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 { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { ICreateAdminCredentials } from "@/lib/api";
import { FC } from "react";
@@ -12,7 +13,7 @@ interface IProps {
id?: string;
}
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
const { errors, handleSubmit, register, router, onSubmit } =
const { errors, handleSubmit, register, onSubmit } =
useCreateAdminPresenter({
editMode,
defaultValues,
@@ -162,6 +163,10 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
value: 20,
message: FormErrorMessages.maxLength(20),
},
pattern:{
value:REGEX.PHONE,
message:FormErrorMessages.invalidPattern
},
})}
name="phone"
/>
@@ -10,6 +10,8 @@ import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
import { getEnumAsArray } from "@/utils/shared";
import { AdminRoles } from "@/constants/enums";
interface IProps {
editMode?: boolean;
@@ -97,16 +99,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
required: { message: FormErrorMessages.required, value: true },
})}
name="role"
items={[
{
label: "Admin",
value: "admin",
},
{
label: "Analyst",
value: "analyst",
},
]}
items={getEnumAsArray(AdminRoles)}
placeholder="Select Role"
/>
</div>
+4
View File
@@ -35,3 +35,7 @@ export enum NotificationTypes {
ALERT = "alert",
REJECT = "reject",
}
export enum AdminRoles {
ADMIN = "admin",
ANALYST = "analyst",
}
+1
View File
@@ -2,4 +2,5 @@ export const REGEX = {
PHONE: /^[+\d]+$/,
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()@:%_\+.~#?&//=]*)$/,
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 React, {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
@@ -13,18 +15,19 @@ import React, {
import { useRouter } from "next/navigation";
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";
type User = z.infer<typeof UserSchema>;
interface UserContextType {
user: User | null;
user: IUser | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
setAuthState: (data: ILoginResponse) => void;
logout: () => void;
setUser:Dispatch<SetStateAction<IUser|null>>
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@@ -34,7 +37,7 @@ interface UserProviderProps {
}
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 [isLoading, setIsLoading] = useState<boolean>(true);
const router = useRouter();
@@ -88,8 +91,9 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
isLoading,
setAuthState,
logout,
setUser,
}),
[user, accessToken, isLoading, setAuthState, logout],
[user, accessToken, isLoading, setAuthState, logout, setUser],
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
+23 -3
View File
@@ -1,7 +1,8 @@
import { API_ENDPOINTS, profileService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useUser } from "@/contexts";
import { API_ENDPOINTS, IUser, profileService } from "@/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const useProfileQuery = () => {
const queryKey = useMemo(
() => [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"],
@@ -12,3 +13,22 @@ export const useProfileQuery = () => {
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: {
READ: "profile",
UPDATE: "profile",
},
COMPANIES: {
READ_ALL: "companies",
+34 -3
View File
@@ -1,15 +1,46 @@
import { parseWithSchema } from "@/utils/shared";
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import { CurrentUserProfileSchema } from "../types";
import {
CurrentUserProfileSchema,
IUser,
TCurrentUser,
TUpdateProfileCredentials,
UserSchema,
} from "../types";
export const profileService = {
getProfile: async () => {
try {
const response = (await api.get(API_ENDPOINTS.PROFILE.READ)).data;
return CurrentUserProfileSchema.parse(response.data);
const userData = parseWithSchema(
CurrentUserProfileSchema,
response.data,
"Profile Service: Get Profile",
);
return userData;
} catch (error) {
console.error("ERROR caught in Profile Service: Get Profile", 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(),
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>;
+16
View File
@@ -1,5 +1,6 @@
import { ILabelValue } from "@/types/shared";
import * as moment from "moment";
import { z } from "zod";
export const unixToDate = ({
hasTime = false,
unix,
@@ -82,3 +83,18 @@ export const truncateString = (
}
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}`);
}
}