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", 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 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..bcb8c119 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/AuthForm.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react' + +import AuthWithPassword from './AuthPassword' +import AuthOtp from './AuthOtp' +import OtherOptionsContainer from './components/OtherAuthOptionsContainer' + +const AuthForm = () => { + const [showPasswordForm, setShowPasswordForm] = useState(false) + const [AuthOtpStep, setAuthOtpStep] = useState<'enter-email' | 'enter-otp'>( + 'enter-otp' + ) + + return ( +
+
+ {showPasswordForm ? ( + + ) : ( + + )} +
+ + {AuthOtpStep === '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..c32644f2 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/AuthOtp.tsx @@ -0,0 +1,247 @@ +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, isLessThan } 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<{ + email: string | null + otp: string | null + api: string | null + }>({ email: null, otp: null, api: null }) + + const resetErrors = () => { + setError({ email: null, otp: null, api: null }) + } + + const { mutateAsync: requestOtp, isPending } = useRequestOtp() + const { mutateAsync: verifyOtp } = useVerifyOtp() + + const validateInputs = async (e: React.FormEvent) => { + e.preventDefault() + resetErrors() + + if (step === 'enter-email') { + // Email validation + if (isEmpty(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً ایمیل خود را وارد کنید.', + })) + + if (!isEmail(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً یک ایمیل معتبر وارد کنید.', + })) + + onSendOtp() + } else if (step === 'enter-otp') { + // OTP validation + if (isEmpty(otp) || isLessThan(otp, 6)) + return setError((prev) => ({ + ...prev, + otp: 'لطفا کد ارسال شده را وارد کنید.', + })) + + onVerifyOtp() + } + } + + const onSendOtp = async () => { + try { + await requestOtp({ email }) + 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]}`) + // } + } + } + + 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]}`) + // } + // } + } + + if (step === 'enter-email') + return ( +
+
+ +
+

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

+

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

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

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

+

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

+
+
+ +
+
+ + + + +
{' '} + + +
+ + +
+ ) +} + +export default AuthOtp diff --git a/src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx b/src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx new file mode 100644 index 00000000..31f3853c --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/AuthPassword.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react' +import { FiLock } 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' +import InputTextError from './components/InputTextError' +import { isEmail, isEmpty, isLessThan } from '@/utils/validators' + +export default function AuthPassword() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState<{ email: string | null; password: string | null }>( + { email: null, password: null } + ) + + const { login } = useAuth() + const { mutateAsync: signInMutation, isPending } = useSignIn() + + const resetErrors = () => { + setError({ email: null, password: null }) + } + + const validateInputs = () => { + // Email validation + if (isEmpty(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً ایمیل خود را وارد کنید.', + })) + + if (!isEmail(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً یک ایمیل معتبر وارد کنید.', + })) + + // Password validation + if (isEmpty(password)) + return setError((prev) => ({ + ...prev, + password: 'لطفا رمز عبور خود را وارد کنید.', + })) + + if (isLessThan(password, 6)) + return setError((prev) => ({ + ...prev, + password: 'رمز عبور باید حداقل ۶ کاراکتر باشد.', + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + resetErrors() + + // try { + // const response = await signInMutation({ + // 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]}`) + // } + // } + } + + return ( +
+
+ +
+

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

+

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

+
+
+ +
+
+ + + + +
+ +
+ + + + +
+ + +
+
+ ) +} 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..ddb36cda --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/InputTextError.tsx @@ -0,0 +1,39 @@ +import { twMerge } from 'tailwind-merge' + +type InputTextErrorProps = { + message?: string | null + className?: string +} + +const InputTextError: React.FC = ({ message, className }) => { + const hasError = Boolean(message) + + const classes = twMerge( + '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 mt-1 min-h-[20px]' + : 'opacity-0 -translate-y-1 pointer-events-none', + className + ) + + return ( +
+ {hasError && ( + <> +
+ ) +} + +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 76% 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..4d5063b2 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() @@ -79,20 +79,22 @@ export function LoginGoogleButton() { type="button" onClick={loginGoogle} disabled={isLoading} - className="group px-8 py-2.5 rounded-xl font-medium shadow-lg h-full w-full flex items-center justify-center border-2 border-content bg-content hover:bg-gray-100 transition-colors gap-2 cursor-pointer" + 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" > -
+
{isLoading ? ( - + ) : ( )}
- + {isLoading ? 'درحال پردازش...' : 'ورود با گوگل'} 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/LoginPasswordButton.tsx b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx new file mode 100644 index 00000000..2b14f926 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/LoginPasswordButton.tsx @@ -0,0 +1,31 @@ +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..d5b07971 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/OtherAuthOptionsContainer.tsx @@ -0,0 +1,40 @@ +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 ( +
+
+
+
+ {showPasswordForm ? ( + + ) : ( + + )} + +
+
+ ) +} + +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..1ab3f010 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/OtpInput.tsx @@ -0,0 +1,131 @@ +import { useRef, useEffect, useState } from 'react' +import { isNumber } from '@/utils/validators' + +type OtpInputProps = { + otp: string + setOtp: (otp: string) => void + isError: boolean +} + +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 newPositions = new Map(positions) + + 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) => { + 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) => { + e.preventDefault() + 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() + + // Fill positions from the beginning + for (let i = 0; i < digits.length; i++) { + newPositions.set(i, digits[i]) + } + + setPositions(newPositions) + updateParentOtp(newPositions) + + // Focus last filled input + const lastIndex = Math.min(digits.length - 1, OTP_LENGTH - 1) + setTimeout(() => inputRefs.current[lastIndex]?.focus(), 0) + } + + return ( +
+ {otpArray.map((digit, index) => ( + { + inputRefs.current[index] = el + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + 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 ${ + 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`} + /> + ))} +
+ ) +} + +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/auth-form/steps/auth-password.tsx b/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx deleted file mode 100644 index 102689e1..00000000 --- a/src/layouts/setting/tabs/account/auth-form/steps/auth-password.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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) { - 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 ( -
-
-
- -
-
-

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

-

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

-
-
- -
-
- -
-
- -
- -
-
- -
-
- - - فراموش کردم - -
-
-
- -
- -
-
- - {error && ( -
-
- -
- {error} -
- )} - - - - {/* Back Button */} -
- -
-
-
- ) -} 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); + +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;