From 6d5f3e29aaf26cd7d62598fe2da8684464974e20 Mon Sep 17 00:00:00 2001
From: Skeby <103804946+Skeby@users.noreply.github.com>
Date: Wed, 24 Jul 2024 16:31:46 +0100
Subject: [PATCH] feat: implement forgot password page
---
.../(auth-routes)/forgot-password/page.tsx | 318 +++++++++++++++++-
src/app/(auth-routes)/layout.tsx | 4 +-
src/components/common/button/button.tsx | 3 +
src/components/common/input-otp/index.tsx | 40 +++
src/components/common/input/index.tsx | 123 +++++++
src/components/common/input/input.tsx | 96 ++++++
src/components/layouts/footer/footer.test.tsx | 1 +
src/components/ui/input-otp.tsx | 75 +++++
src/components/ui/tooltip.tsx | 30 ++
9 files changed, 685 insertions(+), 5 deletions(-)
create mode 100644 src/components/common/input-otp/index.tsx
create mode 100644 src/components/common/input/index.tsx
create mode 100644 src/components/common/input/input.tsx
create mode 100644 src/components/ui/input-otp.tsx
create mode 100644 src/components/ui/tooltip.tsx
diff --git a/src/app/(auth-routes)/forgot-password/page.tsx b/src/app/(auth-routes)/forgot-password/page.tsx
index 6c0021b0d..f28284bdf 100644
--- a/src/app/(auth-routes)/forgot-password/page.tsx
+++ b/src/app/(auth-routes)/forgot-password/page.tsx
@@ -1,9 +1,319 @@
-function ForgotPassword() {
- return (
+"use client";
+
+import { TooltipArrow } from "@radix-ui/react-tooltip";
+import { AnimatePresence, motion } from "framer-motion";
+import { CircleCheck, X } from "lucide-react";
+import Link from "next/link";
+import { ReactNode, useState } from "react";
+
+import CustomButton from "~/components/common/button/button";
+import { InputOtp } from "~/components/common/input-otp";
+import CustomInput from "~/components/common/input/input";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "~/components/ui/tooltip";
+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",
+ "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,
+}: {
+ children?: ReactNode;
+ className?: string;
+}) => (
+
+ {children}
+
+);
+
+const Heading = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+const Description = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+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 Default = (
<>
- forgot password
+
+ Forgot Password
+
+ Enter the email address you used to create the account to receive
+ instructions on how to reset your password
+
+
+
+
+
+ {
+ const newValue = event.target.value;
+ const emailMatch = newValue
+ ? registeredEmails.find((email) => email.includes(newValue))
+ : undefined;
+ setEmailTooltipContent(
+ emailMatch ? (emailMatch === newValue ? "" : emailMatch) : "",
+ );
+ setEmail(newValue);
+ }}
+ value={email}
+ variant="border"
+ label="Email"
+ placeholder="Enter your email"
+ className="h-12 rounded-lg text-sm placeholder:text-sm focus:border-primary focus:bg-zinc-50 focus:placeholder:text-neutral-dark-2 sm:h-16 sm:text-lg sm:placeholder:text-lg"
+ labelClassName="sm:text-xl text-[13px] text-neutral-dark-2"
+ />
+ {email &&
+ !registeredEmails.some((registeredEmail) =>
+ registeredEmail.includes(email),
+ ) && (
+
+ This email doesn't match our records please try again
+
+ )}
+
+ {
+ setEmail(emailTooltipContent);
+ setEmailTooltipContent("");
+ }}
+ >
+ {emailTooltipContent}
+
+
+
+
+
+ Send
+
+
+
+ Remember your Password?
+
+
+ Login
+
+
>
);
-}
+
+ const VerificationCode = (
+ <>
+
+ Verification Code{isOtpResent ? " Resent" : ""}
+
+ Confirm the OTP sent to
+ {email} and enter the verification
+ code that was sent. Code expires in{" "}
+ 00:59
+
+
+
+ {
+ setCode(value);
+ setIsCodeComplete(value.length === 6);
+ }}
+ onComplete={() => {
+ setIsCodeCorrect(code === "123456");
+ }}
+ />
+ {!isCodeCorrect && isCodeComplete && (
+ The OTP entered is not correct. Try again
+ )}
+
+
+ Verify
+
+
+
+
+ Didn't receive any code?
+
+ setIsOtpResent(true)}
+ >
+ Resend OTP
+
+
+ {isOtpResent && (
+ <>
+
+
+ Or
+
+
+
{
+ setCurrentStage(0);
+ setIsOtpResent(false);
+ setIsCodeComplete(false);
+ setCode("");
+ setEmail("");
+ }}
+ >
+ Change email
+
+ >
+ )}
+
+ >
+ );
+
+ const VerificationSuccessful = (
+ <>
+
+ Verification Successful
+
+ Your verification was successful, you can now proceed to reset your
+ password
+
+
+
+ Reset Password
+
+ >
+ );
+
+ const VerificationSuccessfulMessage = (
+
+
+
+
+
+ Email Verified Successfully
+
+
+
setShowSuccessMessage(false)}
+ />
+
+
+ );
+
+ const sections = [
+ {
+ element: Default,
+ stage: 0,
+ onSubmit: () => setCurrentStage(1),
+ },
+ {
+ element: VerificationCode,
+ stage: 1,
+ onSubmit: () => setCurrentStage(2),
+ },
+ {
+ element: VerificationSuccessful,
+ stage: 2,
+ onSubmit: () => {},
+ },
+ ];
+
+ return (
+
+
+ {currentStage === 2 &&
+ showSuccessMessage &&
+ VerificationSuccessfulMessage}
+
+ {sections.map(
+ (section, index) =>
+ section.stage === currentStage && (
+
+ ),
+ )}
+
+ );
+};
export default ForgotPassword;
diff --git a/src/app/(auth-routes)/layout.tsx b/src/app/(auth-routes)/layout.tsx
index 0248d906d..8f29f2af4 100644
--- a/src/app/(auth-routes)/layout.tsx
+++ b/src/app/(auth-routes)/layout.tsx
@@ -5,7 +5,9 @@ function Layout({ children }) {
return (
<>
- {children}
+
+ {children}
+
>
);
diff --git a/src/components/common/button/button.tsx b/src/components/common/button/button.tsx
index bef7075b8..faf6fb57f 100644
--- a/src/components/common/button/button.tsx
+++ b/src/components/common/button/button.tsx
@@ -23,6 +23,7 @@ type Variant =
type Size = "default" | "sm" | "lg" | "link" | "icon" | "circle";
interface ButtonProperties {
+ type?: "submit" | "button" | "reset";
/** Specifies the button style variant */
variant?: Variant;
/** Specifies the size of the button */
@@ -58,6 +59,7 @@ interface ButtonProperties {
* @returns {JSX.Element} The rendered button component.
*/
const CustomButton: FC = ({
+ type = "button",
variant,
size,
children,
@@ -109,6 +111,7 @@ const CustomButton: FC = ({
aria-label={ariaLabel}
>
void;
+ onComplete?: () => void;
+ slotClassName?: string;
+ slotNumber?: number;
+ className?: string;
+ containerClassName?: string;
+}
+
+export function InputOtp({
+ onChange,
+ onComplete,
+ slotNumber = 6,
+ slotClassName,
+ className,
+ // containerClassName,
+}: Properties) {
+ return (
+
+
+ {/* eslint-disable unicorn/no-new-array */}
+ {/* eslint-disable unicorn/no-useless-spread */}
+ {[...new Array(slotNumber)].map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/common/input/index.tsx b/src/components/common/input/index.tsx
new file mode 100644
index 000000000..b56cfa722
--- /dev/null
+++ b/src/components/common/input/index.tsx
@@ -0,0 +1,123 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import CustomButton from "~/components/common/button/button";
+import { cn } from "~/lib/utils";
+
+const inputVariants = cva("text-sm rounded-md transition-colors ", {
+ variants: {
+ variant: {
+ primary:
+ "border-primary text-primary focus:outline-none focus:ring-1 focus:ring-primary",
+ border:
+ "border-border text-foreground focus:outline-none focus:ring-1 focus:ring-border",
+ },
+ state: {
+ default: "border-border",
+ active: "border-primary",
+ invalid: "border-error",
+ },
+ gap: {
+ sm: "gap-1.5",
+ md: "gap-2",
+ lg: "gap-2.5",
+ },
+ labelPosition: {
+ top: "flex-col",
+ side: "flex-row",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ state: "default",
+ gap: "md",
+ labelPosition: "top",
+ },
+});
+
+export interface InputProperties
+ extends React.InputHTMLAttributes,
+ VariantProps {
+ label?: string;
+ labelClassName?: string;
+ height?: string | number;
+ asChild?: boolean;
+ isButtonVisible?: boolean;
+ buttonContent?: string;
+ buttonDisabled?: boolean;
+ onButtonClick?: React.MouseEventHandler;
+}
+
+const Input = React.forwardRef(
+ (
+ {
+ label,
+ variant,
+ state,
+ gap,
+ labelPosition,
+ asChild = false,
+ isButtonVisible = false,
+ buttonContent,
+ buttonDisabled = false,
+ onButtonClick,
+ className,
+ labelClassName,
+ ...properties
+ },
+ reference,
+ ) => {
+ const Comp = asChild ? Slot : "input";
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ {isButtonVisible && (
+
+
+ {buttonContent}
+
+
+ )}
+
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input, inputVariants };
diff --git a/src/components/common/input/input.tsx b/src/components/common/input/input.tsx
new file mode 100644
index 000000000..b3495c1c6
--- /dev/null
+++ b/src/components/common/input/input.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+import { Input } from "~/components/common/input";
+import { cn } from "~/lib/utils";
+
+interface CustomInputProperties {
+ label?: string;
+ name?: string;
+ labelClassName?: string;
+ placeholder: string;
+ type?: string;
+ value?: string;
+ onChange?: React.ChangeEventHandler;
+ onFocus?: React.FocusEventHandler;
+ isButtonVisible?: boolean;
+ buttonContent?: string;
+ onButtonClick?: React.MouseEventHandler;
+ isDisabled?: boolean;
+ gap?: "sm" | "md" | "lg";
+ labelPosition?: "top" | "side";
+ variant?: "primary" | "border";
+ className?: string;
+}
+
+const CustomInput: React.FC = ({
+ label,
+ name,
+ labelClassName,
+ placeholder,
+ type = "text",
+ value: propertyValue,
+ onChange,
+ onFocus,
+ isButtonVisible = false,
+ buttonContent = "Button",
+ onButtonClick,
+ isDisabled = false,
+ gap = "md",
+ labelPosition = "top",
+ variant = "primary",
+ className,
+}) => {
+ const [inputValue, setInputValue] = useState(propertyValue || "");
+ const [isValid, setIsValid] = useState(true);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const value = event.target.value;
+ setInputValue(value);
+ if (onChange) onChange(event);
+
+ if (type === "email") {
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ setIsValid(emailPattern.test(value));
+ }
+ };
+
+ const handleFocus = (event: React.FocusEvent) => {
+ if (onFocus) onFocus(event);
+ };
+
+ const inputState =
+ !isValid && inputValue ? "invalid" : inputValue ? "active" : "default";
+
+ useEffect(() => {
+ setInputValue(propertyValue || "");
+ }, [propertyValue]);
+
+ return (
+
+ );
+};
+
+export default CustomInput;
diff --git a/src/components/layouts/footer/footer.test.tsx b/src/components/layouts/footer/footer.test.tsx
index 6c5d8747c..5edf885ce 100644
--- a/src/components/layouts/footer/footer.test.tsx
+++ b/src/components/layouts/footer/footer.test.tsx
@@ -1,4 +1,5 @@
import Footer from ".";
+
import { render } from "~/test/utils";
describe("page tests", () => {
diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx
new file mode 100644
index 000000000..c054585c4
--- /dev/null
+++ b/src/components/ui/input-otp.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { OTPInput, OTPInputContext } from "input-otp";
+import { Dot } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+const InputOTP = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, containerClassName, ...properties }, reference) => (
+
+));
+InputOTP.displayName = "InputOTP";
+
+const InputOTPGroup = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ className, ...properties }, reference) => (
+
+));
+InputOTPGroup.displayName = "InputOTPGroup";
+
+const InputOTPSlot = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div"> & { index: number }
+>(({ index, className, ...properties }, reference) => {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+});
+InputOTPSlot.displayName = "InputOTPSlot";
+
+const InputOTPSeparator = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ ...properties }, reference) => (
+
+
+
+));
+InputOTPSeparator.displayName = "InputOTPSeparator";
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 000000000..9b7c47f81
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...properties }, reference) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };