From eaa92d2fe4b8022d16510e3c63c2099f92ef4976 Mon Sep 17 00:00:00 2001 From: LandaMm Date: Sat, 7 Jun 2025 00:12:41 +0200 Subject: [PATCH] feat: verification pages --- web/src/pages/Verify/Avatar/index.tsx | 180 +++++++++++++++++++++++ web/src/pages/Verify/Email/OTP/index.tsx | 24 +++ web/src/pages/Verify/Email/index.tsx | 49 ++++++ web/src/pages/Verify/Review/index.tsx | 37 +++++ web/src/pages/Verify/index.tsx | 36 +++++ 5 files changed, 326 insertions(+) create mode 100644 web/src/pages/Verify/Avatar/index.tsx create mode 100644 web/src/pages/Verify/Email/OTP/index.tsx create mode 100644 web/src/pages/Verify/Email/index.tsx create mode 100644 web/src/pages/Verify/Review/index.tsx create mode 100644 web/src/pages/Verify/index.tsx diff --git a/web/src/pages/Verify/Avatar/index.tsx b/web/src/pages/Verify/Avatar/index.tsx new file mode 100644 index 0000000..45b94f0 --- /dev/null +++ b/web/src/pages/Verify/Avatar/index.tsx @@ -0,0 +1,180 @@ +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/store/auth"; +import { User } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type FC } from "react"; +import { Link } from "react-router"; + +const VerifyAvatarPage: FC = () => { + const profile = useAuth((s) => s.profile); + + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [stream, setStream] = useState(null); + const [avatar, setAvatar] = useState(null); + const [error, setError] = useState(null); + const [takingPicture, setTakingPicture] = useState(false); + + const fileInputRef = useRef(null); + + useEffect(() => { + if (profile?.profile_picture) setAvatar(profile.profile_picture); + }, [profile?.profile_picture]); + + // Request camera stream + useEffect(() => { + if (!takingPicture) return; + if (!navigator.mediaDevices?.getUserMedia) { + setError("Camera not supported on this device/browser."); + return; + } + navigator.mediaDevices + .getUserMedia({ video: true }) + .then((mediaStream) => { + setStream(mediaStream); + setError(null); + if (videoRef.current) { + videoRef.current.srcObject = mediaStream; + } + }) + .catch(() => setError("Unable to access camera.")); + return () => { + // Clean up camera stream when component unmounts or stops taking picture + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + setStream(null); + } + }; + // eslint-disable-next-line + }, [takingPicture]); + + const handleTakePicture = useCallback(() => { + setTakingPicture(true); + }, []); + + const handleCapture = useCallback(() => { + if (!videoRef.current || !canvasRef.current) return; + const context = canvasRef.current.getContext("2d"); + if (!context) return; + // Set canvas size to video size + canvasRef.current.width = videoRef.current.videoWidth; + canvasRef.current.height = videoRef.current.videoHeight; + context.drawImage( + videoRef.current, + 0, + 0, + videoRef.current.videoWidth, + videoRef.current.videoHeight, + ); + const imageData = canvasRef.current.toDataURL("image/png"); + setAvatar(imageData); + setTakingPicture(false); + }, []); + + const handleSelectFromDevice = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("Please select an image file."); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + setAvatar(event.target?.result as string); + setError(null); + }; + reader.readAsDataURL(file); + }, + [], + ); + + const handleRetake = useCallback(() => { + setAvatar(null); + setTakingPicture(false); + }, []); + + return ( +
+
+

+ Profile Picture +

+

+ Please take a photo of yourself for your avatar in order to continue. +

+
+ {takingPicture ? ( +
+ {error &&
{error}
} + + {/* File input (hidden) */} + + + {!avatar && !takingPicture && ( + + )} + {!avatar && !takingPicture && ( + + )} + + {takingPicture && ( +
+ + +
+ )} + + {/* Hidden canvas for snapshot */} + + + {avatar && ( + <> + + + + + + )} +
+
+ ); +}; + +export default VerifyAvatarPage; diff --git a/web/src/pages/Verify/Email/OTP/index.tsx b/web/src/pages/Verify/Email/OTP/index.tsx new file mode 100644 index 0000000..b166416 --- /dev/null +++ b/web/src/pages/Verify/Email/OTP/index.tsx @@ -0,0 +1,24 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { type FC } from "react"; +import { Link } from "react-router"; + +const VerifyEmailOtpPage: FC = () => { + return ( +
+

+ OTP Verification +

+

+ We've sent you verification code on your email address, please open your + mailbox and enter the verification code in order to continue. +

+ + + + +
+ ); +}; + +export default VerifyEmailOtpPage; diff --git a/web/src/pages/Verify/Email/index.tsx b/web/src/pages/Verify/Email/index.tsx new file mode 100644 index 0000000..2015857 --- /dev/null +++ b/web/src/pages/Verify/Email/index.tsx @@ -0,0 +1,49 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/store/auth"; +import maskEmail from "@/util/maskEmail"; +import { useCallback, useMemo, useState, type FC } from "react"; +import { useNavigate } from "react-router"; + +const VerifyEmailPage: FC = () => { + const profile = useAuth((s) => s.profile); + const navigate = useNavigate(); + + const [email, setEmail] = useState(""); + const matches = useMemo( + () => email === profile?.email, + [email, profile?.email], + ); + + const handleNext = useCallback(() => { + if (matches) { + navigate("/verify/email/otp"); + } + }, [matches, navigate]); + + return ( +
+

E-Mail Address

+

+ Please fill in your email address used in this account. +

+ { + e.preventDefault(); + setEmail(e.target.value); + }} + /> + +
+ ); +}; + +export default VerifyEmailPage; diff --git a/web/src/pages/Verify/Review/index.tsx b/web/src/pages/Verify/Review/index.tsx new file mode 100644 index 0000000..5b0d2a6 --- /dev/null +++ b/web/src/pages/Verify/Review/index.tsx @@ -0,0 +1,37 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Avatar from "@/feature/Avatar"; +import { useAuth } from "@/store/auth"; +import type { FC } from "react"; + +const VerifyReviewPage: FC = () => { + const profile = useAuth((s) => s.profile); + + return ( +
+

+ You're all setup! +

+

+ You've just finished your account verification. Now you can finally + access your account and use it for home services. +

+ + + +
+

{profile?.full_name}

+ +

{profile?.email}

+
+ + +
+ ); +}; + +export default VerifyReviewPage; diff --git a/web/src/pages/Verify/index.tsx b/web/src/pages/Verify/index.tsx new file mode 100644 index 0000000..d53c02e --- /dev/null +++ b/web/src/pages/Verify/index.tsx @@ -0,0 +1,36 @@ +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/store/auth"; +import { ArrowRight } from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router"; + +const VerifyStartPage: FC = () => { + const profile = useAuth((s) => s.profile); + + return ( +
+ icon +

+ Welcome to Home Guard! +

+

+ Before you can access your account, we need to first verify your email + address and setup your profile. +

+

+ You will be prompted to upload your real picture as well.{" "} + + Learn more. + +

+ + + +
+ ); +}; + +export default VerifyStartPage;