diff --git a/bun.lock b/bun.lock index 0d1afe52..5c10b801 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,8 @@ "react-hot-toast": "2.6.0", "react-icons": "5.5.0", "react-joyride": "2.9.3", - "swiper": "12.0.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", @@ -1122,7 +1123,9 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "swiper": ["swiper@12.0.3", "", {}, "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg=="], + "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=="], diff --git a/package.json b/package.json index 7fdac79e..faab097f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "react-hot-toast": "2.6.0", "react-icons": "5.5.0", "react-joyride": "2.9.3", - "swiper": "12.0.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", 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/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/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/setting/tabs/account/account.tsx b/src/layouts/setting/tabs/account/account.tsx index 7dc50dc0..f3f3241f 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/auth-form' import { UserProfile } from './tabs/user-profile/user-profile' export const AccountTab = () => { 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..61e418d8 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,33 @@ import { useState } from 'react' -import { FiLogIn, FiUserPlus } 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' +import AuthWithPassword from './auth-password' +import AuthOtp from './auth-otp' +import OtherOptionsContainer from './components/other-auth-options.container' -interface AuthTab { - id: AuthMode - label: string - icon: React.ComponentType<{ size?: number; className?: string }> -} - -export const AuthForm = () => { - const [activeMode, setActiveMode] = useState('signin') - - const authTabs: AuthTab[] = [ - { - id: 'signin', - label: 'ورود به حساب', - icon: FiLogIn, - }, - { - id: 'signup', - label: 'ثبت‌نام', - icon: FiUserPlus, - }, - ] - - 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 handleModeChange = (mode: AuthMode) => { - setActiveMode(mode) - Analytics.event(`auth_tab_change_${mode}`) - } +const AuthForm = () => { + const [showPasswordForm, setShowPasswordForm] = useState(false) + const [AuthOtpStep, setAuthOtpStep] = useState<'enter-email' | 'enter-otp'>( + 'enter-email' + ) return ( - -
- {authTabs.map((tab) => ( - - ))} -
- -
- {activeMode === 'signin' ? ( - handleModeChange('signup')} - /> +
+
+ {showPasswordForm ? ( + ) : ( - + )}
- + + {AuthOtpStep === 'enter-email' && ( + + )} +
) } + +export default AuthForm diff --git a/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx new file mode 100644 index 00000000..390caedb --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx @@ -0,0 +1,260 @@ +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/input-text-error' +import OtpInput from './components/otp-input' + +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') { + if (isEmpty(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً ایمیل خود را وارد کنید.', + })) + + if (!isEmail(email)) + return setError((prev) => ({ + ...prev, + email: 'لطفاً یک ایمیل معتبر وارد کنید.', + })) + + onSendOtp() + } else if (step === 'enter-otp') { + 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) + setError({ + api: content as string, + email: null, + otp: null, + }) + } + } + + const onVerifyOtp = async () => { + 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') + return ( +
+
+ +
+

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

+

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

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

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

+

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

+
+
+ +
+
+ + + + +
{' '} + + +
+ + +
+ ) +} + +export default AuthOtp diff --git a/src/layouts/setting/tabs/account/auth-form/auth-password.tsx b/src/layouts/setting/tabs/account/auth-form/auth-password.tsx new file mode 100644 index 00000000..5335433a --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/auth-password.tsx @@ -0,0 +1,179 @@ +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/input-text-error' +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 + api: string | null + }>({ email: null, password: null, api: null }) + + const { login } = useAuth() + const { mutateAsync: signInMutation, isPending } = useSignIn() + + const resetErrors = () => { + setError({ email: null, password: null, api: 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() + validateInputs() + try { + const response = await signInMutation({ + email, + password, + }) + console.log('Sign-in response:', response) + login(response.data) + Analytics.event('sign_in') + } catch (err) { + const content = translateError(err) + console.log('Sign-in error content:', content) + if (typeof content === 'string') { + setError({ + email: null, + password: null, + api: content, + }) + } else { + if (Object.keys(content).length === 0) { + setError({ + email: null, + password: null, + api: 'خطای ناشناخته رخ داد. لطفا دوباره تلاش کنید.', + }) + return + } + setError({ + email: content.email, + password: content.password, + api: null, + }) + } + } + } + + return ( +
+
+ +
+

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

+

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

+
+
+ + {error.api && ( +
+ {error.api} +
+ )} + +
+
+ + + + +
+ +
+ + + + +
+ + +
+
+ ) +} diff --git a/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx b/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx new file mode 100644 index 00000000..ddb36cda --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/input-text-error.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/login-google.button.tsx new file mode 100644 index 00000000..0290df16 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx @@ -0,0 +1,101 @@ +import { IconLoading } from '@/components/loading/icon-loading' +import { useAuth } from '@/context/auth.context' +import { + type AuthResponse, + useGoogleSignIn, +} from '@/services/hooks/auth/authService.hook' +import { useState } from 'react' +import { safeAwait } from '@/services/api' +import type { AxiosError } from 'axios' +import { showToast } from '@/common/toast' +import { translateError } from '@/utils/translate-error' +import Analytics from '@/analytics' + +export default function LoginGoogleButton() { + const { login } = useAuth() + const [isLoading, setIsLoading] = useState(false) + const googleSignInMutation = useGoogleSignIn() + + const loginGoogle = async () => { + Analytics.event('auth_method_changed_to_google') + 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(), + interactive: true, + }) + + const params = new URLSearchParams(redirectUrl?.split('#')[1]) + const token = params.get('access_token') + console.log('Access Token:', token) + + if (token) { + const [err, response] = await safeAwait( + googleSignInMutation.mutateAsync({ + token, + referralCode: undefined, + }) + ) + if (err) { + return showToast(translateError(err) as string, 'error') + } + + login(response.data) + } + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} 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 new file mode 100644 index 00000000..ba303898 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx @@ -0,0 +1,28 @@ +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/login-password.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx new file mode 100644 index 00000000..31b9532f --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx @@ -0,0 +1,30 @@ +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/other-auth-options.container.tsx b/src/layouts/setting/tabs/account/auth-form/components/other-auth-options.container.tsx new file mode 100644 index 00000000..642f76c4 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/other-auth-options.container.tsx @@ -0,0 +1,40 @@ +import LoginGoogleButton from './login-google.button' +import LoginPasswordButton from './login-password.button' +import LoginOtpButton from './login-otp-button' + +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/otp-input.tsx b/src/layouts/setting/tabs/account/auth-form/components/otp-input.tsx new file mode 100644 index 00000000..1e2c6050 --- /dev/null +++ b/src/layouts/setting/tabs/account/auth-form/components/otp-input.tsx @@ -0,0 +1,119 @@ +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)[]>([]) + const [positions, setPositions] = useState>(new Map()) + const isInitialized = useRef(false) + + 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 + } + }, []) + + useEffect(() => { + inputRefs.current[0]?.focus() + }, []) + + const otpArray = Array.from({ length: OTP_LENGTH }, (_, i) => positions.get(i) || '') + + const updateParentOtp = (newPositions: Map) => { + 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) => { + if (value && !isNumber(value)) return + + const newPositions = new Map(positions) + + if (value) { + newPositions.set(index, value) + } else { + newPositions.delete(index) + } + + setPositions(newPositions) + updateParentOtp(newPositions) + + 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) { + inputRefs.current[index - 1]?.focus() + } + } + } + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault() + const pastedData = e.clipboardData.getData('text').trim() + + if (!pastedData || !/^\d+$/.test(pastedData)) return + + const digits = pastedData.slice(0, OTP_LENGTH) + const newPositions = new Map() + + for (let i = 0; i < digits.length; i++) { + newPositions.set(i, digits[i]) + } + + setPositions(newPositions) + updateParentOtp(newPositions) + + 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 border-2 ${ + isError + ? 'border-error/80 bg-error/30 text-error' + : 'border-content bg-content' + } rounded-lg md:rounded-xl text-base-content/70 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 e0e8231d..00000000 --- a/src/layouts/setting/tabs/account/auth-form/sign-in-form.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from 'react' -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 SignInFormProps { - onSwitchToSignUp: () => void -} - -export const SignInForm = ({ onSwitchToSignUp }: SignInFormProps) => { - const [email, setEmail] = useState('') - 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, 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} -
- )} -
-

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

