From 0d822e250bd8a389916d56e5abae391ba20b8703 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sun, 7 Dec 2025 11:42:35 +0330 Subject: [PATCH 01/19] add google login --- .../tabs/account/auth-form/sign-in-form.tsx | 137 +++++++++++++++++- src/services/hooks/auth/authService.hook.ts | 29 +++- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx index e0e8231d..7a9d95ea 100644 --- a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx +++ b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx @@ -1,22 +1,66 @@ import { useState } from 'react' import Analytics from '@/analytics' import { Button } from '@/components/button/button' +import Modal from '@/components/modal' import { TextInput } from '@/components/text-input' import { useAuth } from '@/context/auth.context' -import { useSignIn } from '@/services/hooks/auth/authService.hook' +import { useSignIn, useGoogleSignIn } from '@/services/hooks/auth/authService.hook' import { translateError } from '@/utils/translate-error' +import { GoogleLogin, useGoogleLogin } from '@react-oauth/google' interface SignInFormProps { onSwitchToSignUp: () => void } +async function getAuthToken() { + try { + const token = await browser.identity.getAuthToken({ + interactive: true, + }) + console.log('Access token:', token) + return token + } catch (error) { + console.error('Auth error:', error) + throw error + } +} export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState(null) + const [showReferralModal, setShowReferralModal] = useState(false) + const [referralCode, setReferralCode] = useState('') + const [googleToken, setGoogleToken] = useState(null) const { login } = useAuth() const signInMutation = useSignIn() + const googleSignInMutation = useGoogleSignIn() + + const handleGoogleSignIn = async (referralCode?: string) => { + if (!googleToken) return + + try { + const response = await googleSignInMutation.mutateAsync({ + token: googleToken, + referralCode, + }) + login(response.data) + Analytics.event('sign_in') + setShowReferralModal(false) + setGoogleToken(null) + setReferralCode('') + } catch (err) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + setError('خطا در ورود با گوگل. لطفاً دوباره تلاش کنید.') + } + setShowReferralModal(false) + setGoogleToken(null) + setReferralCode('') + } + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -40,6 +84,48 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { } } + const loginGoogle = async () => { + if (await browser.permissions.contains({ permissions: ['identity'] })) { + } else { + const granted = await browser.permissions.request({ + permissions: ['identity'], + }) + if (!granted) { + console.log('Permission denied') + return + } + } + + const redirectUri = browser.identity.getRedirectURL('google') + console.log('Redirect URI:', redirectUri) + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth') + url.searchParams.set( + 'client_id', + '1062149222368-8cm7q5m4h1amei0b338rifhpqbibis23.apps.googleusercontent.com' + ) + url.searchParams.set('response_type', 'token') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('prompt', 'consent select_account') + url.searchParams.set( + 'scope', + 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile' + ) + + const redirectUrl = await browser.identity.launchWebAuthFlow({ + url: url.toString(), // لینک OAuth گوگل + interactive: true, + }) + + const params = new URLSearchParams(redirectUrl?.split('#')[1]) + const token = params.get('access_token') + console.log('Access Token:', token) + + if (token) { + setGoogleToken(token) + setShowReferralModal(true) + } + } + return (
@@ -89,6 +175,17 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { > {signInMutation.isPending ? 'درحال پردازش...' : 'ورود به حساب'} +
{error && ( @@ -107,6 +204,44 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => {

+ + setShowReferralModal(false)} + title="فقط یک قدم مونده!" + size="sm" + direction="rtl" + > +
+

+ اگه میخای پاداش بگیری و کد دعوت داری کد رو وارد کن! +

+ +
+ + +
+
+
) } diff --git a/src/services/hooks/auth/authService.hook.ts b/src/services/hooks/auth/authService.hook.ts index f635f845..5fbaae64 100644 --- a/src/services/hooks/auth/authService.hook.ts +++ b/src/services/hooks/auth/authService.hook.ts @@ -23,11 +23,9 @@ interface AuthResponse { data: string // token } -interface User { - name: string - gender?: 'MALE' | 'FEMALE' | 'OTHER' - birthdate?: string - avatar?: string +interface GoogleAuthCredentials { + token: string + referralCode?: string } async function signIn(credentials: LoginCredentials): Promise { @@ -52,7 +50,7 @@ async function signUp(credentials: SignUpCredentials): Promise { return response.data } -async function updateUserProfile(formData: FormData): Promise { +async function updateUserProfile(formData: FormData): Promise { const api = await getMainClient() const response = await api.patch('/users/@me', formData, { headers: { @@ -62,7 +60,7 @@ async function updateUserProfile(formData: FormData): Promise { return response.data } -async function updateUsername(username: string): Promise { +async function updateUsername(username: string): Promise { const api = await getMainClient() const response = await api.put('/users/@me/username', { username, @@ -70,6 +68,17 @@ async function updateUsername(username: string): Promise { return response.data } +async function googleSignIn(credentials: GoogleAuthCredentials): Promise { + const client = await getMainClient() + const response = await client.post('/auth/oauth/google', credentials) + + if (response.headers?.refresh_token) { + await setToStorage('refresh_token', response.headers.refresh_token) + } + + return response.data +} + export function useSignIn() { return useMutation({ mutationFn: (credentials: LoginCredentials) => signIn(credentials), @@ -93,3 +102,9 @@ export function useUpdateUsername() { mutationFn: (username: string) => updateUsername(username), }) } + +export function useGoogleSignIn() { + return useMutation({ + mutationFn: (credentials: GoogleAuthCredentials) => googleSignIn(credentials), + }) +} From 9303e922c40aa81ada2647a99e00bb34011950bf Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sun, 7 Dec 2025 17:54:21 +0330 Subject: [PATCH 02/19] feat: enhance referral modal design and functionality in sign-in form --- .../tabs/account/auth-form/sign-in-form.tsx | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx index 7a9d95ea..f5c63641 100644 --- a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx +++ b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx @@ -208,25 +208,53 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { setShowReferralModal(false)} - title="فقط یک قدم مونده!" + title="" size="sm" direction="rtl" + showCloseButton={false} > -
-

- اگه میخای پاداش بگیری و کد دعوت داری کد رو وارد کن! -

- -
+
+
+
+
+
🎁
+
+
+ ! +
+
+
+ +
+

+ فقط یک قدم مونده! +

+

+ اگه میخای پاداش بگیری و کد دعوت داری، کد رو وارد کن! +
+ + بدون کد هم می‌تونی ادامه بدی + +

+
+ + {/* Input Section */} +
+ +
+ + {/* Action Buttons */} +
@@ -235,9 +263,9 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { onClick={() => handleGoogleSignIn(referralCode || undefined)} isPrimary={true} size="sm" - className="w-32 px-4 py-2 rounded-2xl" + className="px-8 py-2.5 rounded-xl font-medium shadow-lg" > - تایید + {referralCode ? 'تایید و ادامه' : 'ادامه بدون کد'}
From 09f5f14ee65bc70b70677fcb237824dc4a170fc0 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sun, 7 Dec 2025 21:29:32 +0330 Subject: [PATCH 03/19] feat: implement password and OTP authentication steps with Google login option --- .../tabs/account/auth-form/auth-form.tsx | 104 ++++++------ .../components/login-google.button.tsx | 82 ++++++++++ .../tabs/account/auth-form/sign-in-form.tsx | 17 +- .../tabs/account/auth-form/steps/auth-otp.tsx | 82 ++++++++++ .../account/auth-form/steps/auth-password.tsx | 153 ++++++++++++++++++ 5 files changed, 367 insertions(+), 71 deletions(-) create mode 100644 src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx diff --git a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx b/src/layouts/setting/tabs/account/auth-form/auth-form.tsx index 6fdb3aa7..2255f7dc 100644 --- a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx +++ b/src/layouts/setting/tabs/account/auth-form/auth-form.tsx @@ -1,71 +1,63 @@ import { useState } from 'react' -import { FiLogIn, FiUserPlus } from 'react-icons/fi' +import { FiLock } from 'react-icons/fi' import Analytics from '@/analytics' -import { SectionPanel } from '@/components/section-panel' -import { SignInForm } from './sign-in-form' -import { SignUpForm } from './sign-up-form' - -type AuthMode = 'signin' | 'signup' - -interface AuthTab { - id: AuthMode - label: string - icon: React.ComponentType<{ size?: number; className?: string }> -} +import { AuthWithPassword } from './steps/auth-password' +import { AuthWithOTP } from './steps/auth-otp' +import { LoginGoogleButton } from './components/login-google.button' export const AuthForm = () => { - const [activeMode, setActiveMode] = useState('signin') - - const authTabs: AuthTab[] = [ - { - id: 'signin', - label: 'ورود به حساب', - icon: FiLogIn, - }, - { - id: 'signup', - label: 'ثبت‌نام', - icon: FiUserPlus, - }, - ] + const [showPasswordForm, setShowPasswordForm] = useState(false) - const getTabStyle = (isActive: boolean) => { - if (isActive) { - return 'bg-primary text-white border-primary shadow-md' - } - return 'bg-transparent text-muted border-base-300 hover:bg-primary/10' + const handleShowPasswordForm = () => { + setShowPasswordForm(true) + Analytics.event('auth_method_changed') } - const handleModeChange = (mode: AuthMode) => { - setActiveMode(mode) - Analytics.event(`auth_tab_change_${mode}`) + const handleBackToOTP = () => { + setShowPasswordForm(false) + Analytics.event('auth_method_changed') } return ( - -
- {authTabs.map((tab) => ( - - ))} -
- -
- {activeMode === 'signin' ? ( - handleModeChange('signup')} - /> +
+
+ {showPasswordForm ? ( + ) : ( - + )}
- + + {!showPasswordForm && ( + <> +
+
+
+
+
+ + یا + +
+
+ +
+ Analytics.event('google_login_success')} + /> + + +
+ + )} +
) } diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx new file mode 100644 index 00000000..4a5c457f --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx @@ -0,0 +1,82 @@ +import { IconLoading } from '@/components/loading/icon-loading' +import { useState } from 'react' + +interface LoginGoogleButtonProps { + onLoginSuccess?: () => void +} + +export function LoginGoogleButton({ onLoginSuccess }: LoginGoogleButtonProps) { + const [isLoading, setIsLoading] = useState(false) + + const loginGoogle = async () => { + setIsLoading(true) + try { + if ( + await browser.permissions.contains({ + permissions: ['identity'], + }) + ) { + } else { + const granted = await browser.permissions.request({ + permissions: ['identity'], + }) + if (!granted) { + console.log('Permission denied') + return + } + } + + const redirectUri = browser.identity.getRedirectURL('google') + console.log('Redirect URI:', redirectUri) + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth') + url.searchParams.set('client_id', import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID) + url.searchParams.set('response_type', 'token') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('prompt', 'consent select_account') + url.searchParams.set( + 'scope', + 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile' + ) + + const redirectUrl = await browser.identity.launchWebAuthFlow({ + url: url.toString(), // لینک OAuth گوگل + interactive: true, + }) + + const params = new URLSearchParams(redirectUrl?.split('#')[1]) + const token = params.get('access_token') + console.log('Access Token:', token) + + if (token) { + // setGoogleToken(token) + // setShowReferralModal(true) + } + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} diff --git a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx index f5c63641..ac0e8c29 100644 --- a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx +++ b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx @@ -6,24 +6,11 @@ import { TextInput } from '@/components/text-input' import { useAuth } from '@/context/auth.context' import { useSignIn, useGoogleSignIn } from '@/services/hooks/auth/authService.hook' import { translateError } from '@/utils/translate-error' -import { GoogleLogin, useGoogleLogin } from '@react-oauth/google' interface SignInFormProps { onSwitchToSignUp: () => void } -async function getAuthToken() { - try { - const token = await browser.identity.getAuthToken({ - interactive: true, - }) - console.log('Access token:', token) - return token - } catch (error) { - console.error('Auth error:', error) - throw error - } -} export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -206,7 +193,7 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => {
setShowReferralModal(false)} title="" size="sm" @@ -263,7 +250,7 @@ export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { onClick={() => handleGoogleSignIn(referralCode || undefined)} isPrimary={true} size="sm" - className="px-8 py-2.5 rounded-xl font-medium shadow-lg" + className="px-8 py-2.5 rounded-xl font-medium shadow-lg !border-2 !border-gray-200 hover:!border-gray-300" > {referralCode ? 'تایید و ادامه' : 'ادامه بدون کد'} diff --git a/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx new file mode 100644 index 00000000..3df2f2af --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import { FiSmartphone } from 'react-icons/fi' +import { TextInput } from '../../../../../../components/text-input' +import { Button } from '@/components/button/button' + +export function AuthWithOTP() { + // Implementation of OTP authentication step(email/SMS)-> enter OTP code -> verify + const [username, setUsername] = useState('') // email can be phone number as well + const [step] = useState<'enter-username' | 'enter-otp'>('enter-username') + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + } + + if (step === 'enter-username') { + return ( +
+ {/* Header */} +
+
+ +
+
+

+ ورود یا ثبت‌نام در ویجتیفای +

+

+ کد تایید به ایمیل یا شماره شما ارسال می‌شود. +

+
+
+ + {/* Step Indicator */} + +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+ {error} +
+ +
+ +
+
+
+ ) + } +} diff --git a/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx b/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx new file mode 100644 index 00000000..9978f5a6 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import { FiMail, FiLock, FiArrowRight } from 'react-icons/fi' +import Analytics from '@/analytics' +import { Button } from '@/components/button/button' +import { TextInput } from '@/components/text-input' +import { useAuth } from '@/context/auth.context' +import { useSignIn } from '@/services/hooks/auth/authService.hook' +import { translateError } from '@/utils/translate-error' + +interface Prop { + onBack: () => void +} +export function AuthWithPassword({ onBack }: Prop) { + // Implementation of password authentication step: enter email/phone and password -> verify + + const [username, setUsername] = useState('') // email can be phone number as well + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + + const { login } = useAuth() + const signInMutation = useSignIn() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + try { + const response = await signInMutation.mutateAsync({ + email: username, + password, + }) + login(response.data) + Analytics.event('sign_in') + } catch (err) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + if (Object.keys(content).length === 0) { + setError('خطا در ورود. لطفاً دوباره تلاش کنید.') + return + } + setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) + } + } + } + return ( +
+ {/* Header */} +
+
+ +
+
+

+ ورود با رمز عبور +

+

+ با ایمیل و رمز عبور خود وارد شوید +

+
+
+ +
+ {/* Email Field */} +
+ +
+
+ +
+ +
+
+ + {/* Password Field */} +
+
+ + + فراموش کردم + +
+
+
+ +
+ +
+
+ + {/* Error Message */} + {error && ( +
+
+ +
+ {error} +
+ )} + + + + {/* Back Button */} +
+ +
+
+
+ ) +} From dead134e4a785a0154a434370b12a77984ae1602 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sun, 7 Dec 2025 23:29:31 +0330 Subject: [PATCH 04/19] feat: implement OTP authentication flow with request and verification steps --- .../tabs/account/auth-form/steps/auth-otp.tsx | 134 +++++++++++++++++- src/services/hooks/auth/authService.hook.ts | 41 +++++- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx index 3df2f2af..7935d2fe 100644 --- a/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx +++ b/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx @@ -2,16 +2,64 @@ import { useState } from 'react' import { FiSmartphone } from 'react-icons/fi' import { TextInput } from '../../../../../../components/text-input' import { Button } from '@/components/button/button' +import { useRequestOtp, useVerifyOtp } from '@/services/hooks/auth/authService.hook' +import { translateError } from '@/utils/translate-error' +import { useAuth } from '@/context/auth.context' export function AuthWithOTP() { - // Implementation of OTP authentication step(email/SMS)-> enter OTP code -> verify + const { login } = useAuth() const [username, setUsername] = useState('') // email can be phone number as well - const [step] = useState<'enter-username' | 'enter-otp'>('enter-username') + const [otp, setOtp] = useState('') + const [step, setStep] = useState<'enter-username' | 'enter-otp'>('enter-username') const [error, setError] = useState(null) + const { mutateAsync: requestOtp } = useRequestOtp() + const { mutateAsync: verifyOtp } = useVerifyOtp() const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) + if (step === 'enter-username') { + await onSubmitRequest() + } else { + await onVerifyOtp() + } + } + + const onSubmitRequest = async () => { + try { + await requestOtp({ email: username }) + setStep('enter-otp') + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + if (Object.keys(content).length === 0) { + setError('خطا در ورود. لطفاً دوباره تلاش کنید.') + return + } + setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) + } + } + } + + const onVerifyOtp = async () => { + try { + setError(null) + const response = await verifyOtp({ email: username, code: otp }) + login(response.data) + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + if (Object.keys(content).length === 0) { + setError('کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.') + return + } + setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) + } + } } if (step === 'enter-username') { @@ -27,17 +75,15 @@ export function AuthWithOTP() { ورود یا ثبت‌نام در ویجتیفای

- کد تایید به ایمیل یا شماره شما ارسال می‌شود. + کد تایید به ایمیل یا شما ارسال می‌شود.

- {/* Step Indicator */} -
@@ -48,7 +94,7 @@ export function AuthWithOTP() { type="text" value={username} onChange={setUsername} - placeholder="example@email.com یا 09123456789" + placeholder="example@email.com" className="w-full !py-3.5" />
@@ -79,4 +125,78 @@ export function AuthWithOTP() {
) } + + return ( +
+ {/* Header */} +
+
+ +
+
+

+ تایید کد ورود +

+

+ کد تایید به{' '} + {username}{' '} + ارسال شد. +

+
+
+ + +
+ +
+ +
+
+ +
+
+ +
+ {error} +
+ +
+ + +
+ +
+ ) } diff --git a/src/services/hooks/auth/authService.hook.ts b/src/services/hooks/auth/authService.hook.ts index 5fbaae64..8f4e4052 100644 --- a/src/services/hooks/auth/authService.hook.ts +++ b/src/services/hooks/auth/authService.hook.ts @@ -21,13 +21,21 @@ interface AuthResponse { statusCode: number message: string | null data: string // token + isNewUser?: boolean } - interface GoogleAuthCredentials { token: string referralCode?: string } +interface OtpPayload { + email: string +} + +interface OtpVerifyPayload { + email: string + code: string +} async function signIn(credentials: LoginCredentials): Promise { const client = await getMainClient() const response = await client.post('/auth/signin', credentials) @@ -79,6 +87,25 @@ async function googleSignIn(credentials: GoogleAuthCredentials): Promise { + const client = await getMainClient() + + const response = await client.post('/auth/otp', payload) + + return response.data +} + +async function verifyOtp(payload: OtpVerifyPayload): Promise { + const client = await getMainClient() + const response = await client.post('/auth/otp/verify', payload) + + if (response.headers?.refresh_token) { + await setToStorage('refresh_token', response.headers.refresh_token) + } + + return response.data +} + export function useSignIn() { return useMutation({ mutationFn: (credentials: LoginCredentials) => signIn(credentials), @@ -108,3 +135,15 @@ export function useGoogleSignIn() { mutationFn: (credentials: GoogleAuthCredentials) => googleSignIn(credentials), }) } + +export function useRequestOtp() { + return useMutation({ + mutationFn: (payload: OtpPayload) => requestOtp(payload), + }) +} + +export function useVerifyOtp() { + return useMutation({ + mutationFn: (payload: OtpVerifyPayload) => verifyOtp(payload), + }) +} From ee8ba2842de05c84d5188dd0c17e50301586dbe8 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sun, 7 Dec 2025 23:50:47 +0330 Subject: [PATCH 05/19] feat: enhance Google login functionality and improve authentication event tracking --- .../tabs/account/auth-form/auth-form.tsx | 8 ++--- .../components/login-google.button.tsx | 34 ++++++++++++++----- .../account/auth-form/steps/auth-password.tsx | 6 ---- src/services/hooks/auth/authService.hook.ts | 2 +- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx b/src/layouts/setting/tabs/account/auth-form/auth-form.tsx index 2255f7dc..2c0644f6 100644 --- a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx +++ b/src/layouts/setting/tabs/account/auth-form/auth-form.tsx @@ -10,12 +10,12 @@ export const AuthForm = () => { const handleShowPasswordForm = () => { setShowPasswordForm(true) - Analytics.event('auth_method_changed') + Analytics.event('auth_method_changed_to_password') } const handleBackToOTP = () => { setShowPasswordForm(false) - Analytics.event('auth_method_changed') + Analytics.event('auth_method_changed_to_otp') } return ( @@ -42,9 +42,7 @@ export const AuthForm = () => {
- Analytics.event('google_login_success')} - /> +
From 96aaea4d15a3c855b921c55494fe00102c12db6a Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Mon, 8 Dec 2025 00:00:16 +0330 Subject: [PATCH 07/19] feat: add translations for invalid OTP code and email usage instructions --- src/utils/translate-error.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/translate-error.ts b/src/utils/translate-error.ts index 8804bbee..5b0e692d 100644 --- a/src/utils/translate-error.ts +++ b/src/utils/translate-error.ts @@ -79,6 +79,8 @@ const errorTranslations: Record = { ITEM_NOT_FOUND: 'آیتم مورد نظر یافت نشد', TODO_NOT_FOUND: 'وظیفه مورد نظر یافت نشد', + INVALID_OTP_CODE: 'کد تایید نامعتبر است', + USE_EMAIL_FOR_OTP: 'لطفا از ایمیل برای دریافت کد تایید استفاده کنید', } const validationTranslations: Record = { From b669273cf1364e76e5d0455bf95427460c090860 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Tue, 9 Dec 2025 21:15:53 +0330 Subject: [PATCH 08/19] feat: expand ignored endpoints for token refreshing in API client --- src/services/api.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/api.ts b/src/services/api.ts index 7a7a401a..d3f4dc04 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -45,7 +45,13 @@ export async function getMainClient(): Promise { _retry?: boolean } - const ignoreEndpoints = ['/auth/signin', '/auth/signup'] + const ignoreEndpoints = [ + '/auth/signin', + '/auth/signup', + '/auth/otp', + '/auth/otp/verify', + '/auth/oauth/google', + ] if ( ignoreEndpoints.some((endpoint) => originalRequest.url?.includes(endpoint) From 183f18d78f97ace9fd335720c15c559ca03655ef Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 11 Dec 2025 00:46:34 +0330 Subject: [PATCH 09/19] add tw-merge package --- bun.lock | 4 ++++ package.json | 1 + 2 files changed, 5 insertions(+) diff --git a/bun.lock b/bun.lock index 27f3f6c9..fb81d461 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "widgetify-webapp", @@ -26,6 +27,7 @@ "react-icons": "5.5.0", "react-joyride": "2.9.3", "swiper": "12.0.2", + "tailwind-merge": "^3.4.0", "uuid": "13.0.0", "workbox-background-sync": "7.3.0", "workbox-cacheable-response": "7.3.0", @@ -1129,6 +1131,8 @@ "swiper": ["swiper@12.0.2", "", {}, "sha512-y8F6fDGXmTVVgwqJj6I00l4FdGuhpFJn0U/9Ucn1MwWOw3NdLV8aH88pZOjyhBgU/6PyBlUx+JuAQ5KMWz906Q=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], diff --git a/package.json b/package.json index 7a5afcbc..2c90da14 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-icons": "5.5.0", "react-joyride": "2.9.3", "swiper": "12.0.2", + "tailwind-merge": "^3.4.0", "uuid": "13.0.0", "workbox-background-sync": "7.3.0", "workbox-cacheable-response": "7.3.0", From 5d7035d23f755e8f40548bc4770a3d9762e97c39 Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 11 Dec 2025 00:46:54 +0330 Subject: [PATCH 10/19] modify modal --- src/components/modal.tsx | 97 +++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/src/components/modal.tsx b/src/components/modal.tsx index 2cd16a83..594ab288 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -1,4 +1,4 @@ -import React, { type ReactNode, useEffect } from 'react' +import React, { type ReactNode, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { LuX } from 'react-icons/lu' @@ -16,20 +16,24 @@ type ModalProps = { const sizeClasses = { sm: { - w: 'max-w-sm', - h: 'max-h-[70vh]', + w: 'w-[calc(100vw-2rem)] max-w-sm', + h: 'max-h-[calc(100vh-4rem)] md:max-h-[560px]', + minH: 'min-h-[180px]', }, md: { - w: 'max-w-md', - h: 'max-h-[80vh]', + w: 'w-[calc(100vw-2rem)] max-w-md', + h: 'max-h-[calc(100vh-4rem)] md:max-h-[640px]', + minH: 'min-h-[200px]', }, lg: { - w: 'max-w-lg', - h: 'max-h-[85vh]', + w: 'w-[calc(100vw-2rem)] max-w-lg', + h: 'max-h-[calc(100vh-4rem)] md:max-h-[720px]', + minH: 'min-h-[240px]', }, xl: { - w: 'max-w-4xl', - h: 'max-h-[90vh]', + w: 'w-[calc(100vw-2rem)] max-w-4xl', + h: 'max-h-[calc(100vh-4rem)] md:max-h-[800px]', + minH: 'min-h-[280px]', }, } as const @@ -44,24 +48,53 @@ export default function Modal({ showCloseButton = true, className = '', }: ModalProps) { + const modalRef = useRef(null) const sizeValue = sizeClasses[size] || sizeClasses.md + // Lock body scroll when modal is open useEffect(() => { - document.documentElement.classList.toggle('modal-isActive', isOpen) - return () => document.documentElement.classList.remove('modal-isActive') + if (isOpen) { + document.documentElement.classList.add('modal-isActive') + document.body.style.overflow = 'hidden' + } else { + document.documentElement.classList.remove('modal-isActive') + document.body.style.overflow = '' + } + return () => { + document.documentElement.classList.remove('modal-isActive') + document.body.style.overflow = '' + } }, [isOpen]) + // Handle keyboard events useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) onClose() + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } } + document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, onClose]) + // Focus management + useEffect(() => { + if (isOpen && modalRef.current) { + const focusableElements = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + const firstElement = focusableElements[0] as HTMLElement + firstElement?.focus() + } + }, [isOpen]) + const modalBoxClasses = ` - modal-box overflow-hidden rounded-2xl p-4 shadow-xl transition-all duration-200 - ${sizeValue.w} ${className} + modal-box overflow-hidden rounded-xl md:rounded-2xl p-3 md:p-4 shadow-xl transition-all duration-200 + ${sizeValue.w} ${sizeValue.minH} ${className} ` if (!isOpen) return null @@ -70,34 +103,44 @@ export default function Modal({ closeOnBackdropClick && onClose()} - className={`modal modal-middle flex items-center justify-center transition-opacity duration-200 ${ - isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' - }`} + className="modal modal-middle flex items-center justify-center transition-opacity duration-200 opacity-100 p-2 md:p-4" >
e.stopPropagation()} - className={`${modalBoxClasses} transform ${ - isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0' - }`} + className={`${modalBoxClasses} animate-modal-in`} > {(title || showCloseButton) && ( -
- {title &&

{title}

} +
+ {title && ( + + )} {showCloseButton && ( )}
)} -
{children}
+
+ {children} +
, document.body From 7967cb0084acd9b2a86db08a7a57bb9086073502 Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 11 Dec 2025 00:47:28 +0330 Subject: [PATCH 11/19] implement auth components --- src/index.css | 195 +++++++------ src/layouts/navbar/profile/profile.tsx | 4 +- src/layouts/setting/tabs/account/account.tsx | 2 +- .../tabs/account/auth-form/AuthForm.tsx | 34 +++ .../tabs/account/auth-form/AuthOtp.tsx | 233 ++++++++++++++++ .../auth-password.tsx => AuthPassword.tsx} | 2 +- .../tabs/account/auth-form/auth-form.tsx | 61 ---- .../auth-form/components/InputTextError.tsx | 22 ++ ...oogle.button.tsx => LoginGoogleButton.tsx} | 2 +- .../components/LoginPasswordButton.tsx | 29 ++ .../components/OtherAuthOptionsContainer.tsx | 33 +++ .../account/auth-form/components/OtpInput.tsx | 71 +++++ .../tabs/account/auth-form/sign-in-form.tsx | 262 ------------------ .../tabs/account/auth-form/sign-up-form.tsx | 228 --------------- .../tabs/account/auth-form/steps/auth-otp.tsx | 218 --------------- .../tabs/account/user-account.modal.tsx | 21 +- src/utils/validators.ts | 6 + 17 files changed, 558 insertions(+), 865 deletions(-) create mode 100644 src/layouts/setting/tabs/account/auth-form/AuthForm.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx rename src/layouts/setting/tabs/account/auth-form/{steps/auth-password.tsx => AuthPassword.tsx} (98%) delete mode 100644 src/layouts/setting/tabs/account/auth-form/auth-form.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx rename src/layouts/setting/tabs/account/auth-form/components/{login-google.button.tsx => LoginGoogleButton.tsx} (98%) create mode 100644 src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx create mode 100644 src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx delete mode 100644 src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx delete mode 100644 src/layouts/setting/tabs/account/auth-form/sign-up-form.tsx delete mode 100644 src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx create mode 100644 src/utils/validators.ts diff --git a/src/index.css b/src/index.css index 3c9cbf4b..607e3738 100644 --- a/src/index.css +++ b/src/index.css @@ -3,210 +3,233 @@ @import "./styles/theme-colors.css"; html { - font-size: 16px !important; + font-size: 16px !important; } html { - font-size: 16px; + font-size: 16px; } body { - overflow-x: hidden; - overflow-y: hidden; - font-family: "Vazir", sans-serif; - height: 100vh; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + overflow-x: hidden; + overflow-y: hidden; + font-family: "Vazir", sans-serif; + height: 100vh; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - background-attachment: fixed; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-attachment: fixed; } .custom-select-box hr { - color: #333333; + color: #333333; } @layer base { - body { - direction: rtl; - font-family: Vazirmatn, sans-serif; - } + body { + direction: rtl; + font-family: Vazirmatn, sans-serif; + } } .bg-content { - @apply bg-base-200; + @apply bg-base-200; } .bg-widget { - @apply bg-base-100; + @apply bg-base-100; } .text-content { - @apply text-base-content/90; + @apply text-base-content/90; } - .text-muted { - @apply text-base-content/70; + @apply text-base-content/70; } .border-content { - @apply border-base-300; + @apply border-base-300; } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } /* Hide the spinner in Firefox */ input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } /* Custom styling for color inputs */ +/* English text (email) input styles */ +input[type="email"] { + direction: ltr; + text-align: left; + font-family: "Inter", sans-serif; + letter-spacing: 0.5px; +} + input[type="color"] { - -webkit-appearance: none; - border: none; - padding: 0; - width: 32px; - height: 32px; - border-radius: 4px; - cursor: pointer; - background: transparent; + -webkit-appearance: none; + border: none; + padding: 0; + width: 32px; + height: 32px; + border-radius: 4px; + cursor: pointer; + background: transparent; } input[type="color"]::-webkit-color-swatch-wrapper { - padding: 0; - border-radius: 4px; - overflow: hidden; + padding: 0; + border-radius: 4px; + overflow: hidden; } input[type="color"]::-webkit-color-swatch { - border: none; - border-radius: 4px; + border: none; + border-radius: 4px; } input[type="color"]::-moz-color-swatch { - border: none; - border-radius: 4px; + border: none; + border-radius: 4px; } /* Hide scrollbar for webkit browsers */ .hide-scrollbar::-webkit-scrollbar { - display: none; + display: none; } /* Hide scrollbar for Firefox and other browsers */ .hide-scrollbar { - -ms-overflow-style: none; - /* IE and Edge */ - scrollbar-width: none; - /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ } /* Custom scrollbar styles */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: transparent; - border-radius: 4px; + background: transparent; + border-radius: 4px; } ::-webkit-scrollbar-thumb { - background: rgba(156, 163, 175, 0.5); - border-radius: 4px; - transition: background-color 0.2s ease; + background: rgba(156, 163, 175, 0.5); + border-radius: 4px; + transition: background-color 0.2s ease; } ::-webkit-scrollbar-thumb:hover { - background: rgba(156, 163, 175, 0.8); + background: rgba(156, 163, 175, 0.8); } /* Firefox scrollbar styles */ * { - scrollbar-width: thin; - scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; } /* Add this to your CSS file */ .weather-forecast-slider { - padding-bottom: 25px !important; - /* Space for pagination bullets */ + padding-bottom: 25px !important; + /* Space for pagination bullets */ } .weather-forecast-slider .swiper-slide { - width: auto; - max-width: 150px; + width: auto; + max-width: 150px; } .weather-forecast-slider .swiper-pagination { - bottom: 0; + bottom: 0; } /* Custom scrollbar styles for info panel */ .scrollbar-thin { - scrollbar-width: thin; + scrollbar-width: thin; } .scrollbar-thumb-base-300::-webkit-scrollbar { - width: 4px; + width: 4px; } .scrollbar-thumb-base-300::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .scrollbar-thumb-base-300::-webkit-scrollbar-thumb { - background: hsl(var(--bc) / 0.2); - border-radius: 2px; + background: hsl(var(--bc) / 0.2); + border-radius: 2px; } .scrollbar-thumb-base-300::-webkit-scrollbar-thumb:hover { - background: hsl(var(--bc) / 0.3); + background: hsl(var(--bc) / 0.3); } .scrollbar-none { - scrollbar-width: none; - -ms-overflow-style: none; + scrollbar-width: none; + -ms-overflow-style: none; } .scrollbar-none::-webkit-scrollbar { - display: none; + display: none; } html.modal-isActive { - scrollbar-width: none !important; + scrollbar-width: none !important; } html.modal-isActive::-webkit-scrollbar { - display: none !important; + display: none !important; } .disabled-blur-mode { - filter: blur(0px); - transition: filter 300ms ease-in-out; + filter: blur(0px); + transition: filter 300ms ease-in-out; } .blur-mode { - filter: blur(8px); - pointer-events: none; - transition: filter 300ms ease-in; + filter: blur(8px); + pointer-events: none; + transition: filter 300ms ease-in; } img { - /* disable user selection */ - user-select: none; - -webkit-user-drag: none; - -moz-user-select: none; - -ms-user-select: none; - -o-user-select: none; -} \ No newline at end of file + /* disable user selection */ + user-select: none; + -webkit-user-drag: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; +} + +/* Modal animations */ +@keyframes modal-in { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.animate-modal-in { + animation: modal-in 0.2s ease-out; +} diff --git a/src/layouts/navbar/profile/profile.tsx b/src/layouts/navbar/profile/profile.tsx index 6219bf9a..2e09ebc4 100644 --- a/src/layouts/navbar/profile/profile.tsx +++ b/src/layouts/navbar/profile/profile.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { LuCircleUser } from 'react-icons/lu' +import { LuLogIn } from 'react-icons/lu' import { listenEvent } from '@/common/utils/call-event' import { AvatarComponent } from '@/components/avatar.component' import Tooltip from '@/components/toolTip' @@ -65,7 +65,7 @@ export function ProfileNav() { id="profile-and-friends-list" onClick={handleProfileClick} > - diff --git a/src/layouts/setting/tabs/account/account.tsx b/src/layouts/setting/tabs/account/account.tsx index 7dc50dc0..068a8f84 100644 --- a/src/layouts/setting/tabs/account/account.tsx +++ b/src/layouts/setting/tabs/account/account.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import { useAuth } from '@/context/auth.context' -import { AuthForm } from './auth-form/auth-form' +import AuthForm from './auth-form/AuthForm' import { UserProfile } from './tabs/user-profile/user-profile' export const AccountTab = () => { diff --git a/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx new file mode 100644 index 00000000..b39051ac --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' + +import Analytics from '@/analytics' +import AuthWithPassword from './AuthPassword' +import AuthOtp from './AuthOtp' +import OtherOptionsContainer from './components/OtherAuthOptionsContainer' + +const AuthForm = () => { + const [showPasswordForm, setShowPasswordForm] = useState(false) + const [step, setStep] = useState<'enter-email' | 'enter-otp'>('enter-otp') + + const handleBackToOTP = () => { + setShowPasswordForm(false) + Analytics.event('auth_method_changed_to_otp') + } + + return ( +
+
+ {showPasswordForm ? ( + + ) : ( + + )} +
+ + {!showPasswordForm && step === 'enter-email' && ( + + )} +
+ ) +} + +export default AuthForm diff --git a/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx new file mode 100644 index 00000000..cfdc59a7 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx @@ -0,0 +1,233 @@ +import { useState } from 'react' +import { FiAtSign, FiKey, FiArrowRight, FiRefreshCw } from 'react-icons/fi' +import { TextInput } from '@/components/text-input' +import { Button } from '@/components/button/button' +import { useRequestOtp, useVerifyOtp } from '@/services/hooks/auth/authService.hook' +import { translateError } from '@/utils/translate-error' +import { useAuth } from '@/context/auth.context' +import { isEmpty, isEmail } from '@/utils/validators' +import InputTextError from './components/InputTextError' +import OtpInput from './components/OtpInput' + +type AuthOtpProps = { + step: 'enter-email' | 'enter-otp' + setStep: (step: 'enter-email' | 'enter-otp') => void +} + +const AuthOtp: React.FC = ({ step, setStep }) => { + const { login } = useAuth() + const [email, setEmail] = useState('') + const [otp, setOtp] = useState(['', '', '', '', '', '']) + + const [error, setError] = useState(null) + const { mutateAsync: requestOtp, isPending } = useRequestOtp() + const { mutateAsync: verifyOtp } = useVerifyOtp() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + if (step === 'enter-email') { + if (isEmpty(email)) { + setError('لطفاً ایمیل خود را وارد کنید.') + return + } + + if (!isEmail(email)) { + setError('لطفاً یک ایمیل معتبر وارد کنید.') + return + } + + await onSendOtp() + } else if (step === 'enter-otp') { + if (isEmpty(otp.join(''))) { + setError('لطفا کد ارسال شده را وارد کنید.') + return + } + await onVerifyOtp() + } + } + + const onSendOtp = async () => { + try { + await requestOtp({ email }) + setStep('enter-otp') + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + if (Object.keys(content).length === 0) { + setError('خطا در ورود. لطفاً دوباره تلاش کنید.') + return + } + setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) + } + } + } + + const onVerifyOtp = async () => { + try { + setError(null) + const response = await verifyOtp({ email, code: otp.join('') }) + login(response.data) + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + setError(content) + } else { + if (Object.keys(content).length === 0) { + setError('کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.') + return + } + setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) + } + } + } + + if (step === 'enter-email') + return ( +
+
+ + +

+ ورود یا ثبت‌نام با ایمیل +

+
+ +
+
+ + + + +
+ +
+

+ ℹ️ + کد تایید به ایمیل شما ارسال می‌شود. +

+
+ + +
+
+ ) + + return ( +
+
+ + +
+

تایید کد ورود

+

+ کد تایید به{' '} + {email} ارسال + شد. +

+
+
+ +
+
+ + + + +
+ + + + +
+ + +
+ ) +} + +export default AuthOtp diff --git a/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx b/src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx similarity index 98% rename from src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx rename to src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx index 102689e1..32f7ea92 100644 --- a/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx +++ b/src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx @@ -10,7 +10,7 @@ import { translateError } from '@/utils/translate-error' interface Prop { onBack: () => void } -export function AuthWithPassword({ onBack }: Prop) { +export default function AuthPassword({ onBack }: Prop) { const [username, setUsername] = useState('') // email can be phone number as well const [password, setPassword] = useState('') const [error, setError] = useState(null) diff --git a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx b/src/layouts/setting/tabs/account/auth-form/auth-form.tsx deleted file mode 100644 index 2c0644f6..00000000 --- a/src/layouts/setting/tabs/account/auth-form/auth-form.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react' -import { FiLock } from 'react-icons/fi' -import Analytics from '@/analytics' -import { AuthWithPassword } from './steps/auth-password' -import { AuthWithOTP } from './steps/auth-otp' -import { LoginGoogleButton } from './components/login-google.button' - -export const AuthForm = () => { - const [showPasswordForm, setShowPasswordForm] = useState(false) - - const handleShowPasswordForm = () => { - setShowPasswordForm(true) - Analytics.event('auth_method_changed_to_password') - } - - const handleBackToOTP = () => { - setShowPasswordForm(false) - Analytics.event('auth_method_changed_to_otp') - } - - return ( -
-
- {showPasswordForm ? ( - - ) : ( - - )} -
- - {!showPasswordForm && ( - <> -
-
-
-
-
- - یا - -
-
- -
- - - -
- - )} -
- ) -} diff --git a/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx b/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx new file mode 100644 index 00000000..defe1166 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx @@ -0,0 +1,22 @@ +import { twMerge } from 'tailwind-merge' + +type InputTextErrorProps = { + message: string + className?: string | null +} + +const InputTextError: React.FC = ({ message, className }) => { + const classes = twMerge( + 'mt-1.5 text-xs text-red-500 flex items-center gap-1.5 transition-all', + className + ) + + return ( +

+ + {message} +

+ ) +} + +export default InputTextError diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx similarity index 98% rename from src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx rename to src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx index 7dec4e3f..1551d37f 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx @@ -11,7 +11,7 @@ import { showToast } from '@/common/toast' import { translateError } from '@/utils/translate-error' import Analytics from '@/analytics' -export function LoginGoogleButton() { +export default function LoginGoogleButton() { const { login } = useAuth() const [isLoading, setIsLoading] = useState(false) const googleSignInMutation = useGoogleSignIn() diff --git a/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx new file mode 100644 index 00000000..099203e1 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx @@ -0,0 +1,29 @@ +import Analytics from '@/analytics' +import { FiLock } from 'react-icons/fi' + +type LoginPasswordButtonProps = { + setShowPasswordForm: (show: boolean) => void +} + +const LoginPasswordButton: React.FC = ({ + setShowPasswordForm, +}) => { + const handleShowPasswordForm = () => { + setShowPasswordForm(true) + Analytics.event('auth_method_changed_to_password') + } + + return ( + + ) +} + +export default LoginPasswordButton diff --git a/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx new file mode 100644 index 00000000..015d429e --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx @@ -0,0 +1,33 @@ +import LoginGoogleButton from './LoginGoogleButton' +import LoginPasswordButton from './LoginPasswordButton' + +type OtherOptionsContainerProps = { + setShowPasswordForm: React.Dispatch> +} + +const OtherOptionsContainer: React.FC = ({ + setShowPasswordForm, +}) => { + return ( + <> +
+
+
+ + +
+ + ) +} + +export default OtherOptionsContainer diff --git a/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx b/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx new file mode 100644 index 00000000..d52f8a6a --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx @@ -0,0 +1,71 @@ +import { isNumber } from '@/utils/validators' + +type OtpInputProps = { + otp: Array + setOtp: (otp: Array) => void + error: string | null + setError: (error: string | null) => void +} + +const OtpInput: React.FC = ({ otp, setOtp, error, setError }) => { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + const handleChange = (index: number, value: string) => { + if (value && !isNumber(value)) return + + const newOtp = [...otp] + newOtp[index] = value + setOtp(newOtp) + setError('') + + // Move to next input + if (value && index < 5) inputRefs.current[index + 1]?.focus() + } + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + // Move to previous input on backspace + if (e.key === 'Backspace' && !otp[index] && index > 0) + inputRefs.current[index - 1]?.focus() + } + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault() + const pastedData = e.clipboardData.getData('text').slice(0, 6) + + if (!isNumber(pastedData)) return + + const newOtp = [...otp] + pastedData.split('').forEach((char, index) => { + if (index < 6) newOtp[index] = char + }) + setOtp(newOtp) + + // Focus last filled input or last input + const lastFilledIndex = Math.min(pastedData.length, 5) + inputRefs.current[lastFilledIndex]?.focus() + } + + return ( +
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + className={`w-12 h-14 text-center text-2xl font-bold bg-dark-bg border ${ + error ? 'border-red-500' : 'border-dark-border' + } rounded-xl text-white focus:outline-none focus:border-primary transition-all duration-300 hover:border-primary/50`} + /> + ))} +
+ ) +} + +export default OtpInput diff --git a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx b/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx deleted file mode 100644 index ac0e8c29..00000000 --- a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { useState } from 'react' -import Analytics from '@/analytics' -import { Button } from '@/components/button/button' -import Modal from '@/components/modal' -import { TextInput } from '@/components/text-input' -import { useAuth } from '@/context/auth.context' -import { useSignIn, useGoogleSignIn } from '@/services/hooks/auth/authService.hook' -import { translateError } from '@/utils/translate-error' - -interface SignInFormProps { - onSwitchToSignUp: () => void -} - -export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [showReferralModal, setShowReferralModal] = useState(false) - const [referralCode, setReferralCode] = useState('') - const [googleToken, setGoogleToken] = useState(null) - - const { login } = useAuth() - const signInMutation = useSignIn() - const googleSignInMutation = useGoogleSignIn() - - const handleGoogleSignIn = async (referralCode?: string) => { - if (!googleToken) return - - try { - const response = await googleSignInMutation.mutateAsync({ - token: googleToken, - referralCode, - }) - login(response.data) - Analytics.event('sign_in') - setShowReferralModal(false) - setGoogleToken(null) - setReferralCode('') - } catch (err) { - const content = translateError(err) - if (typeof content === 'string') { - setError(content) - } else { - setError('خطا در ورود با گوگل. لطفاً دوباره تلاش کنید.') - } - setShowReferralModal(false) - setGoogleToken(null) - setReferralCode('') - } - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) - - try { - const response = await signInMutation.mutateAsync({ email, password }) - login(response.data) - Analytics.event('sign_in') - } catch (err) { - const content = translateError(err) - if (typeof content === 'string') { - setError(content) - } else { - if (Object.keys(content).length === 0) { - setError('خطا در ورود. لطفاً دوباره تلاش کنید.') - return - } - setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) - } - } - } - - const loginGoogle = async () => { - if (await browser.permissions.contains({ permissions: ['identity'] })) { - } else { - const granted = await browser.permissions.request({ - permissions: ['identity'], - }) - if (!granted) { - console.log('Permission denied') - return - } - } - - const redirectUri = browser.identity.getRedirectURL('google') - console.log('Redirect URI:', redirectUri) - const url = new URL('https://accounts.google.com/o/oauth2/v2/auth') - url.searchParams.set( - 'client_id', - '1062149222368-8cm7q5m4h1amei0b338rifhpqbibis23.apps.googleusercontent.com' - ) - url.searchParams.set('response_type', 'token') - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('prompt', 'consent select_account') - url.searchParams.set( - 'scope', - 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile' - ) - - const redirectUrl = await browser.identity.launchWebAuthFlow({ - url: url.toString(), // لینک OAuth گوگل - interactive: true, - }) - - const params = new URLSearchParams(redirectUrl?.split('#')[1]) - const token = params.get('access_token') - console.log('Access Token:', token) - - if (token) { - setGoogleToken(token) - setShowReferralModal(true) - } - } - - return ( -
-
-
- - -
- - - -
- - -
-
- {error && ( -
- {error} -
- )} -
-

- حساب کاربری ندارید؟ - -

-
- - setShowReferralModal(false)} - title="" - size="sm" - direction="rtl" - showCloseButton={false} - > -
-
-
-
-
🎁
-
-
- ! -
-
-
- -
-

- فقط یک قدم مونده! -

-

- اگه میخای پاداش بگیری و کد دعوت داری، کد رو وارد کن! -
- - بدون کد هم می‌تونی ادامه بدی - -

-
- - {/* Input Section */} -
- -
- - {/* Action Buttons */} -
- - -
-
-
-
- ) -} diff --git a/src/layouts/setting/tabs/account/auth-form/sign-up-form.tsx b/src/layouts/setting/tabs/account/auth-form/sign-up-form.tsx deleted file mode 100644 index e019d7b8..00000000 --- a/src/layouts/setting/tabs/account/auth-form/sign-up-form.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useState } from 'react' -import { FaSearch, FaShareAlt, FaUsers, FaYoutube } from 'react-icons/fa' -import { FiGift } from 'react-icons/fi' -import Analytics from '@/analytics' -import { Button } from '@/components/button/button' -import { ConfirmationModal } from '@/components/modal/confirmation-modal' -import { ItemSelector } from '@/components/item-selector' -import { TextInput } from '@/components/text-input' -import { useAuth } from '@/context/auth.context' -import { type ReferralSource, useSignUp } from '@/services/hooks/auth/authService.hook' -import { translateError } from '@/utils/translate-error' -import { showToast } from '@/common/toast' - -interface ReferralOption { - id: ReferralSource - label: string - icon: React.ComponentType<{ className?: string }> -} - -const referralOptions: ReferralOption[] = [ - { - id: 'social', - label: 'شبکه‌های اجتماعی', - icon: FaShareAlt, - }, - { - id: 'youtube', - label: 'یوتیوب', - icon: FaYoutube, - }, - { - id: 'friends', - label: 'دوستان', - icon: FaUsers, - }, - { - id: 'search_other', - label: 'موتور جستجو/غیره', - icon: FaSearch, - }, -] -export const SignUpForm = () => { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [name, setName] = useState('') - const [referralSource, setReferralSource] = useState(null) - const [referralCode, setReferralCode] = useState('') - const [error, setError] = useState(null) - const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false) - - const { login } = useAuth() - const signUpMutation = useSignUp() - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - setError(null) - setIsConfirmModalOpen(true) - } - - const handleConfirm = async () => { - setIsConfirmModalOpen(false) - try { - const response = await signUpMutation.mutateAsync({ - name, - email, - password, - referralSource, - referralCode: referralCode || undefined, - }) - login(response.data) - Analytics.event('sign_up') - } catch (err) { - const content = translateError(err) - let errorContent = '' - if (typeof content === 'string') { - errorContent = content - } else { - if (Object.keys(content).length === 0) { - errorContent = 'خطا در ورود. لطفاً دوباره تلاش کنید.' - return - } - errorContent = `${Object.keys(content)[0]}: ${Object.values(content)[0]}` - } - - setError(errorContent) - showToast(errorContent, 'error') - } - } - - const onEditButtonClick = () => { - setIsConfirmModalOpen(false) - Analytics.event('sign_up_edit_email') - } - - return ( -
-
-
-
- - -
- -
- - -
-
- -
- - -
- -
-
-
- -
- کد دعوت - - ( اختیاری ) - -
- -
- -
- -
- {referralOptions.map((option) => ( - setReferralSource(option.id)} - label={ -
-
- -
- - {option.label} - -
- } - className="!p-3 flex-1 min-w-0 sm:min-w-[120px]" - /> - ))} -
-
- -
- -
-
- - {error && ( -
- {error} -
- )} - - - - {email} - -
- } - confirmText="ادامه" - cancelText="ویرایش" - variant="info" - isLoading={signUpMutation.isPending} - direction="rtl" - /> - - ) -} diff --git a/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx deleted file mode 100644 index d783af37..00000000 --- a/src/layouts/setting/tabs/account/auth-form/steps/auth-otp.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useState } from 'react' -import { FiSmartphone } from 'react-icons/fi' -import { TextInput } from '../../../../../../components/text-input' -import { Button } from '@/components/button/button' -import { useRequestOtp, useVerifyOtp } from '@/services/hooks/auth/authService.hook' -import { translateError } from '@/utils/translate-error' -import { useAuth } from '@/context/auth.context' - -export function AuthWithOTP() { - const { login } = useAuth() - const [username, setUsername] = useState('') // email can be phone number as well - const [otp, setOtp] = useState('') - const [step, setStep] = useState<'enter-username' | 'enter-otp'>('enter-username') - const [error, setError] = useState(null) - const { mutateAsync: requestOtp, isPending } = useRequestOtp() - const { mutateAsync: verifyOtp } = useVerifyOtp() - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) - if (step === 'enter-username') { - if (username.trim() === '') { - setError('لطفاً ایمیل خود را وارد کنید.') - return - } - const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username) - if (!isEmail) { - setError('لطفاً یک ایمیل معتبر وارد کنید.') - return - } - - await onSubmitRequest() - } else { - if (otp.trim() === '') { - setError('لطفاً کد تایید را وارد کنید.') - return - } - await onVerifyOtp() - } - } - - const onSubmitRequest = async () => { - try { - await requestOtp({ email: username }) - setStep('enter-otp') - } catch (err: any) { - const content = translateError(err) - if (typeof content === 'string') { - setError(content) - } else { - if (Object.keys(content).length === 0) { - setError('خطا در ورود. لطفاً دوباره تلاش کنید.') - return - } - setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) - } - } - } - - const onVerifyOtp = async () => { - try { - setError(null) - const response = await verifyOtp({ email: username, code: otp }) - login(response.data) - } catch (err: any) { - const content = translateError(err) - if (typeof content === 'string') { - setError(content) - } else { - if (Object.keys(content).length === 0) { - setError('کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.') - return - } - setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) - } - } - } - - if (step === 'enter-username') { - return ( -
- {/* Header */} -
-
- -
-
-

- ورود یا ثبت‌نام در ویجتیفای -

-

- کد تایید به ایمیل یا شما ارسال می‌شود. -

-
-
- -
-
- -
-
- -
- -
-
- -
-
- -
- {error} -
- -
- -
-
-
- ) - } - - return ( -
- {/* Header */} -
-
- -
-
-

- تایید کد ورود -

-

- کد تایید به{' '} - {username}{' '} - ارسال شد. -

-
-
- -
-
- -
- -
-
- -
-
- -
- {error} -
- -
- - -
-
-
- ) -} diff --git a/src/layouts/setting/tabs/account/user-account.modal.tsx b/src/layouts/setting/tabs/account/user-account.modal.tsx index 339c15d0..7b3ead44 100644 --- a/src/layouts/setting/tabs/account/user-account.modal.tsx +++ b/src/layouts/setting/tabs/account/user-account.modal.tsx @@ -5,7 +5,7 @@ import { type TabItem, TabManager } from '@/components/tab-manager' import { useAuth } from '@/context/auth.context' import { AccountTab } from '@/layouts/setting/tabs/account/account' import { AllFriendsTab, FriendRequestsTab, RewardsTab } from './tabs' - +import AuthForm from './auth-form/AuthForm' interface FriendSettingModalProps { isOpen: boolean onClose: () => void @@ -44,9 +44,20 @@ export const UserAccountModal = ({ selectedTab, }: FriendSettingModalProps) => { const { isAuthenticated } = useAuth() - const filteredTabs = isAuthenticated - ? tabs - : [tabs.find((tab) => tab.value === 'profile') as any] + + if (!isAuthenticated) + return ( + + + + ) + return ( (text.trim() === "" ? true : false); + +export const isEmail = (email: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + +export const isNumber = (value: string) => /^\d$/.test(value); From ebaf2bf50310fc2cf2b8a6f3fd0ff37c4a9d704a Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 11 Dec 2025 01:12:52 +0330 Subject: [PATCH 12/19] responsive and optimize the auth components --- .../tabs/account/auth-form/AuthForm.tsx | 4 +- .../tabs/account/auth-form/AuthOtp.tsx | 103 +++++++++--------- .../tabs/account/auth-form/AuthPassword.tsx | 72 +++++++----- .../auth-form/components/InputTextError.tsx | 31 ++++-- .../components/LoginGoogleButton.tsx | 14 ++- .../components/LoginPasswordButton.tsx | 8 +- .../components/OtherAuthOptionsContainer.tsx | 12 +- .../account/auth-form/components/OtpInput.tsx | 13 ++- 8 files changed, 149 insertions(+), 108 deletions(-) diff --git a/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx index b39051ac..47474ea1 100644 --- a/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx +++ b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx @@ -15,8 +15,8 @@ const AuthForm = () => { } return ( -
-
+
+
{showPasswordForm ? ( ) : ( diff --git a/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx index cfdc59a7..fdc15c88 100644 --- a/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx +++ b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx @@ -87,22 +87,25 @@ const AuthOtp: React.FC = ({ step, setStep }) => { if (step === 'enter-email') return (
-
+
-

+

ورود یا ثبت‌نام با ایمیل

-
+
-
- -
-

- ℹ️ - کد تایید به ایمیل شما ارسال می‌شود. + +

{' '} +
+

+ + ℹ️ + + کد تایید به ایمیل شما ارسال می‌شود.

-
@@ -146,27 +151,34 @@ const AuthOtp: React.FC = ({ step, setStep }) => { return (
-
+
-
-

تایید کد ورود

-

- کد تایید به{' '} - {email} ارسال - شد. +

+

+ تایید کد ورود +

+

+ کد تایید به + + {email} + + ارسال شد.

-
+
-
- + +
{' '} - - {/* Back Button */} -
+
diff --git a/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx index 099203e1..2b14f926 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx @@ -15,11 +15,13 @@ const LoginPasswordButton: React.FC = ({ return ( diff --git a/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx index 015d429e..e98f2ac7 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx @@ -9,24 +9,24 @@ const OtherOptionsContainer: React.FC = ({ setShowPasswordForm, }) => { return ( - <> -
+
+
- +
{' '} - -
- -
-
+ ) } diff --git a/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx b/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx index 1b52a4a0..ddb36cda 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx @@ -9,9 +9,9 @@ const InputTextError: React.FC = ({ message, className }) = const hasError = Boolean(message) const classes = twMerge( - 'mt-1.5 text-xs md:text-sm text-red-500 flex items-center gap-1.5 min-h-[20px] transition-all duration-300 ease-in-out overflow-hidden', + 'text-xs text-red-500 flex items-center gap-1.5 transition-all duration-300 ease-in-out overflow-hidden', hasError - ? 'opacity-100 translate-y-0' + ? 'opacity-100 translate-y-0 mt-1 min-h-[20px]' : 'opacity-0 -translate-y-1 pointer-events-none', className ) diff --git a/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx new file mode 100644 index 00000000..d41e48c5 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx @@ -0,0 +1,29 @@ +import Analytics from '@/analytics' +import { FiMail } from 'react-icons/fi' + +type LoginOtpButtonProps = { + setShowPasswordForm: (show: boolean) => void +} + +const LoginOtpButton: React.FC = ({ setShowPasswordForm }) => { + const handleShowOtpForm = () => { + setShowPasswordForm(false) + Analytics.event('auth_method_changed_to_otp') + } + + return ( + + ) +} + +export default LoginOtpButton diff --git a/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx index e98f2ac7..d5b07971 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx @@ -1,12 +1,15 @@ import LoginGoogleButton from './LoginGoogleButton' import LoginPasswordButton from './LoginPasswordButton' +import LoginOtpButton from './LoginOtpButton' type OtherOptionsContainerProps = { setShowPasswordForm: React.Dispatch> + showPasswordForm: boolean } const OtherOptionsContainer: React.FC = ({ setShowPasswordForm, + showPasswordForm, }) => { return (
@@ -23,7 +26,11 @@ const OtherOptionsContainer: React.FC = ({
- + {showPasswordForm ? ( + + ) : ( + + )}
diff --git a/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx b/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx index a5f68b64..1ab3f010 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx @@ -1,57 +1,111 @@ +import { useRef, useEffect, useState } from 'react' import { isNumber } from '@/utils/validators' type OtpInputProps = { - otp: Array - setOtp: (otp: Array) => void - error: string | null - setError: (error: string | null) => void + otp: string + setOtp: (otp: string) => void + isError: boolean } -const OtpInput: React.FC = ({ otp, setOtp, error, setError }) => { +const OTP_LENGTH = 6 + +const OtpInput: React.FC = ({ otp, setOtp, isError }) => { const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + // Track which positions have values (source of truth) + const [positions, setPositions] = useState>(new Map()) + // Track if component is initialized to avoid syncing from parent + const isInitialized = useRef(false) + + // Initialize from parent only once on mount or when otp is cleared + useEffect(() => { + if (!isInitialized.current || otp === '') { + const newPositions = new Map() + for (let i = 0; i < otp.length && i < OTP_LENGTH; i++) { + newPositions.set(i, otp[i]) + } + setPositions(newPositions) + isInitialized.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Auto-focus first input on mount + useEffect(() => { + inputRefs.current[0]?.focus() + }, []) + + // Convert positions Map to array for rendering + const otpArray = Array.from({ length: OTP_LENGTH }, (_, i) => positions.get(i) || '') + + // Helper to send clean string to parent (only digits, no spaces) + const updateParentOtp = (newPositions: Map) => { + // Collect all digits in order from positions that have values + const digits: string[] = [] + for (let i = 0; i < OTP_LENGTH; i++) { + const digit = newPositions.get(i) + if (digit) digits.push(digit) + } + setOtp(digits.join('')) + } const handleChange = (index: number, value: string) => { + // Only allow single digits if (value && !isNumber(value)) return - const newOtp = [...otp] - newOtp[index] = value - setOtp(newOtp) - setError('') + const newPositions = new Map(positions) - // Move to next input - if (value && index < 5) inputRefs.current[index + 1]?.focus() + if (value) { + newPositions.set(index, value) + } else { + newPositions.delete(index) + } + + setPositions(newPositions) + updateParentOtp(newPositions) + + // Auto-focus next input when digit entered + if (value && index < OTP_LENGTH - 1) { + inputRefs.current[index + 1]?.focus() + } } const handleKeyDown = (index: number, e: React.KeyboardEvent) => { - // Move to previous input on backspace - if (e.key === 'Backspace' && !otp[index] && index > 0) - inputRefs.current[index - 1]?.focus() + if (e.key === 'Backspace') { + const currentValue = positions.get(index) + + if (!currentValue && index > 0) { + // If empty, move to previous input + inputRefs.current[index - 1]?.focus() + } + } } - const handlePaste = (e: React.ClipboardEvent) => { + const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault() - const pastedData = e.clipboardData.getData('text').slice(0, 6) + const pastedData = e.clipboardData.getData('text').trim() + + // Validate: only digits, max 6 chars + if (!pastedData || !/^\d+$/.test(pastedData)) return + + const digits = pastedData.slice(0, OTP_LENGTH) + const newPositions = new Map() - if (!isNumber(pastedData)) return + // Fill positions from the beginning + for (let i = 0; i < digits.length; i++) { + newPositions.set(i, digits[i]) + } - const newOtp = [...otp] - pastedData.split('').forEach((char, index) => { - if (index < 6) newOtp[index] = char - }) - setOtp(newOtp) + setPositions(newPositions) + updateParentOtp(newPositions) - // Focus last filled input or last input - const lastFilledIndex = Math.min(pastedData.length, 5) - inputRefs.current[lastFilledIndex]?.focus() + // Focus last filled input + const lastIndex = Math.min(digits.length - 1, OTP_LENGTH - 1) + setTimeout(() => inputRefs.current[lastIndex]?.focus(), 0) } return ( -
- {otp.map((digit, index) => ( +
+ {otpArray.map((digit, index) => ( { @@ -64,8 +118,9 @@ const OtpInput: React.FC = ({ otp, setOtp, error, setError }) => aria-label={`کد تایید رقم ${index + 1}`} onChange={(e) => handleChange(index, e.target.value)} onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} className={`w-10 h-12 md:w-12 md:h-14 text-center text-xl md:text-2xl font-bold bg-dark-bg border-2 ${ - error ? 'border-red-500 bg-red-50/5' : 'border-dark-border' + isError ? 'border-red-500 bg-red-50/5' : 'border-dark-border' } rounded-lg md:rounded-xl text-white focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 hover:border-primary/50 active:scale-95`} /> ))} diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 2754c9fd..6ec9eef2 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -3,4 +3,10 @@ export const isEmpty = (text: string) => (text.trim() === "" ? true : false); export const isEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -export const isNumber = (value: string) => /^\d$/.test(value); +export const isNumber = (value: string) => /^\d+$/.test(value); + +export const isMoreThan = (text: string, length: number) => + text.trim().length > length ? true : false; + +export const isLessThan = (text: string, length: number) => + text.trim().length < length ? true : false; From be6bccb65c61b5547fa43c1e42c70b933dd65590 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Mon, 22 Dec 2025 18:12:33 +0330 Subject: [PATCH 15/19] enhance authentication forms with autocomplete and error handling improvements --- src/components/text-input.tsx | 4 +- .../tabs/account/auth-form/AuthForm.tsx | 6 +- .../tabs/account/auth-form/AuthOtp.tsx | 91 +++++++++++-------- .../tabs/account/auth-form/AuthPassword.tsx | 79 ++++++++++------ .../components/LoginGoogleButton.tsx | 5 +- .../auth-form/components/LoginOtpButton.tsx | 5 +- .../components/LoginPasswordButton.tsx | 5 +- .../components/OtherAuthOptionsContainer.tsx | 8 +- .../account/auth-form/components/OtpInput.tsx | 22 +---- 9 files changed, 124 insertions(+), 101 deletions(-) diff --git a/src/components/text-input.tsx b/src/components/text-input.tsx index 74b41005..ca25176b 100644 --- a/src/components/text-input.tsx +++ b/src/components/text-input.tsx @@ -27,6 +27,7 @@ interface TextInputProps { size?: TextInputSize min?: number max?: number + autoComplete?: 'on' | 'off' } const sizes: Record = { @@ -56,6 +57,7 @@ export const TextInput = memo(function TextInput({ size = TextInputSize.MD, min, max, + autoComplete = 'off', }: TextInputProps) { const [localValue, setLocalValue] = useState(value) const debounceTimerRef = useRef(null) @@ -119,7 +121,7 @@ export const TextInput = memo(function TextInput({ font-light ${className}`} onChange={handleChange} maxLength={maxLength} - autoComplete="off" + autoComplete={autoComplete} min={min} max={max} /> diff --git a/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx index bcb8c119..baa5c71f 100644 --- a/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx +++ b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx @@ -7,12 +7,12 @@ import OtherOptionsContainer from './components/OtherAuthOptionsContainer' const AuthForm = () => { const [showPasswordForm, setShowPasswordForm] = useState(false) const [AuthOtpStep, setAuthOtpStep] = useState<'enter-email' | 'enter-otp'>( - 'enter-otp' + 'enter-email' ) return ( -
-
+
+
{showPasswordForm ? ( ) : ( diff --git a/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx index c32644f2..c4ad5f3b 100644 --- a/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx +++ b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx @@ -37,7 +37,6 @@ const AuthOtp: React.FC = ({ step, setStep }) => { resetErrors() if (step === 'enter-email') { - // Email validation if (isEmpty(email)) return setError((prev) => ({ ...prev, @@ -52,7 +51,6 @@ const AuthOtp: React.FC = ({ step, setStep }) => { onSendOtp() } else if (step === 'enter-otp') { - // OTP validation if (isEmpty(otp) || isLessThan(otp, 6)) return setError((prev) => ({ ...prev, @@ -69,36 +67,50 @@ const AuthOtp: React.FC = ({ step, setStep }) => { setStep('enter-otp') } catch (err: any) { const content = translateError(err) - console.log(content) - // if (typeof content === 'string') { - // setError((prev) => ({ ...prev, api: content })) - // } else { - // if (Object.keys(content).length === 0) { - // setError('خطا در ورود. لطفاً دوباره تلاش کنید.') - // return - // } - // setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) - // } + setError({ + api: content as string, + email: null, + otp: null, + }) } } const onVerifyOtp = async () => { - // try { - // setError(null) - // const response = await verifyOtp({ email, code: otp }) - // login(response.data) - // } catch (err: any) { - // const content = translateError(err) - // if (typeof content === 'string') { - // setError(content) - // } else { - // if (Object.keys(content).length === 0) { - // setError('کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.') - // return - // } - // setError(`${Object.keys(content)[0]}: ${Object.values(content)[0]}`) - // } - // } + try { + setError({ email: null, otp: null, api: null }) + const response = await verifyOtp({ email, code: otp }) + login(response.data) + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + if (err.response?.data?.message === 'INVALID_OTP_CODE') { + setError({ + email: null, + otp: 'کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.', + api: null, + }) + } else setError({ email: null, otp: null, api: content }) + } else { + if (Object.keys(content).length === 0) { + setError({ + email: null, + otp: null, + api: 'کد تایید نامعتبر است. لطفاً دوباره تلاش کنید.', + }) + return + } + setError({ + email: null, + otp: null, + api: `${Object.keys(content)[0]}: ${Object.values(content)[0]}`, + }) + } + } + } + + const onSetOtp = (value: string) => { + setOtp(value) + setError((prev) => ({ ...prev, otp: null })) } if (step === 'enter-email') @@ -107,12 +119,12 @@ const AuthOtp: React.FC = ({ step, setStep }) => {
-

+

ورود یا ثبت‌نام با ایمیل

@@ -123,7 +135,7 @@ const AuthOtp: React.FC = ({ step, setStep }) => {

@@ -151,7 +164,7 @@ const AuthOtp: React.FC = ({ step, setStep }) => { size="md" loading={isPending} disabled={isPending || !email} - className="relative w-full py-2.5 md:py-3 text-sm md:text-base transition-all duration-200 shadow text-white group rounded-xl disabled:opacity-50 disabled:cursor-not-allowed" + className="relative w-full py-2.5 md:py-3 text-sm md:text-base transition-all duration-200 shadow text-white group rounded-xl disabled:cursor-not-allowed disabled:text-base-content disabled:opacity-50" > {isPending ? 'درحال ارسال...' : 'تایید ایمیل'} @@ -166,19 +179,19 @@ const AuthOtp: React.FC = ({ step, setStep }) => {
-
-

+
+

تایید کد ورود

کد تایید به {email} @@ -196,7 +209,7 @@ const AuthOtp: React.FC = ({ step, setStep }) => { @@ -204,7 +217,7 @@ const AuthOtp: React.FC = ({ step, setStep }) => { diff --git a/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx index 4d5063b2..cd252982 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginGoogleButton.tsx @@ -79,8 +79,7 @@ export default function LoginGoogleButton() { type="button" onClick={loginGoogle} disabled={isLoading} - aria-label="ورود با گوگل" - className="group px-4 md:px-8 py-2.5 md:py-3 rounded-lg md:rounded-xl text-sm md:text-base font-medium shadow-md hover:shadow-lg w-full flex items-center justify-center border-2 border-content bg-content hover:bg-base-200 transition-all duration-200 gap-1.5 md:gap-2 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 disabled:active:scale-100" + className="group px-4 md:px-8 py-2.5 md:py-3 rounded-2xl text-sm md:text-base font-medium shadow-md hover:shadow-lg w-full flex items-center justify-center border-2 border-content bg-content hover:bg-base-200 transition-all duration-200 gap-1.5 md:gap-2 cursor-pointer active:scale-95" >

{isLoading ? ( @@ -90,7 +89,7 @@ export default function LoginGoogleButton() { src="https://cdn.widgetify.ir/sites/google.png" alt="" aria-hidden="true" - className="w-4 h-4 md:w-5 md:h-5 transition-all duration-200 group-hover:scale-110 group-hover:rotate-3" + className="w-4 h-4 transition-all duration-200 md:w-5 md:h-5 group-hover:scale-110 group-hover:rotate-3" /> )}
diff --git a/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx index d41e48c5..09d0fe05 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginOtpButton.tsx @@ -15,10 +15,9 @@ const LoginOtpButton: React.FC = ({ setShowPasswordForm }) diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx index 09d0fe05..ba303898 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx @@ -18,7 +18,7 @@ const LoginOtpButton: React.FC = ({ setShowPasswordForm }) className="group px-4 md:px-8 py-2.5 md:py-3 rounded-2xl text-sm md:text-base font-medium shadow-md hover:shadow-lg w-full flex items-center justify-center border-2 border-content bg-content hover:bg-base-200 transition-all duration-200 gap-1.5 md:gap-2 cursor-pointer active:scale-95" > - + ورود با کد موقت diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx index b2873c63..31b9532f 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx @@ -20,7 +20,7 @@ const LoginPasswordButton: React.FC = ({ className="group px-4 md:px-8 py-2.5 md:py-3 rounded-2xl text-sm md:text-base font-medium shadow-md hover:shadow-lg w-full flex items-center justify-center border-2 border-content bg-content hover:bg-base-200 transition-all duration-200 gap-1.5 md:gap-2 cursor-pointer active:scale-95" > - + ورود با رمز عبور From 3009c47067b3af23fd16c8fc4fdb972eae58de57 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Tue, 23 Dec 2025 17:27:58 +0330 Subject: [PATCH 19/19] feat: add tailwind-merge package to enhance styling capabilities --- bun.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index 5ccac6b4..5c10b801 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "react-icons": "5.5.0", "react-joyride": "2.9.3", "swiper": "12.0.2", + "tailwind-merge": "^3.4.0", "uuid": "13.0.0", "workbox-background-sync": "7.4.0", "workbox-cacheable-response": "7.4.0", @@ -1124,6 +1125,8 @@ "swiper": ["swiper@12.0.2", "", {}, "sha512-y8F6fDGXmTVVgwqJj6I00l4FdGuhpFJn0U/9Ucn1MwWOw3NdLV8aH88pZOjyhBgU/6PyBlUx+JuAQ5KMWz906Q=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],