generated from muhtadeetaron/nextjs-template
feat(auth): add verification feature in settings
This commit is contained in:
@ -4,6 +4,7 @@ import BackgroundWrapper from "@/components/BackgroundWrapper";
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import {
|
import {
|
||||||
|
BadgeCheck,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -59,6 +60,19 @@ const SettingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-8">
|
<section className="flex flex-col gap-8">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/settings/verify")}
|
||||||
|
className="flex justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<BadgeCheck size={30} color="#113768" />
|
||||||
|
<h3 className="text-md font-medium text-[#113768]">
|
||||||
|
Verify your account
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={30} color="#113768" />
|
||||||
|
</button>
|
||||||
|
<div className="h-[0.5px] border-[0.1px] w-full border-[#113768]/20"></div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/profile")}
|
onClick={() => router.push("/profile")}
|
||||||
className="flex justify-between"
|
className="flex justify-between"
|
||||||
|
|||||||
96
app/(tabs)/settings/verify/page.tsx
Normal file
96
app/(tabs)/settings/verify/page.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
import { API_URL } from "@/lib/auth";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import Image from "next/image";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const VerificationScreen = () => {
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { fetchUser } = useAuthStore();
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
if (otp.length < 6) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/verify?code=${otp}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || "Verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Call zustand action to update auth state
|
||||||
|
await fetchUser();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<Header displayTabTitle="Verification" />
|
||||||
|
<div className="flex flex-col items-center justify-center pt-10 px-6 gap-4">
|
||||||
|
<Image
|
||||||
|
src={"/images/icons/otp.svg"}
|
||||||
|
height={200}
|
||||||
|
width={300}
|
||||||
|
alt="otp-banner"
|
||||||
|
/>
|
||||||
|
<h1 className="font-medium text-xl text-center ">
|
||||||
|
Enter the code here that you received in your email.
|
||||||
|
</h1>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={otp}
|
||||||
|
onChange={setOtp}
|
||||||
|
onComplete={handleVerify} // auto-submit when complete
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 mt-3">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={otp.length < 6 || loading}
|
||||||
|
className="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg disabled:bg-gray-400 transition"
|
||||||
|
>
|
||||||
|
{loading ? "Verifying..." : "Verify"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VerificationScreen;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import QuestionItem from "@/components/QuestionItem";
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
@ -10,6 +10,7 @@ import { useTimerStore } from "@/stores/timerStore";
|
|||||||
|
|
||||||
export default function ExamPage() {
|
export default function ExamPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const test_id = searchParams.get("test_id") || "";
|
const test_id = searchParams.get("test_id") || "";
|
||||||
const type = searchParams.get("type") || "";
|
const type = searchParams.get("type") || "";
|
||||||
@ -136,3 +137,6 @@ export default function ExamPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function cancelExam() {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}
|
||||||
|
|||||||
77
components/ui/input-otp.tsx
Normal file
77
components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-14 w-14 items-center justify-center border-y border-r text-lg font-bold shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"capacitor-secure-storage-plugin": "^0.11.0",
|
"capacitor-secure-storage-plugin": "^0.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.523.0",
|
"lucide-react": "^0.523.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@ -4652,6 +4653,16 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/input-otp": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"capacitor-secure-storage-plugin": "^0.11.0",
|
"capacitor-secure-storage-plugin": "^0.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.523.0",
|
"lucide-react": "^0.523.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
1
public/images/icons/otp.svg
Normal file
1
public/images/icons/otp.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.9 KiB |
Reference in New Issue
Block a user