-
-
- ) -} 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/components/ProfileDatePicker.tsx b/src/layouts/setting/tabs/account/components/profile-date-picker.tsx similarity index 100% rename from src/layouts/setting/tabs/account/components/ProfileDatePicker.tsx rename to src/layouts/setting/tabs/account/components/profile-date-picker.tsx diff --git a/src/layouts/setting/tabs/account/components/profile-edit-form.tsx b/src/layouts/setting/tabs/account/components/profile-edit-form.tsx index aea38552..ca904108 100644 --- a/src/layouts/setting/tabs/account/components/profile-edit-form.tsx +++ b/src/layouts/setting/tabs/account/components/profile-edit-form.tsx @@ -12,7 +12,7 @@ import { } from '@/services/hooks/auth/authService.hook' import type { UserProfile } from '@/services/hooks/user/userService.hook' import { translateError } from '@/utils/translate-error' -import JalaliDatePicker from './ProfileDatePicker' +import JalaliDatePicker from './profile-date-picker' import { showToast } from '@/common/toast' interface ProfileEditFormProps { diff --git a/src/layouts/setting/tabs/account/user-account.modal.tsx b/src/layouts/setting/tabs/account/user-account.modal.tsx index 339c15d0..9bedd315 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/auth-form' 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 ( { _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) diff --git a/src/services/hooks/auth/authService.hook.ts b/src/services/hooks/auth/authService.hook.ts index f635f845..088038fc 100644 --- a/src/services/hooks/auth/authService.hook.ts +++ b/src/services/hooks/auth/authService.hook.ts @@ -17,19 +17,25 @@ interface SignUpCredentials { referralCode?: string } -interface AuthResponse { +export interface AuthResponse { statusCode: number message: string | null data: string // token + isNewUser?: boolean +} +interface GoogleAuthCredentials { + token: string + referralCode?: string } -interface User { - name: string - gender?: 'MALE' | 'FEMALE' | 'OTHER' - birthdate?: string - avatar?: 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) @@ -52,7 +58,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 +68,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 +76,36 @@ 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 +} + +async function requestOtp(payload: OtpPayload): 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), @@ -93,3 +129,21 @@ export function useUpdateUsername() { mutationFn: (username: string) => updateUsername(username), }) } + +export function useGoogleSignIn() { + return useMutation({ + 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), + }) +} 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 = { diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 00000000..6ec9eef2 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,12 @@ +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 isMoreThan = (text: string, length: number) => + text.trim().length > length ? true : false; + +export const isLessThan = (text: string, length: number) => + text.trim().length < length ? true : false; diff --git a/wxt.config.ts b/wxt.config.ts index eb4dcc8c..0c9f179a 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ '@wxt-dev/module-react', ], manifest: { - version: '1.0.59', + version: '1.0.60', name: 'Widgetify', description: 'Transform your new tab into a smart dashboard with Widgetify! Get currency rates, crypto prices, weather & more.',