feat: verification pages

This commit is contained in:
2025-06-07 00:12:41 +02:00
parent a9e382d713
commit eaa92d2fe4
5 changed files with 326 additions and 0 deletions

View File

@ -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<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [avatar, setAvatar] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [takingPicture, setTakingPicture] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="w-full sm:max-w-sm mx-auto p-4">
<div className="flex flex-col gap-2 w-full max-w-xs mx-auto">
<h1 className="text-xl font-medium dark:text-gray-200">
Profile Picture
</h1>
<p className="dark:text-gray-400 mb-6">
Please take a photo of yourself for your avatar in order to continue.
</p>
<div className="relative w-48 h-48 mx-auto rounded-full border-4 border-blue-500 overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
{takingPicture ? (
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover rounded-lg bg-black"
/>
) : avatar ? (
<img
src={avatar}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-4xl text-gray-400">
<User size={48} />
</span>
)}
</div>
{error && <div className="text-red-500 text-center">{error}</div>}
{/* File input (hidden) */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{!avatar && !takingPicture && (
<Button onClick={handleTakePicture}>Take Photo</Button>
)}
{!avatar && !takingPicture && (
<Button variant="outlined" onClick={handleSelectFromDevice}>
Pick from Device
</Button>
)}
{takingPicture && (
<div className="flex flex-col items-center gap-2 w-full">
<Button onClick={handleCapture}>Capture</Button>
<Button variant="outlined" onClick={() => setTakingPicture(false)}>
Cancel
</Button>
</div>
)}
{/* Hidden canvas for snapshot */}
<canvas ref={canvasRef} style={{ display: "none" }} />
{avatar && (
<>
<Link to="/verify/review" className="w-full">
<Button className="w-full">Next</Button>
</Link>
<Button
className="border-yellow-500 text-yellow-500 hover:border-yellow-600 hover:text-yellow-600"
variant="outlined"
onClick={handleRetake}
>
Retake/Choose Another
</Button>
</>
)}
</div>
</div>
);
};
export default VerifyAvatarPage;

View File

@ -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 (
<div className="flex flex-col items-stretch gap-2 max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">
OTP Verification
</h1>
<p className="text-sm dark:text-gray-400">
We've sent you verification code on your email address, please open your
mailbox and enter the verification code in order to continue.
</p>
<Input placeholder="Enter OTP" />
<Link to="/verify/avatar" className="w-full">
<Button className="mt-3 w-full">Verify</Button>
</Link>
</div>
);
};
export default VerifyEmailOtpPage;

View File

@ -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 (
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">E-Mail Address</h1>
<p className="text-sm dark:text-gray-400">
Please fill in your email address used in this account.
</p>
<Input
value={email}
placeholder={maskEmail(profile?.email ?? "")}
onChange={(e) => {
e.preventDefault();
setEmail(e.target.value);
}}
/>
<Button
className={`mt-3 ${!matches ? "opacity-60" : ""}`}
onClick={handleNext}
disabled={!matches}
>
Next
</Button>
</div>
);
};
export default VerifyEmailPage;

View File

@ -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 (
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">
You're all setup!
</h1>
<p className="text-sm dark:text-gray-400">
You've just finished your account verification. Now you can finally
access your account and use it for home services.
</p>
<Avatar
avatarId={profile?.profile_picture ?? undefined}
iconSize={64}
className="w-48 h-48 min-w-48 mx-auto mt-4"
/>
<div className="flex flex-col items-center mb-5">
<h2 className="dark:text-gray-200 text-2xl">{profile?.full_name}</h2>
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
</div>
<Button className="mt-4">Back Home</Button>
</div>
);
};
export default VerifyReviewPage;

View File

@ -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 (
<div className="flex flex-col items-center justify-center gap-5 w-full h-screen px-4 sm:px-0 sm:max-w-xl sm:h-auto text-center">
<img src="/icon.png" className="w-16 h-16" alt="icon" />
<h1 className="text-2xl dark:text-gray-200 font-medium">
Welcome to Home Guard!
</h1>
<p className="text-base dark:text-gray-500">
Before you can access your account, we need to first verify your email
address and setup your profile.
</p>
<p className="text-base dark:text-gray-500">
You will be prompted to upload your real picture as well.{" "}
<Link to="#" className="text-blue-500">
Learn more.
</Link>
</p>
<Link to="/verify/email">
<Button className="flex items-center gap-2">
<ArrowRight />
Start Verification
</Button>
</Link>
</div>
);
};
export default VerifyStartPage;