add: login page fundamentals

This commit is contained in:
Mahyar Khanbolooki
2025-06-30 21:17:35 +03:30
parent 268782fcc5
commit 09d1093253
28 changed files with 563 additions and 22 deletions
+48
View File
@@ -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
View File
@@ -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"
}
}
+43
View File
@@ -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

+221
View File
@@ -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
View File
@@ -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;
}
+13
View File
@@ -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
+70
View File
@@ -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>
);
};
+29
View File
@@ -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
+77
View File
@@ -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