diff --git a/package-lock.json b/package-lock.json index c13cc97..8be8bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@mui/x-data-grid-premium": "^8.10.0", "@mui/x-date-pickers": "^8.10.0", "@rkheftan/harmony-ui": "^0.2.89", - "@rollup/rollup-win32-x64-msvc": "^4.46.3", + "@rollup/rollup-darwin-arm64": "^4.46.3", "axios": "^1.11.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.0.0-0", @@ -1962,9 +1962,7 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", - "optional": true, "os": [ "darwin" ] @@ -2228,7 +2226,9 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", + "optional": true, "os": [ "win32" ] diff --git a/package.json b/package.json index 237bc04..d03bf6c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@mui/x-data-grid-premium": "^8.10.0", "@mui/x-date-pickers": "^8.10.0", "@rkheftan/harmony-ui": "^0.2.89", - "@rollup/rollup-win32-x64-msvc": "^4.46.3", + "@rollup/rollup-darwin-arm64": "^4.46.3", "axios": "^1.11.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.0.0-0", diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index cf75b15..37059cc 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -72,7 +72,9 @@ "errorConfirmCode": "Failed to confirm code", "errorChangePhone": "Failed to change phone number", "verificationCodeSent": "Verification code sent", - "onlyOneAccountAllowed": "You can only link one email account." + "onlyOneAccountAllowed": "You can only link one email account.", + "editEmailOrGoogle": "Change Email / Google", + "emailText": "Your new email will replace your previous email (<1>{{email}})" }, "active": { "activeDevices": "Active devices", @@ -147,4 +149,4 @@ "description": "Encourage repeat purchases by sending discount codes and points to your customers." } } -} \ No newline at end of file +} diff --git a/public/locales/fa/setting.json b/public/locales/fa/setting.json index 20c8cdd..9051565 100644 --- a/public/locales/fa/setting.json +++ b/public/locales/fa/setting.json @@ -72,7 +72,9 @@ "errorConfirmCode": "تایید کد با خطا مواجه شد", "errorChangePhone": "تغییر تلفن همراه با خطا مواجه شد", "verificationCodeSent": "کد تایید ارسال شد", - "onlyOneAccountAllowed": "شما فقط می‌توانید یک حساب ایمیل را متصل کنید" + "onlyOneAccountAllowed": "شما فقط می‌توانید یک حساب ایمیل را متصل کنید", + "editEmailOrGoogle": "تغییر ایمیل / گوگل", + "emailText": "ایمیل جدید شما جایگزین ایمل قبلی ({{email}}) خواهد شد" }, "active": { "activeDevices": "نشست های فعال", @@ -147,4 +149,4 @@ "description": "با ارسال کد تخفیف و امتیاز به مشتریان کسب و کارتان آنها را به خرید مجدد ترغیب کنید" } } -} \ No newline at end of file +} diff --git a/src/components/CountDownTimer.tsx b/src/components/CountDownTimer.tsx index 6cb280f..12f8885 100644 --- a/src/components/CountDownTimer.tsx +++ b/src/components/CountDownTimer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toLocaleDigits } from '@/utils/persianDigit'; @@ -14,27 +14,34 @@ export function CountDownTimer({ const { i18n } = useTranslation(); const [secondsLeft, setSecondsLeft] = useState(initialSeconds); + const onCompleteRef = useRef(onComplete); + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + useEffect(() => { setSecondsLeft(initialSeconds); + }, [initialSeconds]); - const timer = setInterval(() => { + useEffect(() => { + if (secondsLeft <= 0) return; + const id = setInterval(() => { setSecondsLeft((prev) => { if (prev <= 1) { - clearInterval(timer); - onComplete?.(); + clearInterval(id); + onCompleteRef.current?.(); return 0; } return prev - 1; }); }, 1000); - - return () => clearInterval(timer); - }, [initialSeconds, onComplete]); + return () => clearInterval(id); + }, [secondsLeft]); const formatTime = (totalSeconds: number) => { - const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); - const seconds = String(totalSeconds % 60).padStart(2, '0'); - return toLocaleDigits(`${minutes}:${seconds}`, i18n.language); + const m = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); + const s = String(totalSeconds % 60).padStart(2, '0'); + return toLocaleDigits(`${m}:${s}`, i18n.language); }; return {formatTime(secondsLeft)}; diff --git a/src/features/profile/components/security/PasswordDialog.tsx b/src/features/profile/components/security/PasswordDialog.tsx index f83ca8c..cda6322 100644 --- a/src/features/profile/components/security/PasswordDialog.tsx +++ b/src/features/profile/components/security/PasswordDialog.tsx @@ -92,7 +92,7 @@ export function PasswordDialog({ fullWidth value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }} + sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 }, mt: 2 }} InputProps={{ endAdornment: ( @@ -121,7 +121,7 @@ export function PasswordDialog({ fullWidth value={password} onChange={(e) => setPassword(e.target.value)} - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }} + sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 }, mt: 2 }} InputProps={{ endAdornment: ( @@ -169,7 +169,7 @@ export function PasswordDialog({ ? t('securityForm.notCompatibility') : ' ' } - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} + sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }} InputProps={{ endAdornment: ( diff --git a/src/features/profile/components/userInformation/SocialMedia.tsx b/src/features/profile/components/userInformation/SocialMedia.tsx index 0e4eaac..41acb67 100644 --- a/src/features/profile/components/userInformation/SocialMedia.tsx +++ b/src/features/profile/components/userInformation/SocialMedia.tsx @@ -1,13 +1,13 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { CardContainer } from '@/components/CardContainer'; +import { Box, CircularProgress, Button } from '@mui/material'; import { PageWrapper } from '../PageWrapper'; +import { CardContainer } from '@/components/CardContainer'; import SocialMediaList from './socialMedia/SocialMediaList'; -import SocialMediaMenu from './socialMedia/SocialMediaMenu'; -import SocialMediaDialog from './socialMedia/SocialMediaDialog'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import type { Theme } from '@mui/material/styles'; -import { Box, CircularProgress } from '@mui/material'; +import SocialEditForm from './socialMedia/SocialEditForm'; +import SocialMediaMenu, { + type SocialChoice, +} from './socialMedia/SocialMediaMenu'; import { useApi } from '@/hooks/useApi'; import { sendEmailCode, @@ -20,51 +20,46 @@ import { useProfile } from '../../hooks/useProfile'; export function SocialMedia() { const { t } = useTranslation('setting'); + const toast = useToast(); - const [openDialog, setOpenDialog] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [emailInput, setEmailInput] = useState(''); const [verificationCode, setVerificationCode] = useState(''); - const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>( - 'enterEmail', + const [email, setEmail] = useState([]); + const [emailError, setEmailError] = useState(); + const [verificationCodeError, setVerificationCodeError] = useState< + string | undefined + >(); + const [isCodeSent, setIsCodeSent] = useState(false); + const [buttonState, setButtonState] = useState<'default' | 'counting'>( + 'default', ); - const [emailList, setEmailList] = useState([]); - const [formError, setFormError] = useState(null); - const toast = useToast(); + const [isVerified, setIsVerified] = useState(false); const { isLoadingProfile, refetchProfile } = useProfile(); const { loading: isSendingCode, execute: executeSendCode } = useApi(sendEmailCode); - const { loading: isConfirming, execute: executeConfirmCode } = useApi(confirmEmailCode); - const { loading: isChangingEmail, execute: executeChangeEmail } = useApi(changeEmail); - const fullScreen = useMediaQuery((theme: Theme) => - theme?.breakpoints.down('sm'), + const isBusy = useMemo( + () => isSendingCode || isConfirming || isChangingEmail, + [isSendingCode, isConfirming, isChangingEmail], ); - const downMd = useMediaQuery((theme: Theme) => theme?.breakpoints.down('md')); - const computedMaxWidth = (fullScreen ? 'xs' : downMd ? 'sm' : 'md') as - | 'xs' - | 'sm' - | 'md' - | 'lg' - | 'xl'; + const isSubmitting = isConfirming || isChangingEmail; useEffect(() => { const loadProfile = async () => { const profileData = await refetchProfile(); - if (!profileData) return; if (profileData?.success) { const { email } = profileData; - if (!email) return; - - setEmailList([ + setEmail([ { email, provider: email.includes('@gmail.') ? 'google' : 'email', @@ -72,33 +67,48 @@ export function SocialMedia() { }, ]); } else { - toast({ - message: profileData.message, - severity: 'error', - }); + toast({ message: profileData.message, severity: 'error' }); } }; + void loadProfile(); + }, [refetchProfile, toast]); - loadProfile(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const resetDialog = () => { - setOpenDialog(false); + const resetState = () => { setEmailInput(''); setVerificationCode(''); - setFormError(null); - setDialogStep('enterEmail'); + setEmailError(undefined); + setVerificationCodeError(undefined); + setIsCodeSent(false); + setButtonState('default'); + setIsVerified(false); + }; + + const startEmailEdit = () => { + resetState(); + setIsEditing(true); + }; + + const buttonLabel = email.length + ? t('settingForm.editEmailOrGoogle') + : t('settingForm.addEmailOrSocialButton'); + + const validateEmail = (value: string) => { + if (!value) { + setEmailError(t('settingForm.thisFieldIsRequired')); + return false; + } + const ok = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value); + if (!ok) { + setEmailError(t('settingForm.emailIsInvalid')); + return false; + } + setEmailError(undefined); + return true; }; const handleSendCode = async () => { - if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(emailInput)) { - setFormError(t('settingForm.emailIsInvalid')); - return; - } - setFormError(null); + if (!validateEmail(emailInput)) return; const sendCodeData = await executeSendCode({ email: emailInput }); - if (!sendCodeData) return; if (sendCodeData.success) { @@ -106,28 +116,27 @@ export function SocialMedia() { message: t('settingForm.verificationCodeSent'), severity: 'success', }); - setDialogStep('enterCode'); - - setTimeout( - () => { - resetDialog(); - }, - 3 * 60 * 1000, - ); + setIsCodeSent(true); + setButtonState('counting'); + } else { + toast({ + message: sendCodeData.message || t('settingForm.error'), + severity: 'error', + }); } }; - const handleConfirmAndChangeEmail = async () => { - if (verificationCode.length < 4) { - setFormError(t('settingForm.verificationCodeRequired')); + const handleVerifyClick = async () => { + if (!verificationCode || verificationCode.length < 4) { + setVerificationCodeError(t('settingForm.verificationCodeRequired')); return; } - setFormError(null); + setVerificationCodeError(undefined); + const confirmData = await executeConfirmCode({ email: emailInput, verifyCode: verificationCode, }); - if (!confirmData) return; if (!confirmData.success || !confirmData.confirm) { @@ -138,43 +147,111 @@ export function SocialMedia() { return; } - if (emailList.length >= 1) { - toast({ - message: t('settingForm.onlyOneAccountAllowed'), - severity: 'error', - }); - return; - } - const changeEmailData = await executeChangeEmail({ email: emailInput }); if (!changeEmailData) return; if (changeEmailData.success) { - setEmailList([ + setEmail([ { email: emailInput, provider: emailInput.includes('@gmail.') ? 'google' : 'email', time: t('settingForm.justNow'), }, ]); - + setIsVerified(true); toast({ message: t('settingForm.emailChangedSuccessfully'), severity: 'success', }); - - resetDialog(); + setIsEditing(false); refetchProfile({ force: true }); + } else { + toast({ + message: changeEmailData.message || t('settingForm.error'), + severity: 'error', + }); } }; + const handleBlur = (field: 'email' | 'verificationCode') => { + if (field === 'email') validateEmail(emailInput); + if (field === 'verificationCode') { + if (!verificationCode || verificationCode.length < 4) { + setVerificationCodeError(t('settingForm.verificationCodeRequired')); + } else { + setVerificationCodeError(undefined); + } + } + }; + + const onMenuSelect = (choice: SocialChoice) => { + if (choice === 'email') { + startEmailEdit(); + return; + } + if (choice === 'google') { + toast({ + message: t('settingForm.googleLinkComingSoon'), + severity: 'info', + }); + } + }; + + const rejectButton = () => { + setIsEditing(false); + setIsCodeSent(false); + }; + return ( setOpenDialog(true)} /> + isEditing ? ( + + + + + ) : ( + + ) } > {isLoadingProfile ? ( @@ -184,30 +261,45 @@ export function SocialMedia() { justifyContent: 'center', alignItems: 'center', p: 4, - minHeight: '100px', + minHeight: '150px', }} > + ) : isEditing ? ( + { + e.preventDefault(); + e.stopPropagation(); + if (!isBusy) void handleVerifyClick(); + }} + > + ({ address: e.email }))} + verificationCode={verificationCode} + setVerificationCode={setVerificationCode} + isVerified={isVerified} + isVerifying={isBusy} + isSendingCode={isSendingCode} + isCodeSent={isCodeSent} + setIsCodeSent={setIsCodeSent} + buttonState={buttonState} + setButtonState={setButtonState} + handleSendCode={handleSendCode} + handleVerifyClick={handleVerifyClick} + handleBlur={handleBlur} + emailError={emailError} + verificationCodeError={verificationCodeError} + /> + ) : ( - + )} - ); diff --git a/src/features/profile/components/userInformation/socialMedia/SocialEditForm.tsx b/src/features/profile/components/userInformation/socialMedia/SocialEditForm.tsx new file mode 100644 index 0000000..e09bd9e --- /dev/null +++ b/src/features/profile/components/userInformation/socialMedia/SocialEditForm.tsx @@ -0,0 +1,199 @@ +import { + Box, + Typography, + TextField, + Button, + IconButton, + InputAdornment, + CircularProgress, +} from '@mui/material'; +import { Edit2, TickCircle } from 'iconsax-react'; +import { CountDownTimer } from '@/components/CountDownTimer'; +import { Icon } from '@rkheftan/harmony-ui'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +type SocialEditFormProps = { + email: string; + setEmail: (v: string) => void; + emails: { address: string }[]; + verificationCode: string; + setVerificationCode: (v: string) => void; + isVerified: boolean; + isVerifying: boolean; + isSendingCode: boolean; + isCodeSent: boolean; + setIsCodeSent: (v: boolean) => void; + buttonState: 'default' | 'counting'; + setButtonState: (v: 'default' | 'counting') => void; + handleSendCode: () => void; + handleVerifyClick: () => void; + handleBlur: (field: 'email' | 'verificationCode') => void; + emailError?: string; + verificationCodeError?: string; +}; + +export default function SocialEditForm({ + email, + setEmail, + emails, + verificationCode, + setVerificationCode, + isVerified, + isVerifying, + isSendingCode, + isCodeSent, + setIsCodeSent, + buttonState, + setButtonState, + handleSendCode, + handleVerifyClick, + handleBlur, + emailError, + verificationCodeError, +}: SocialEditFormProps) { + const { t } = useTranslation('setting'); + + const textFieldRef = useRef(null); + const inputRef = useRef(null); + + return ( + + + + {t('settingForm.editEmailOrGoogle')} + + + {t('settingForm.emailText', { + email: emails.map((e) => e.address).join(', '), + })} + + + + + handleBlur('email')} + error={!!emailError} + helperText={emailError} + onChange={(e) => setEmail(e.target.value)} + placeholder="abc@email.com" + autoComplete="email" + inputMode="email" + disabled={isCodeSent} + slotProps={{ + input: { + endAdornment: isCodeSent ? ( + + { + setButtonState('default'); + setVerificationCode(''); + setIsCodeSent(false); + }} + edge="end" + > + + + + ) : undefined, + }, + }} + /> + + {isVerified ? ( + + + {t('settingForm.successButton')} + + ) : ( + + )} + + + {isCodeSent && !isSendingCode && !isVerified && ( + + handleBlur('verificationCode')} + onChange={(e) => + setVerificationCode(e.target.value.replace(/\D/g, '')) + } + placeholder={t('settingForm.verificationCode')} + autoComplete="one-time-code" + inputMode="numeric" + /> + + + + )} + + ); +} diff --git a/src/features/profile/components/userInformation/socialMedia/SocialMediaDialog.tsx b/src/features/profile/components/userInformation/socialMedia/SocialMediaDialog.tsx deleted file mode 100644 index 7f130f3..0000000 --- a/src/features/profile/components/userInformation/socialMedia/SocialMediaDialog.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React, { type ReactElement, type ElementType, useState } from 'react'; -import { - Box, - Button, - CircularProgress, - Dialog, - DialogContent, - DialogTitle, - IconButton, - TextField, - Typography, -} from '@mui/material'; -import Slide from '@mui/material/Slide'; -import type { TransitionProps } from '@mui/material/transitions'; -import { CloseCircle } from 'iconsax-react'; -import { Icon } from '@rkheftan/harmony-ui'; -import { type SocialMediaDialogProps } from '@/features/profile/types/settingsType'; -import { isEmail } from '@/utils/regexes/isEmail'; - -const MobileSlide = React.forwardRef(function MobileSlide( - props: TransitionProps & { children: ReactElement }, - ref: React.Ref, -) { - return ; -}); - -export default function SocialMediaDialog({ - open, - onClose, - t, - emailInput, - setEmailInput, - fullScreen, - verificationCode, - setVerificationCode, - isLoading, - dialogStep, - onSendCode, - onConfirmEmail, -}: SocialMediaDialogProps) { - const [emailError, setEmailError] = useState(); - const [touched, setTouched] = useState(false); - - const validateEmail = (value: string) => { - if (!value) { - setEmailError(t('settingForm.thisFieldIsRequired')); - return false; - } - if (!isEmail(value)) { - setEmailError(t('settingForm.emailIsInvalid')); - return false; - } - setEmailError(undefined); - return true; - }; - - const handleSubmit: React.FormEventHandler = (e) => { - e.preventDefault(); - e.stopPropagation(); - - if (isLoading) return; - - if (dialogStep === 'enterEmail') { - setTouched(true); - if (!validateEmail(emailInput)) return; - onSendCode(); - } else { - onConfirmEmail(); - } - }; - - return ( - - - - - - - - {t('settingForm.addEmailButton')} - - - - - - - - - {t('settingForm.newEmail')} - - - {t('settingForm.dialogHeader')} - - - - { - setEmailInput(e.target.value); - if (touched) validateEmail(e.target.value); - }} - onBlur={() => { - setTouched(true); - validateEmail(emailInput); - }} - label={t('settingForm.email')} - placeholder="abc@email.com" - autoComplete="email" - inputMode="email" - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 }, mt: 1 }} - autoFocus={dialogStep === 'enterEmail'} - disabled={isLoading || dialogStep === 'enterCode'} - error={touched && !!emailError} - helperText={touched && emailError ? emailError : ' '} - /> - - {dialogStep === 'enterCode' && ( - setVerificationCode(e.target.value)} - label={t('settingForm.verificationCode')} - autoComplete="one-time-code" - inputMode="numeric" - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }} - autoFocus - disabled={isLoading} - /> - )} - - - - - - - - ); -} diff --git a/src/features/profile/components/userInformation/socialMedia/SocialMediaList.tsx b/src/features/profile/components/userInformation/socialMedia/SocialMediaList.tsx index 2f0a0e0..bfbb3fa 100644 --- a/src/features/profile/components/userInformation/socialMedia/SocialMediaList.tsx +++ b/src/features/profile/components/userInformation/socialMedia/SocialMediaList.tsx @@ -19,12 +19,7 @@ export default function SocialMediaList({ emailList }: SocialMediaListProps) { }} > - {item.provider === 'google' && ( + {item.provider === 'google' ? ( - )} - {item.provider === 'email' && ( + ) : ( string; + onSelect: (choice: SocialChoice) => void; + buttonLabel: string; +}; + +export default function SocialMediaMenu({ t, onSelect, buttonLabel }: Props) { const [anchor, setAnchor] = useState(null); const openMenu = Boolean(anchor); - const [open, setOpen] = useState(false); - - const handleClickMenu = (e: React.MouseEvent) => { - setOpen(true); - setAnchor(e.currentTarget); - }; - const handleCloseMenu = () => { - setOpen(false); - setAnchor(null); - }; return ( - - + - + {buttonLabel} + + setAnchor(null)} + PaperProps={{ sx: { width: anchor ? anchor.offsetWidth : 'auto' } }} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'top', horizontal: 'left' }} > { - handleCloseMenu(); - onOpenDialog(); + setAnchor(null); + onSelect('email'); }} > @@ -92,7 +70,13 @@ export default function SocialMediaMenu({ {t('settingForm.email')} - + + { + setAnchor(null); + onSelect('google'); + }} + > diff --git a/src/features/profile/types/settingsType.ts b/src/features/profile/types/settingsType.ts index 26dea18..5cc7112 100644 --- a/src/features/profile/types/settingsType.ts +++ b/src/features/profile/types/settingsType.ts @@ -69,7 +69,7 @@ export interface SocialMediaDialogProps { } export interface SocialMediaListProps { - t: (key: string) => string; + // t: (key: string) => string; emailList: readonly { email: string; provider: 'email' | 'google' | 'apple';