diff --git a/src/app/(auth-routes)/forgot-password/page.test.tsx b/src/app/(auth-routes)/forgot-password/page.test.tsx index 024edbf5d..2381a2d7f 100644 --- a/src/app/(auth-routes)/forgot-password/page.test.tsx +++ b/src/app/(auth-routes)/forgot-password/page.test.tsx @@ -42,75 +42,40 @@ describe("forgot password page", () => { }); }); - it("proceeds to verification code stage on valid email", async () => { + it("does not proceed to verification code stage if email is invalid", async () => { expect.hasAssertions(); - render(); const emailInput = screen.getByPlaceholderText(/enter your email/i); const sendButton = screen.getByText(/send/i); fireEvent.change(emailInput, { - target: { value: "akinsanyaadeyinka4166@gmail.com" }, + target: { value: "invalid-email@example.com" }, }); fireEvent.click(sendButton); await waitFor(() => { - expect(screen.getByText("Verification Code")).toBeInTheDocument(); + expect(screen.queryByText("Verification Code")).not.toBeInTheDocument(); }); }); - it("shows error for incorrect OTP", async () => { + it("displays an error message for an invalid email format", async () => { expect.hasAssertions(); - render(); const emailInput = screen.getByPlaceholderText(/enter your email/i); const sendButton = screen.getByText(/send/i); - fireEvent.change(emailInput, { - target: { value: "akinsanyaadeyinka4166@gmail.com" }, + target: { value: "invalid-email" }, }); fireEvent.click(sendButton); - await waitFor(() => { - expect(screen.getByText("Verification Code")).toBeInTheDocument(); - }); - - const otpInput = screen.getByTestId("forgot-password-otp-input"); - fireEvent.change(otpInput, { target: { value: "000000" } }); - await waitFor(() => { expect( - screen.getByText(/the otp entered is not correct/i), + screen.getByText( + "This email doesn't match our records please try again", + ), ).toBeInTheDocument(); }); }); - - it("proceeds to reset password stage on correct OTP", async () => { - expect.hasAssertions(); - - render(); - - const emailInput = screen.getByPlaceholderText(/enter your email/i); - const sendButton = screen.getByText(/send/i); - - fireEvent.change(emailInput, { - target: { value: "akinsanyaadeyinka4166@gmail.com" }, - }); - fireEvent.click(sendButton); - - await waitFor(() => { - expect(screen.getByText("Verification Code")).toBeInTheDocument(); - }); - - const otpInput = screen.getByTestId("forgot-password-otp-input"); - fireEvent.change(otpInput, { target: { value: "123456" } }); - const verifyButton = screen.getByText(/verify/i); - fireEvent.click(verifyButton); - - await waitFor(() => { - expect(screen.getByText(/verification successful/i)).toBeInTheDocument(); - }); - }); }); diff --git a/src/app/(auth-routes)/forgot-password/page.tsx b/src/app/(auth-routes)/forgot-password/page.tsx index b48ca531b..3b87bf5db 100644 --- a/src/app/(auth-routes)/forgot-password/page.tsx +++ b/src/app/(auth-routes)/forgot-password/page.tsx @@ -1,11 +1,13 @@ "use client"; import { TooltipArrow } from "@radix-ui/react-tooltip"; +import axios from "axios"; import { AnimatePresence, motion } from "framer-motion"; -import { CircleCheck, X } from "lucide-react"; +import { Check, CircleCheck, X } from "lucide-react"; import Link from "next/link"; -import { ReactNode, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; +import { getApiUrl } from "~/actions/getApiUrl"; import CustomButton from "~/components/common/common-button/common-button"; import { InputOtp } from "~/components/common/input-otp"; import CustomInput from "~/components/common/input/input"; @@ -15,40 +17,9 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip"; +import { useToast } from "~/components/ui/use-toast"; import { cn } from "~/lib/utils"; -const registeredEmails = [ - "akinsanyaadeyinka4166@gmail.com", - "ellafedora@gmail.com", - "alice@example.com", - "bob@example.com", - "charlie@example.com", - "david@example.com", - "eve@example.com", - "frank@example.com", - "grace@example.com", - "hank@example.com", - "irene@example.com", - "jack@example.com", - "karen@example.com", - "leo@example.com", - "markessien@gmail.com", - "mike@example.com", - "nancy@example.com", - "oliver@example.com", - "paul@example.com", - "quincy@example.com", - "rachel@example.com", - "steve@example.com", - "tina@example.com", - "ursula@example.com", - "victor@example.com", - "wendy@example.com", - "xander@example.com", - "yvonne@example.com", - "zach@example.com", -]; - const Error = ({ children, className, @@ -74,18 +45,144 @@ const Description = ({ children }: { children: ReactNode }) => ( const ForgotPassword = () => { const [currentStage, setCurrentStage] = useState(0); const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); const [isCodeComplete, setIsCodeComplete] = useState(false); const [isCodeCorrect, setIsCodeCorrect] = useState(false); const [isOtpResent, setIsOtpResent] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(true); const [emailTooltipContent, setEmailTooltipContent] = useState(""); + const [apiUrl, setApiUrl] = useState(""); + const [userEmails, setUserEmails] = useState([]); + const { toast } = useToast(); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + const [isValidating, setIsValidating] = useState(false); + + useEffect(() => { + const getUsers = async () => { + let allUsers: string[] = []; + let currentPage = 1; + let lastPage = 1; + try { + const url = await getApiUrl(); + setApiUrl(url); + + do { + const response = await axios.get( + `${url}/api/v1/users?page=${currentPage}`, + ); + + type UserResponse = { + data: { + email: string; + }[]; + }; + + const data: UserResponse = response.data; + const fetchedUserEmails: string[] = data.data.map( + (user) => user.email, + ); + + allUsers = [...allUsers, ...fetchedUserEmails]; + currentPage = response.data.current_page; + lastPage = response.data.last_page; + currentPage++; + } while (currentPage <= lastPage); + + setUserEmails(allUsers); + } catch { + toast({ + title: "Error", + description: "Failed to fetch user emails", + variant: "destructive", + }); + } + }; + + getUsers(); + }, [toast]); + + const passwordError = + newPassword.length < 8 || + !/[AZa-z]/.test(newPassword) || + !/\d/.test(newPassword) || + !/[\W_]/.test(newPassword); + + const confirmPasswordError = confirmNewPassword !== newPassword; const emailError = - email && - !registeredEmails.some((registeredEmail) => - registeredEmail.includes(email), - ); + email && !userEmails.some((userEmail) => userEmail.includes(email)); + + const fetchOtpCode = async () => { + try { + const response = await axios.post( + `${apiUrl}/api/v1/auth/forgot-password`, + { email }, + ); + return response; + } catch (error) { + return axios.isAxiosError(error) && error.response + ? { + success: false, + error: error.response.data, + } + : { + success: false, + error: "Unknown error occurred", + }; + } + }; + const validateOTP = async (inputCode: string) => { + setIsValidating(true); + try { + const response = await axios.post( + `${apiUrl}/api/v1/auth/verify-forgot-otp`, + { email, otp: inputCode }, + ); + setIsCodeCorrect(response.status === 200 ? true : false); + } catch (error) { + setIsCodeCorrect(false); + return axios.isAxiosError(error) && error.response + ? { + success: false, + error: error.response.data, + } + : { + success: false, + error: "Unknown error occurred", + }; + } finally { + setIsValidating(false); + } + }; + + const handleReset = async () => { + const formattedData = { + email: email, + password: newPassword, + password_confirmation: confirmNewPassword, + }; + try { + setIsValidating(true); + const response = await axios.post( + `${apiUrl}/api/v1/auth/reset-forgot-password`, + formattedData, + ); + if (response.status === 200) { + setIsValidating(false); + } + return response; + } catch (error) { + return axios.isAxiosError(error) && error.response + ? { + success: false, + error: error.response.data, + } + : { + success: false, + error: "Unknown error occurred", + }; + } + }; const Default = ( <> @@ -104,7 +201,7 @@ const ForgotPassword = () => { onChange={(event) => { const newValue = event.target.value; const emailMatch = newValue - ? registeredEmails.find((email) => email.includes(newValue)) + ? userEmails.find((email) => email.includes(newValue)) : undefined; setEmailTooltipContent( emailMatch ? (emailMatch === newValue ? "" : emailMatch) : "", @@ -141,6 +238,8 @@ const ForgotPassword = () => { type="submit" variant="primary" className="h-12 rounded-lg text-sm font-bold sm:h-16 sm:text-base sm:font-medium" + onClick={fetchOtpCode} + isDisabled={email === ""} > Send @@ -171,15 +270,16 @@ const ForgotPassword = () => {
{ - setCode(value); - setIsCodeComplete(value.length === 6); - }} - onComplete={() => { - setIsCodeCorrect(code === "123456"); + if (value.length === 6) { + validateOTP(value); + setIsCodeComplete(true); + } else { + setIsCodeComplete(false); + } }} /> {!isCodeCorrect && isCodeComplete && ( @@ -187,12 +287,12 @@ const ForgotPassword = () => { )}
- Verify + {isValidating ? "Verifying..." : "Verify"}

@@ -219,7 +319,6 @@ const ForgotPassword = () => { setCurrentStage(0); setIsOtpResent(false); setIsCodeComplete(false); - setCode(""); setEmail(""); }} > @@ -240,13 +339,33 @@ const ForgotPassword = () => { password

- + + Reset Password + + + ); + + const PasswordUpdated = ( + <> +
+ PASSWORD UPDATED + + Your password has been updated +
+ - Reset Password + Back to Login @@ -279,6 +398,87 @@ const ForgotPassword = () => { ); + const PasswordUpdatedSuccessfulMessage = ( + +
+
+ +

+ Password Updated Successfully! +

+
+ setShowSuccessMessage(false)} + /> +
+
+ ); + + const ResetPassword = ( + <> +
+ Reset Password + + Your password must be at least 8 characters long + +
+ { + setNewPassword(event.target.value); + }} + value={newPassword} + variant="border" + label="New Password" + placeholder="New Password" + className={`h-12 rounded-lg text-sm placeholder:text-sm focus:bg-zinc-50 focus:placeholder:text-neutral-dark-2 sm:h-16 sm:text-lg sm:placeholder:text-lg ${passwordError ? "border-error" : "focus:border-primary"}`} + labelClassName="sm:text-xl text-[13px] text-neutral-dark-2" + /> + {passwordError && ( + + Invalid password format. Please check again + + )} + { + setConfirmNewPassword(event.target.value); + }} + value={confirmNewPassword} + variant="border" + label="Confirm Password" + placeholder="Confirm Password" + className={`h-12 rounded-lg text-sm placeholder:text-sm focus:bg-zinc-50 focus:placeholder:text-neutral-dark-2 sm:h-16 sm:text-lg sm:placeholder:text-lg ${confirmPasswordError ? "border-error" : "focus:border-primary"}`} + labelClassName="sm:text-xl text-[13px] text-neutral-dark-2" + /> + {confirmPasswordError && ( + + Password do not match! + + )} + + {isValidating ? "Validating..." : "Reset Password"} + + + ); + const sections = [ { element: Default, @@ -295,6 +495,16 @@ const ForgotPassword = () => { { element: VerificationSuccessful, stage: 2, + onSubmit: () => setCurrentStage(3), + }, + { + element: ResetPassword, + stage: 3, + onSubmit: () => setCurrentStage(4), + }, + { + element: PasswordUpdated, + stage: 4, onSubmit: () => {}, }, ]; @@ -306,6 +516,11 @@ const ForgotPassword = () => { showSuccessMessage && VerificationSuccessfulMessage} + + {currentStage === 4 && + showSuccessMessage && + PasswordUpdatedSuccessfulMessage} + {sections.map( (section, index) => section.stage === currentStage && ( diff --git a/src/app/(auth-routes)/reset-password/page.tsx b/src/app/(auth-routes)/reset-password/page.tsx index fc796802f..dbee39a0c 100644 --- a/src/app/(auth-routes)/reset-password/page.tsx +++ b/src/app/(auth-routes)/reset-password/page.tsx @@ -1,9 +1,5 @@ -function ResetPassword() { - return ( - <> -
reset password
- - ); -} +const Reset = () => { + return
Reset
; +}; -export default ResetPassword; +export default Reset; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.test.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.test.tsx new file mode 100644 index 000000000..18c844bf7 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import DeleteDialog from "./delete-dialog"; + +describe("deleteDialog", () => { + it("renders the dialog with correct content", () => { + expect.hasAssertions(); + const onClose = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument(); + expect( + screen.getByText( + "This action cannot be undone. This will permanently delete this product from the database.", + ), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + }); + + it("calls onClose when Cancel button is clicked", () => { + expect.hasAssertions(); + const onClose = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onDelete when Delete button is clicked", () => { + expect.hasAssertions(); + const onClose = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /delete/i })); + expect(onDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.tsx new file mode 100644 index 000000000..f6b329a47 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/dialogue/delete-dialog.tsx @@ -0,0 +1,53 @@ +import { Button } from "~/components/common/common-button"; +import LoadingSpinner from "~/components/miscellaneous/loading-spinner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; + +const DeleteDialog = ({ + onClose, + onDelete, + isDeleting, +}: { + onClose: () => void; + onDelete: () => void; + isDeleting: boolean; +}) => { + return ( + + + + + Are you absolutely sure? + + + This action cannot be undone. This will permanently delete this + product from the database. + + +
+ + +
+
+
+ ); +}; + +export default DeleteDialog; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userModal.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userModal.tsx new file mode 100644 index 000000000..81bdb6fe3 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userModal.tsx @@ -0,0 +1,116 @@ +import { Button } from "~/components/common/common-button"; +import { Input } from "~/components/common/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Label } from "~/components/ui/label"; + +interface AddProductModalProperties { + children: React.ReactNode; +} +const AddUserModal = ({ children }: AddProductModalProperties) => { + return ( + + {children} + + + + Add new user + + + Create a new user + + +
+
+
+
+
+ Upload Picture +
+
+ + +
+
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + +
+
+ ); +}; + +export default AddUserModal; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTable.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTable.tsx new file mode 100644 index 000000000..ea852c9b9 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTable.tsx @@ -0,0 +1,15 @@ +import UserTableBody from "./userProductTableBody"; +import UserTableHead from "./userProductTableHead"; + +const UserProductsTable = () => { + return ( + <> + + + +
+ + ); +}; + +export default UserProductsTable; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableBody.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableBody.tsx new file mode 100644 index 000000000..e1d880b46 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableBody.tsx @@ -0,0 +1,181 @@ +import { EllipsisVertical } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; + +const UserProductsTableBody = () => { + return ( + <> + + + +
+
+ Hypernova Headphones +
+ + + + $129.99 + + + + 25 + + + +
+ Draft +
+ + + + 2024-07-16 10:36AM + + + + + + + + + + Actions + + Edit + Delete + + + + + + +
+
+ Hypernova Headphones +
+ + + + $129.99 + + + + 25 + + + +
+ Draft +
+ + + + 2024-07-16 10:36AM + + + + + + + + + + Actions + + Edit + Delete + + + + + + +
+
+ Hypernova Headphones +
+ + + + $129.99 + + + + 25 + + + +
+ Draft +
+ + + + 2024-07-16 10:36AM + + + + + + + + + + Actions + + Edit + handleOpenDialog(id)} + > + Delete + + + + + + + + ); +}; + +export default UserProductsTableBody; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableHead.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableHead.tsx new file mode 100644 index 000000000..7a8d2cbac --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/component/userProductTableHead.tsx @@ -0,0 +1,31 @@ +const tableHeadData: string[] = [ + "name", + "price", + "total sales", + "status", + "created at", + "action", +]; + +const UserProductsTableHead = () => { + return ( + <> + + + {tableHeadData.map((data, index) => { + return ( + + {data} + + ); + })} + + + + ); +}; + +export default UserProductsTableHead; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/[id]/page.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/page.tsx new file mode 100644 index 000000000..f59951440 --- /dev/null +++ b/src/app/dashboard/(admin)/admin/(overview)/users/[id]/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import axios from "axios"; +import { ChevronLeft, ChevronRight, Star } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { getApiUrl } from "~/actions/getApiUrl"; +import CardComponent from "~/components/common/DashboardCard/CardComponent"; +import LoadingSpinner from "~/components/miscellaneous/loading-spinner"; +import { Button } from "~/components/ui/button"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, +} from "~/components/ui/pagination"; +import { useToast } from "~/components/ui/use-toast"; +import { UserCardData } from "../data/user-dummy-data"; +import UserProductsTable from "./component/userProductTable"; + +export interface UserDetailsProperties { + email: string; + id: string; + is_active: boolean; + name: string; + products: []; + created_at: string; +} + +function formatMongoDate(mongoDateString: string): string { + const date = new Date(mongoDateString); + + const day = date.getUTCDate(); + const month = date.getUTCMonth() + 1; + const year = date.getUTCFullYear(); + + const formattedDate = `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}/${year}`; + + return formattedDate; +} + +const UserDetails = () => { + const { id } = useParams(); + const [loading, setLoading] = useState(false); + const [userData, setUserData] = useState(); + const [rating, setRating] = useState(0); + const { toast } = useToast(); + + const [totalProducts, setTotalProducts] = useState({ + title: "Total Products", + value: 0, + description: "+10% from last month", + icon: "user", + }); + + const [totalOrders, setTotalOrders] = useState({ + title: "Total Orders", + value: 0, + description: "+20% from last month", + icon: "box", + }); + + const [totalSales, setTotalSales] = useState({ + title: "Total Sales", + value: 0, + description: "+150% from last month", + icon: "arrow-up", + }); + + useEffect(() => { + (async () => { + try { + setLoading(true); + const baseUrl = await getApiUrl(); + const API_URL = `${baseUrl}/api/v1/users/${id}`; + const response = await axios.get(`${API_URL}`); + + const userDetails: UserDetailsProperties = response.data; + setUserData(userDetails); + setTotalSales((previous) => ({ + ...previous, + value: 450_000, + })); + setTotalOrders((previous) => ({ + ...previous, + value: 1000, + })); + setTotalProducts((previous) => ({ + ...previous, + value: 4000, + })); + } catch (error) { + toast({ + title: `Error fetching user ${id}`, + description: + error instanceof Error + ? error.message + : "An unknown error occurred", + variant: "destructive", + }); + } finally { + setLoading(false); + } + })(); + }, [id, toast]); + + const handleRatingClick = (index: number) => { + setRating(index); + }; + + if (loading) { + + Fetching user data...{" "} + + ; + } + + return ( +
+
+
+
+ {userData?.name?.charAt(0).toUpperCase() ?? + userData?.email?.charAt(0).toUpperCase()} +
+
+
+
+ {userData?.name ? ( + (userData?.name ?? userData?.email) + ) : ( +
+ )} +
+
+ {userData?.email || ( +
+ )} +
+
+
+
+
+
+ A Farmer that loves making fresh food produce from his farm +
+ +
+
+
3.3/5
+
+ {Array.from({ length: 5 }).map((_, index) => ( + handleRatingClick(index + 1)} + className={`h-6 w-6 cursor-pointer ${ + index < rating + ? "stroke-current text-yellow-500" + : "text-black" + }`} + /> + ))} +
+
+
+ ({userData?.products.length} products) +
+
+ Date Added + {userData?.created_at ? ( + formatMongoDate(userData?.created_at as string) + ) : ( +
+ )} +
+
+
+
+ +
+ {[totalProducts, totalOrders, totalSales].map((card, index) => ( + + ))} +
+ +
+
+
+

+ Products +

+
+ Manage & Track users products +
+
+
+ +
+ +
+ +
+ + + + + + + + 1 + + + + + + + +
+
+
+ ); +}; + +export default UserDetails; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/component/userTable.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/component/userTable.tsx index 1af381260..29d93ca78 100644 --- a/src/app/dashboard/(admin)/admin/(overview)/users/component/userTable.tsx +++ b/src/app/dashboard/(admin)/admin/(overview)/users/component/userTable.tsx @@ -1,19 +1,46 @@ +import React from "react"; + +import LoadingSpinner from "~/components/miscellaneous/loading-spinner"; import { UserData } from "../page"; import UserTableBody from "./userTableBody"; import UserTableHead from "./userTableHead"; -const UserTable = ({ data }: { data: UserData[] }) => { +interface UserTableProperties { + data: UserData[]; + onDelete: (userId: string) => void; + isDeleting: boolean; + loading: boolean; + isDialogOpen: boolean; + setIsDialogOpen: React.Dispatch>; +} + +const UserTable: React.FC = ({ + data, + onDelete, + isDeleting, + loading, + isDialogOpen, + setIsDialogOpen, +}) => { return ( <> - + {loading ? ( + + Fetching user data...{" "} + + + ) : ( + + )}
- {data.length === 0 && ( -
- No data -
- )} ); }; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/component/userTableBody.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/component/userTableBody.tsx index 4472340a7..ca6f77925 100644 --- a/src/app/dashboard/(admin)/admin/(overview)/users/component/userTableBody.tsx +++ b/src/app/dashboard/(admin)/admin/(overview)/users/component/userTableBody.tsx @@ -1,8 +1,7 @@ -import axios from "axios"; import { EllipsisVertical } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; -import { getApiUrl } from "~/actions/getApiUrl"; import { Button } from "~/components/ui/button"; import { DropdownMenu, @@ -14,129 +13,124 @@ import { import { UserData } from "../page"; import DeleteDialog from "./dialogue/delete-dialog"; -const UserTableBody = ({ data }: { data: UserData[] }) => { +interface UserTableProperties { + data: UserData[]; + onDelete: (userId: string) => void; + isDeleting: boolean; + isDialogOpen: boolean; + setIsDialogOpen: React.Dispatch>; +} + +const UserTableBody: React.FC = ({ + data, + onDelete, + isDeleting, + isDialogOpen, + setIsDialogOpen, +}) => { const [userId, setUserId] = useState(""); - const [isDeleting, setIsDeleting] = useState(false); - const [isDialogOpen, setIsDialogOpen] = useState(false); const handleOpenDialog = (id: string) => { setIsDialogOpen(true); setUserId(id); }; const handleCloseDialog = () => setIsDialogOpen(false); - - const deleteHandler = async () => { - try { - const baseUrl = getApiUrl(); - const API_URL = `${baseUrl}/api/v1/users/${userId}`; - setIsDeleting(true); - await axios.delete(API_URL); - } catch { - setIsDeleting(false); - } finally { - setIsDeleting(false); - } - }; - return ( <> - {data.map((_data, index) => { - const { - id, - email, - phone, - is_active: status, - name: fullName, - created_at: date, - } = _data; - - return ( - - -
-
-
-
- {fullName[0]} -
+ {Array.isArray(data) && + data.map((_data, index) => { + const { + id, + email, + phone, + is_active: status, + name: fullName, + created_at: date, + } = _data; + + return ( + + +
+
+
+
+ {fullName?.charAt(0).toUpperCase() ?? + email?.charAt(0).toUpperCase()} +
+
-
-
-

- {fullName} -

-
- {email} +
+ + {fullName ?? email} + +
+ {email} +
-
- - - - {phone ?? "Nil"} - - - - {formatMongoDate(date)} - - - -
- {status && ( - <> -
-
Active
- - )} - - {!status && ( - <> -
-
Inactive
- - )} -
- - - - - - - - - - Actions - - Edit - handleOpenDialog(id)}> - Delete - - - - - - ); - })} + + + + {phone ?? "Nil"} + + + + {formatMongoDate(date)} + + + +
+ {status && ( + <> +
+
Active
+ + )} + + {!status && ( + <> +
+
Inactive
+ + )} +
+ + + + + + + + + + Actions + + Edit + handleOpenDialog(id)}> + Delete + + + + + + ); + })} {isDialogOpen && ( onDelete(userId)} /> )} @@ -144,15 +138,10 @@ const UserTableBody = ({ data }: { data: UserData[] }) => { }; function formatMongoDate(mongoDateString: string): string { - // Parse the date string into a JavaScript Date object const date = new Date(mongoDateString); - - // Extract the day, month, and year const day = date.getUTCDate(); - const month = date.getUTCMonth() + 1; // Months are zero-based in JavaScript + const month = date.getUTCMonth() + 1; const year = date.getUTCFullYear(); - - // Format the values into DD/MM/YYYY const formattedDate = `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}/${year}`; return formattedDate; diff --git a/src/app/dashboard/(admin)/admin/(overview)/users/page.tsx b/src/app/dashboard/(admin)/admin/(overview)/users/page.tsx index ade63611d..064625067 100644 --- a/src/app/dashboard/(admin)/admin/(overview)/users/page.tsx +++ b/src/app/dashboard/(admin)/admin/(overview)/users/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ChevronLeft, ChevronRight, Filter } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import CardComponent from "~/components/common/DashboardCard/CardComponent"; import { Button } from "~/components/ui/button"; @@ -25,6 +25,7 @@ import "./assets/style.css"; import axios from "axios"; import { getApiUrl } from "~/actions/getApiUrl"; +import { useToast } from "~/components/ui/use-toast"; interface FilterDataProperties { title: string; @@ -48,10 +49,15 @@ export interface UserData { email: string; name: string; role: string; - phone?: string | number; + phone: string | null; is_active: boolean; signup_type: string; created_at: string; + deleted_at: string | null; + email_verified_at: string | null; + is_verified: boolean; + social_id: string | null; + updated_at: string; } const UserPage = () => { @@ -61,6 +67,13 @@ const UserPage = () => { const [filterData, setFilterData] = useState([]); + const [isDeleting, setIsDeleting] = useState(false); + + const [loading, setLoading] = useState(false); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { toast } = useToast(); + const [totalUserOverview, setTotalUserOverview] = useState({ title: "Total Users", value: 0, @@ -99,49 +112,97 @@ const UserPage = () => { } }; - useEffect(() => { - (async () => { - try { - const baseUrl = await getApiUrl(); - const API_URL = `${baseUrl}/api/v1/users`; - const response = await axios.get(`${API_URL}?page=${page}`); - - setIsNextPageActive(response.data?.next_page_url ? true : false); - - setIsPreviousPageActive(response.data?.prev_page_url ? true : false); + const fetchData = useCallback(async () => { + try { + setLoading(true); + const baseUrl = await getApiUrl(); + const API_URL = `${baseUrl}/api/v1/users`; + const response = await axios.get(`${API_URL}?page=${page}`); + setIsNextPageActive(response.data.data?.next_page_url ? true : false); + setIsPreviousPageActive(response.data.data?.prev_page_url ? true : false); + const usersData: UserData[] = response.data.data.data; + setData(usersData); + setFilterData(usersData); + const totalUser = response.data.data.total; + const deletedUser = usersData.filter( + (user) => user.deleted_at !== null, + ).length; + + setTotalUserOverview((previous) => ({ + ...previous, + value: totalUser, + })); + + setdeletedUserOverview((previous) => ({ + ...previous, + value: deletedUser, + })); + + setActiveUserOverview((previous) => { + let count = 0; + for (const user of usersData) { + if (user.is_active) { + count += 1; + } + } - setData(response.data.data); - setFilterData(response.data.data); - setTotalUserOverview((previous) => ({ + return { ...previous, - value: response.data.total, - })); - - const userData: UserData[] = response.data.data; + value: count, + }; + }); + } catch (error) { + setLoading(false); + toast({ + title: `Error fetching users`, + description: + error instanceof Error ? error.message : "An unknown error occurred", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }, [page, toast]); - setdeletedUserOverview((previous) => ({ - ...previous, - value: response.data.total, - })); - - setActiveUserOverview((previous) => { - let count = 0; - for (const user of userData) { - if (user.is_active) { - count += 1; - } - } + useEffect(() => { + fetchData(); + }, [fetchData]); + + const deleteUser = async (userId: string) => { + try { + const baseUrl = await getApiUrl(); + const API_URL = `${baseUrl}/api/v1/users/${userId}`; + setIsDeleting(true); + await axios.delete(API_URL); + fetchData(); + const updatedUser = data.filter((user) => user.id !== userId); + setData(updatedUser); + setFilterData(updatedUser); + const deletedCount = updatedUser.filter( + (user) => user.deleted_at !== null, + ).length; + setdeletedUserOverview((previous) => ({ + ...previous, + value: deletedCount, + })); + const activeCount = updatedUser.filter((user) => user.is_active).length; + setActiveUserOverview((previous) => ({ + ...previous, + value: activeCount, + })); + } catch { + setIsDeleting(false); + } finally { + setIsDeleting(false); + setIsDialogOpen(false); + } + }; - return { - ...previous, - value: count, - }; - }); - } catch { - // console.log(error); - } - })(); - }, [page]); + if (!data) { +
+ No data +
; + } return ( <> @@ -152,7 +213,7 @@ const UserPage = () => { @@ -221,7 +282,14 @@ const UserPage = () => {
- +
diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 2411becbc..734a3fe96 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -65,6 +65,18 @@ export const organizationSchema = z.object({ }), }); +export const ResetPasswordSchema = z + .object({ + password: passwordSchema, + confirmPassword: z + .string() + .min(1, { message: "Confirm Password is required" }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + export const OtpSchema = z.object({ token: z.string(), email: z.string().email().optional(),