add: login page fundamentals
This commit is contained in:
Generated
+48
@@ -8,6 +8,8 @@
|
||||
"name": "dmenu-plus-frontned",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"dmenu-plus-frontned": "file:",
|
||||
"framer-motion": "^12.20.1",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
@@ -2527,6 +2529,10 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dmenu-plus-frontned": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -3333,6 +3339,33 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.20.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.20.1.tgz",
|
||||
"integrity": "sha512-NW2t2GHQcNvLHq18JyNVY15VKrwru+nkNyhLdqf4MbxbGhxZcSDi68iNcAy6O1nG0yYAQJbLioBIH1Kmg8Xr1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.20.1",
|
||||
"motion-utils": "^12.19.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -4605,6 +4638,21 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.20.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.20.1.tgz",
|
||||
"integrity": "sha512-XyveLJ9dmQTmaEsP9RlcuoNFxWlRIGdasdPJBB4aOwPr8bRcJdhltudAbiEjRQBmsGD30sjJdaEjhkHsAHapLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.19.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.19.0.tgz",
|
||||
"integrity": "sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
+8
-6
@@ -9,19 +9,21 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"dmenu-plus-frontned": "file:",
|
||||
"framer-motion": "^12.20.1",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.4"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Irancell Font Section */
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Irancell';
|
||||
src: url('/assets/fonts/Irancell_ExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
/* ------------------ */
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, type FormEvent, type ChangeEvent, useEffect, useRef } from 'react'
|
||||
import PrimaryButton from '@/components/button/PrimaryButton'
|
||||
import { EyeToggleIcon } from '@/components/icons/EyeToggleIcon';
|
||||
import InputField from '@/components/input/InputField';
|
||||
import OTPInputField from '@/components/input/MultiInputField';
|
||||
|
||||
enum LOGIN_STEPS {
|
||||
ENTER_NUMBER = 0,
|
||||
ENTER_PASSWORD = 1,
|
||||
ENTER_OTP = 2,
|
||||
}
|
||||
|
||||
enum CLICK_ID {
|
||||
TOGGLE_PASSWORD = "togglePassword",
|
||||
RESET_PASSWORD = "resetPassword",
|
||||
RESEND_OTP = "resendOtp"
|
||||
}
|
||||
|
||||
type Props = object
|
||||
|
||||
function AuthIndex({ }: Props) {
|
||||
const [number, setNumber] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [step, setStep] = useState(LOGIN_STEPS.ENTER_NUMBER);
|
||||
const [timerRunning, setTimerRunning] = useState(false);
|
||||
const [secondLeft, setSecondsLeft] = useState(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null); // ← useRef for interval
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRunning && intervalRef.current === null) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSecondsLeft((state) => {
|
||||
if (state <= 1) { // changed to <= 1 to prevent negative values
|
||||
setTimerRunning(false);
|
||||
clearInterval(intervalRef.current!);
|
||||
intervalRef.current = null;
|
||||
return 0;
|
||||
}
|
||||
return state - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup if component unmounts or timerRunning changes
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [timerRunning]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (step == LOGIN_STEPS.ENTER_OTP) {
|
||||
setSecondsLeft(30);
|
||||
setTimerRunning(true);
|
||||
}
|
||||
return () => {
|
||||
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const updateInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.id == "phone") {
|
||||
setNumber(() => e.target.value)
|
||||
}
|
||||
else if (e.target.id == "password") {
|
||||
setPassword(() => e.target.value)
|
||||
}
|
||||
else if (e.target.id == "rememberMe") {
|
||||
setRememberMe((state) => !state)
|
||||
}
|
||||
else if (e.target.id.startsWith("otp-data")) {
|
||||
const index = +e.target.id.split('-')[2];
|
||||
const prev = otp.padEnd(6).split('');
|
||||
prev[index] = e.target.value;
|
||||
const next = prev.join('');
|
||||
setOtp(next.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
const onClickEvent = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLButtonElement;
|
||||
|
||||
if (target.id === CLICK_ID.TOGGLE_PASSWORD) {
|
||||
setShowPassword((state) => !state);
|
||||
}
|
||||
else if (target.id === CLICK_ID.RESET_PASSWORD) {
|
||||
setShowPassword((state) => !state);
|
||||
}
|
||||
else if (target.id === CLICK_ID.RESEND_OTP) {
|
||||
setSecondsLeft(30);
|
||||
setTimerRunning(true);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
console.log("STEP: ", step)
|
||||
if (step == LOGIN_STEPS.ENTER_NUMBER) {
|
||||
setStep(LOGIN_STEPS.ENTER_PASSWORD)
|
||||
}
|
||||
if (step == LOGIN_STEPS.ENTER_PASSWORD) {
|
||||
setStep(LOGIN_STEPS.ENTER_OTP)
|
||||
}
|
||||
if (step == LOGIN_STEPS.ENTER_OTP) {
|
||||
setStep(LOGIN_STEPS.ENTER_NUMBER)
|
||||
setTimerRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step == LOGIN_STEPS.ENTER_NUMBER) {
|
||||
// First Step -> Enter number
|
||||
return (
|
||||
<form onSubmit={submit} className='p-6 h-full flex flex-col justify-between'>
|
||||
<div className='block'>
|
||||
<img src='/assets/images/login-banner.png' />
|
||||
<div className='pt-4' dir='rtl'>
|
||||
<h6 className='text-lg font-bold'>ورود به سیستم</h6>
|
||||
<p className='mt-3 text-[13px]'>برای ورود شماره همراه خود را وارد نمایید.</p>
|
||||
</div>
|
||||
<InputField
|
||||
className='mt-10'
|
||||
htmlFor='phone'
|
||||
labelText='شماره همراه'
|
||||
value={number}
|
||||
placeholder='09120000000'
|
||||
onChange={updateInput}
|
||||
type='number' />
|
||||
</div>
|
||||
<PrimaryButton type='submit'>بعدی</PrimaryButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
if (step == LOGIN_STEPS.ENTER_PASSWORD) {
|
||||
return (
|
||||
<form onSubmit={submit} className='p-6 h-full flex flex-col justify-between'>
|
||||
<div className='block'>
|
||||
<img src='/assets/images/login-banner.png' />
|
||||
<div className='pt-4' dir='rtl'>
|
||||
<h6 className='text-lg font-bold'>ورود به سیستم</h6>
|
||||
<p className='mt-3 text-[13px]'>کلمه عبور خود را وارد نمایید.</p>
|
||||
</div>
|
||||
<InputField
|
||||
className='mt-10'
|
||||
htmlFor='password'
|
||||
labelText='کلمه عبور'
|
||||
value={password}
|
||||
placeholder=''
|
||||
onChange={updateInput}
|
||||
type={showPassword ? 'text' : 'password'}>
|
||||
<button className='ps-3' id={CLICK_ID.TOGGLE_PASSWORD} type='button' onClick={onClickEvent}>
|
||||
<EyeToggleIcon slash={showPassword} className="pointer-events-none" />
|
||||
</button>
|
||||
</InputField>
|
||||
<div className='inline-flex justify-between items-center w-full pt-3'>
|
||||
<button id={CLICK_ID.RESET_PASSWORD} type='button' onClick={onClickEvent} className='text-sm2 cursor-pointer'>بازیابی کلمه عبور</button>
|
||||
|
||||
<div className='inline-flex justify-between items-center'>
|
||||
{/* TODO: customize checkbox */}
|
||||
<label htmlFor='rememberMe' className='text-sm2 px-2 py-0.5'>مرا به خاطر بسپار</label>
|
||||
<input id='rememberMe' className='h-4.5 w-4.5 checked:accent-primary' onChange={updateInput} type='checkbox' checked={rememberMe} /> </div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton type='submit'>ورود به منو</PrimaryButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
if (step == LOGIN_STEPS.ENTER_OTP) {
|
||||
return (
|
||||
<form onSubmit={submit} className='p-6 h-full flex flex-col justify-between'>
|
||||
<div className='block'>
|
||||
<img src='/assets/images/login-banner.png' />
|
||||
<div className='pt-4' dir='rtl'>
|
||||
{/* // TODO: add persian digits font */}
|
||||
<h6 className='text-lg font-bold'>کد فعالسازی را وارد کنید</h6>
|
||||
<p className='mt-3 text-[13px]'>کد ۶ رقمی ارسال شده به شماره {number} را وارد نمایید.</p>
|
||||
</div>
|
||||
<OTPInputField
|
||||
className='mt-10'
|
||||
maxLength={6}
|
||||
htmlFor='otp'
|
||||
labelText=''
|
||||
value={otp}
|
||||
placeholder=''
|
||||
onChange={updateInput}
|
||||
type='text'>
|
||||
|
||||
</OTPInputField>
|
||||
<div className='inline-flex justify-center items-center w-full pt-6 text-xs'>
|
||||
<div dir='rtl'>
|
||||
{timerRunning ?
|
||||
<div>{secondLeft} ثانیه دیگر تا دریافت کد</div> :
|
||||
<div>
|
||||
کد را دریافت نکردید؟
|
||||
<span className='text-primary px-1'>
|
||||
<button id={CLICK_ID.RESEND_OTP} className='cursor-pointer' onClick={onClickEvent}>
|
||||
دوباره امتحان کنید
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton type='submit'>بعدی</PrimaryButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthIndex
|
||||
+54
-16
@@ -1,26 +1,64 @@
|
||||
@import "tailwindcss";
|
||||
@import "../../public/assets/css/fonts.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@theme {
|
||||
--font-irancell: "Irancell";
|
||||
|
||||
--color-background: #FFFFFF;
|
||||
--color-primary: #C39854;
|
||||
--color-foreground: #333333;
|
||||
--color-disabled: #999999;
|
||||
--radius-normal: 10px;
|
||||
--text-sm2: 13px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
html,
|
||||
body {
|
||||
@apply h-full bg-background font-irancell font-normal text-foreground;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* color-scheme: dark; */
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: none;
|
||||
color: var(--color-disabled);
|
||||
margin: 10px 0px 10px 0px;
|
||||
font-size: 34px;
|
||||
/* Controls dot size (width & height) */
|
||||
line-height: 8px;
|
||||
/* Ensures vertical alignment */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
|
||||
/* add spacing to better separate each image */
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = { } & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
function PrimaryButton({children, ...rest}: Props) {
|
||||
return (
|
||||
<button className='bg-primary w-full rounded-normal p-3 text-white font-bold text-sm' {...rest}>
|
||||
{ children }
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrimaryButton
|
||||
@@ -0,0 +1,70 @@
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
type EyeToggleIconProps = {
|
||||
slash: boolean;
|
||||
} & React.SVGProps<SVGSVGElement>;
|
||||
|
||||
export const EyeToggleIcon: React.FC<EyeToggleIconProps> = ({ slash, ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<filter id="whiteShadow" x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB" >
|
||||
<feDropShadow dx="1.5" dy="0" stdDeviation="0" floodColor="white" floodOpacity="1" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Eye Circle */}
|
||||
<path
|
||||
d="M12.9833 9.99994C12.9833 11.6499 11.6499 12.9833 9.99994 12.9833C8.34993 12.9833 7.0166 11.6499 7.0166 9.99994C7.0166 8.34993 8.34993 7.0166 9.99994 7.0166C11.6499 7.0166 12.9833 8.34993 12.9833 9.99994Z"
|
||||
stroke="#333"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Eye Outline */}
|
||||
<path
|
||||
d="M10.0001 16.8918C12.9418 16.8918 15.6834 15.1584 17.5918 12.1584C18.3418 10.9834 18.3418 9.00843 17.5918 7.83343C15.6834 4.83343 12.9418 3.1001 10.0001 3.1001C7.05845 3.1001 4.31678 4.83343 2.40845 7.83343C1.65845 9.00843 1.65845 10.9834 2.40845 12.1584C4.31678 15.1584 7.05845 16.8918 10.0001 16.8918Z"
|
||||
stroke="#333"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Slash Line - Animate with Framer Motion */}
|
||||
<motion.path
|
||||
d="M19.6 1.66675L1.66699 19.6"
|
||||
stroke="#FFF"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={false}
|
||||
animate={{ strokeWidth: slash ? 0 : 1.2, opacity: slash ? 0 : 1 }}
|
||||
transition={{
|
||||
strokeWidth: { duration: 0.2, ease: "easeInOut" },
|
||||
opacity: { duration: 0.1, ease: "easeInOut", delay: slash ? 0.1 : 0 }
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M18.3332 1.66675L1.66699 18.3334"
|
||||
stroke="#333"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={false}
|
||||
animate={{ pathLength: slash ? 0 : 1, opacity: slash ? 0 : 1 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.3, ease: "easeInOut" },
|
||||
opacity: { duration: 0.2, ease: "easeInOut", delay: slash ? 0.1 : 0 }
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
htmlFor: string;
|
||||
labelText?: React.ReactNode;
|
||||
value?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "id">;
|
||||
|
||||
|
||||
function InputField({ onChange, htmlFor, labelText, children, className, ...inputProps }: Props) {
|
||||
return (
|
||||
<div className={`${className} h-11 inline-flex relative border border-[#E5E5E5] px-3 w-full rounded-normal group focus-within:border focus-within:border-primary`}>
|
||||
<label
|
||||
className='absolute right-2 -top-3 px-2 bg-background text-[12px] text-foreground'
|
||||
htmlFor={htmlFor}>
|
||||
{labelText}
|
||||
</label>
|
||||
<input
|
||||
onChange={onChange}
|
||||
className='py-2.5 text-base w-full outline-0 !leading-6'
|
||||
id={htmlFor}
|
||||
{...inputProps} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputField
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import React, { createRef, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
htmlFor: string;
|
||||
labelText?: React.ReactNode;
|
||||
value?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
maxLength?: number;
|
||||
className?: string;
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "id">;
|
||||
|
||||
|
||||
function OTPInputField({ onChange, maxLength = 6, htmlFor, labelText, className, value = '' }: Props) {
|
||||
const values = value?.toString().split('').slice(0, maxLength);
|
||||
const inputRefs = useRef(Array.from({ length: maxLength }, () => createRef<HTMLInputElement>()));
|
||||
const [prevLength, setPrevLength] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const valLength = values.length;
|
||||
const focusIndex = valLength < prevLength
|
||||
? Math.max(0, valLength)
|
||||
: Math.min(valLength, maxLength - 1);
|
||||
|
||||
inputRefs.current[focusIndex]?.current?.focus();
|
||||
setPrevLength(valLength);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const keyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const val = String(value);
|
||||
const target = e.target as HTMLInputElement;
|
||||
const index = +target.id.split('-')[2];
|
||||
|
||||
if (e.key == "Backspace") {
|
||||
let i = Math.max(0, val.length - 1)
|
||||
if (val.length >= maxLength && index < maxLength) {
|
||||
i = maxLength - 1;
|
||||
}
|
||||
inputRefs.current[i]?.current?.focus();
|
||||
setPrevLength(() => i)
|
||||
} else {
|
||||
const i = Math.max(0, val.length)
|
||||
inputRefs.current[i]?.current?.focus();
|
||||
setPrevLength(() => i)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className} h-14 inline-flex relative border-none w-full`}>
|
||||
<label
|
||||
className='absolute right-0 -top-6 px-2 bg-background text-[12px] text-foreground'
|
||||
htmlFor={htmlFor}>
|
||||
{labelText}
|
||||
</label>
|
||||
<div className='inline-flex justify-between items-center gap-2'>
|
||||
{inputRefs?.current?.map((ref, i) => {
|
||||
return (
|
||||
<input
|
||||
key={i}
|
||||
onKeyDown={keyDown}
|
||||
onChange={onChange}
|
||||
id={`${htmlFor}-data-${i}`}
|
||||
readOnly={Math.max(0, values?.length) != i && prevLength != i}
|
||||
ref={ref}
|
||||
value={values?.at(i) ?? ''}
|
||||
maxLength={1}
|
||||
className='outline-none text-center inline-flex items-center justify-center h-full relative border border-[#E5E5E5] w-full rounded-normal group focus-within:border focus-within:border-primary' />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OTPInputField
|
||||
Reference in New Issue
Block a user