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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,3 +35,7 @@ export enum NotificationTypes {
|
||||
ALERT = "alert",
|
||||
REJECT = "reject",
|
||||
}
|
||||
export enum AdminRoles {
|
||||
ADMIN = "admin",
|
||||
ANALYST = "analyst",
|
||||
}
|
||||
|
||||
@@ -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,}$/,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
PROFILE: {
|
||||
READ: "profile",
|
||||
UPDATE: "profile",
|
||||
},
|
||||
COMPANIES: {
|
||||
READ_ALL: "companies",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user