refactor: modals

This commit is contained in:
Mahyar Khanbolooki
2025-08-10 17:54:21 +03:30
parent ac30ac1ecf
commit 389ac492c2
9 changed files with 214 additions and 129 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -36
View File
@@ -1,8 +1,6 @@
'use client'; 'use client';
import Button from '@/components/button/PrimaryButton';
import SearchBox from '@/components/input/SearchBox'; import SearchBox from '@/components/input/SearchBox';
import BlurredOverlayContainer from '@/components/overlays/BlurredOverlayContainer';
import { ScrollArea } from '@/components/ui/scrollarea'; import { ScrollArea } from '@/components/ui/scrollarea';
import { Radar2, Trash } from 'iconsax-react'; import { Radar2, Trash } from 'iconsax-react';
import { Check, CheckCheck } from 'lucide-react'; import { Check, CheckCheck } from 'lucide-react';
@@ -10,10 +8,10 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { LongPressCallbackMeta, LongPressReactEvents, useLongPress } from 'use-long-press' import { LongPressCallbackMeta, LongPressReactEvents, useLongPress } from 'use-long-press'
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ef } from '@/lib/helpers/utfNumbers'; import useToggle from '@/hooks/helpers/useToggle';
import Prompt from '@/components/utils/Prompt';
type Props = object type Props = object
@@ -36,8 +34,8 @@ function ChatIndex({ }: Props) {
}); });
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedChats, setSelectedChats] = useState<Array<string>>([]); const [selectedChats, setSelectedChats] = useState<Array<string>>([]);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const router = useRouter(); const router = useRouter();
const { active: deleteModal, onToggle: onToggleDeleteModal } = useToggle();
const [chatsList, setChatList] = useState<Array<ChatEntryModel>>([ const [chatsList, setChatList] = useState<Array<ChatEntryModel>>([
{ {
@@ -105,12 +103,10 @@ function ChatIndex({ }: Props) {
const toggleDeleteModalVisiblity = (e?: React.MouseEvent) => { const toggleDeleteModalVisiblity = (e?: React.MouseEvent) => {
e?.preventDefault(); e?.preventDefault();
e?.stopPropagation(); e?.stopPropagation();
setDeleteModalVisible((state) => { if (deleteModal) {
if (state) { setSelectedChats([]);
setSelectedChats([]); }
} onToggleDeleteModal()
return !state;
});
}; };
const deleteSelectedChats = () => { const deleteSelectedChats = () => {
@@ -157,7 +153,7 @@ function ChatIndex({ }: Props) {
<SearchBox placeholder={t('InputSearch.Placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} /> <SearchBox placeholder={t('InputSearch.Placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
{selectedChats.length > 0 ? {selectedChats.length > 0 ?
<span onClick={toggleDeleteModalVisiblity} className='bg-container p-2 rounded-xl active:bg-gray-50'> <span onClick={onToggleDeleteModal} className='bg-container p-2 rounded-xl active:bg-gray-50'>
<Trash size={24} className='stroke-primary' /> <Trash size={24} className='stroke-primary' />
</span> </span>
: :
@@ -222,30 +218,16 @@ function ChatIndex({ }: Props) {
</ScrollArea> </ScrollArea>
</section> </section>
<BlurredOverlayContainer visible={deleteModalVisible} onClick={toggleDeleteModalVisiblity} inDuration={100} outDuration={100} outDelay={75}> <Prompt
<div className="absolute top-1/2 left-0 w-full px-6"> title={tDeleteModal('Heading')}
<motion.div description={tDeleteModal('Description').replace('{count}', String(selectedChats.length))}
animate={{ y: deleteModalVisible ? [10, 0] : [0, 10] }} textConfirm={tDeleteModal('ButtonOk')}
transition={{ duration: 0.1, ease: 'easeInOut' }} textCancel={tDeleteModal('ButtonCancel')}
className="top-1/2 absolute left-1/2 min-w-xs w-full max-w-sm text-center -translate-1/2 px-6 pt-8 pb-9 drop-shadow-container bg-white/63 rounded-4xl" onConfirm={deleteSelectedChats}
> onCancel={toggleDeleteModalVisiblity}
<h2 className="text-base font-medium"> onClick={toggleDeleteModalVisiblity}
{tDeleteModal('Heading')} visible={deleteModal}
</h2> />
<p className="text-xs font-medium mt-6">
{tDeleteModal('Description').replace('{count}', ef(selectedChats.length))}
</p>
<div className="grid grid-cols-2 gap-5.5 mt-8">
<Button onClick={deleteSelectedChats} className="text-sm font-normal">
{tDeleteModal('ButtonOk')}
</Button>
<Button onClick={toggleDeleteModalVisiblity} className="bg-disabled! text-disabled2! text-sm font-normal">
{tDeleteModal('ButtonCancel')}
</Button>
</div>
</motion.div>
</div>
</BlurredOverlayContainer>
</div> </div>
) )
} }
+46 -92
View File
@@ -1,18 +1,20 @@
'use client'; 'use client';
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef } from 'react';
import { motion, PanInfo, Variants } from 'framer-motion'; import { motion, PanInfo, Variants } from 'framer-motion';
import SideMenuItem from './SideMenuItem'; import SideMenuItem from './SideMenuItem';
import NoteBoardIcon from '../icons/NoteBoardIcon'; import NoteBoardIcon from '../icons/NoteBoardIcon';
import BlurredOverlayContainer from '../overlays/BlurredOverlayContainer'; import BlurredOverlayContainer from '../overlays/BlurredOverlayContainer';
import NightModeSwitch from '../button/NightModeSwitch'; import NightModeSwitch from '../button/NightModeSwitch';
import Button from '../button/PrimaryButton'; import Button from '../button/PrimaryButton';
import { useAuthStore } from '@/zustand/authStore'; import { useParams, usePathname } from 'next/navigation';
import { useParams, usePathname, useRouter } from 'next/navigation';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CalendarSearch, Cup, DirectboxReceive, DocumentCopy, Game, Icon, Instagram, Like1, LogoutCurve, Notification, Receipt1, Setting2, Share, TicketDiscount, Whatsapp } from 'iconsax-react'; import { CalendarSearch, Cup, DirectboxReceive, DocumentCopy, Game, Icon, Instagram, Like1, LogoutCurve, Notification, Receipt1, Setting2, Share, TicketDiscount, Whatsapp } from 'iconsax-react';
import TelegramIcon from '../icons/TelegramIcon'; import TelegramIcon from '../icons/TelegramIcon';
import Modal from '../utils/Modal';
import LogoutPrompt from '@/features/general/LogoutPrompt';
import useToggle from '@/hooks/helpers/useToggle';
type MenuItemType = { type MenuItemType = {
href: string | undefined; href: string | undefined;
@@ -49,21 +51,17 @@ type Props = {
function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) { function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) {
const menuStateMemo = useMemo(() => menuState, [menuState]); const menuStateMemo = useMemo(() => menuState, [menuState]);
const [logoutModalVisible, setLogoutModalVisible] = useState(false);
const [shareModalVisible, setShareModalVisible] = useState(false);
const closeRef = useRef<HTMLDivElement>(null); const closeRef = useRef<HTMLDivElement>(null);
const userIsAuthenticated = true; // useAuthStore((state) => state.isAuthenticated); const userIsAuthenticated = true; // useAuthStore((state) => state.isAuthenticated);
const authLogout = useAuthStore((state) => state.logout); const { active: logoutModal, onToggle: onToggleLogoutModal } = useToggle();
const router = useRouter(); const { active: shareModal, onToggle: onToggleShareModal } = useToggle();
const params = useParams(); const params = useParams();
const { name } = params; const { name } = params;
const pathname = usePathname(); const pathname = usePathname();
const { t: tMenu } = useTranslation('common', { const { t: tMenu } = useTranslation('common', {
keyPrefix: 'SideMenu' keyPrefix: 'SideMenu'
}); });
const { t: tLogoutModal } = useTranslation('common', {
keyPrefix: 'LogoutModal'
});
const { t: tShareModal } = useTranslation('common', { const { t: tShareModal } = useTranslation('common', {
keyPrefix: 'ShareModal' keyPrefix: 'ShareModal'
}); });
@@ -79,29 +77,9 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
} }
}; };
const toggleLogoutModalVisiblity = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setLogoutModalVisible((state) => !state);
};
const toggleShareModalVisiblity = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setShareModalVisible((state) => !state);
};
const onLogout = (e: React.MouseEvent<HTMLButtonElement> | null) => {
if (!e) return;
if (userIsAuthenticated) {
authLogout();
router.replace('/');
}
};
const hrefOnClicks = [ const hrefOnClicks = [
{ href: '?logout', handler: toggleLogoutModalVisiblity }, { href: '?logout', handler: onToggleLogoutModal },
{ href: '?share', handler: toggleShareModalVisiblity } { href: '?share', handler: onToggleShareModal }
]; ];
const renderMenu = () => { const renderMenu = () => {
@@ -210,80 +188,56 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
className="fixed top-0 bottom-0 right-0 bg-white w-[200px] h-dvh flex flex-col z-40 overflow-clip not-xl:!rounded-s-none" className="fixed top-0 bottom-0 right-0 bg-white w-[200px] h-dvh flex flex-col z-40 overflow-clip not-xl:!rounded-s-none"
> >
{renderMenu()} {renderMenu()}
{String(logoutModal)}
</motion.nav> </motion.nav>
</BlurredOverlayContainer> </BlurredOverlayContainer>
<nav <nav
className="hidden fixed top-4 bottom-4 right-4 bg-white xl:w-[250px] xl:flex flex-col z-40 overflow-clip xl:rounded-4xl" className="hidden fixed top-4 bottom-4 right-4 bg-white xl:w-[250px] xl:flex flex-col z-40 overflow-clip xl:rounded-4xl"
> >
{renderMenu()} {renderMenu()}
</nav> </nav>
</aside> </aside>
</div> </div>
{/* Logout Modal */} {/* Logout Modal */}
<BlurredOverlayContainer visible={logoutModalVisible} onClick={toggleLogoutModalVisiblity} inDuration={100} outDuration={100} outDelay={75}> <LogoutPrompt
<div className="absolute top-1/2 left-6 right-6 px-6"> visible={logoutModal}
<motion.div onClick={onToggleLogoutModal}
animate={{ y: logoutModalVisible ? [10, 0] : [0, 10] }} />
transition={{ duration: 0.1, ease: 'easeInOut' }}
className="top-1/2 absolute left-1/2 min-w-xs w-full max-w-sm text-center -translate-1/2 px-6 pt-8 pb-9 drop-shadow-container bg-white/63 rounded-4xl"
>
<h2 className="text-base font-medium">
{tLogoutModal('Heading')}
</h2>
<p className="text-xs font-medium mt-6">
{tLogoutModal('Description')}
</p>
<div className="grid grid-cols-2 gap-5.5 mt-8">
<Button onClick={onLogout} className="text-sm font-normal">
{tLogoutModal('ButtonOk')}
</Button>
<Button onClick={toggleLogoutModalVisiblity} className="bg-disabled! text-disabled2! text-sm font-normal">
{tLogoutModal('ButtonCancel')}
</Button>
</div>
</motion.div>
</div>
</BlurredOverlayContainer>
{/* Share modal */} {/* Share modal */}
<BlurredOverlayContainer visible={shareModalVisible} onClick={toggleShareModalVisiblity} inDuration={100} outDuration={100} outDelay={75}> <Modal
<div className="absolute top-1/2 left-6 right-6 px-6"> visible={shareModal}
<motion.div onClick={onToggleShareModal}
animate={{ y: shareModalVisible ? [10, 0] : [0, 10] }} >
transition={{ duration: 0.1, ease: 'easeInOut' }} <h2 className="text-base font-medium">
className="top-1/2 absolute left-1/2 min-w-xs w-full max-w-sm text-center -translate-1/2 px-6 pt-8 pb-9 drop-shadow-container bg-white/63 rounded-4xl" <Share className="inline-block ml-2 mb-1.5 stroke-primary" size={24} />
> {tShareModal('Heading')}
<h2 className="text-base font-medium"> </h2>
<Share className="inline-block ml-2 mb-1.5 stroke-primary" size={24} /> <p className="text-xs font-medium mt-6">
{tShareModal('Heading')} {tShareModal('Description')}
</h2> </p>
<p className="text-xs font-medium mt-6">
{tShareModal('Description')}
</p>
<Button onClick={onLogout} className="text-sm mt-8"> <Button onClick={() => { }} className="text-sm mt-8">
<DocumentCopy className="inline-block ml-2 mb-0.5 stroke-white" size={20} /> <DocumentCopy className="inline-block ml-2 mb-0.5 stroke-white" size={20} />
<span className="font-light">{tShareModal('ButtonCopy')}</span> <span className="font-light">{tShareModal('ButtonCopy')}</span>
</Button> </Button>
<div className="grid grid-cols-3 gap-5.5 mt-6 px-12"> <div className="grid grid-cols-3 gap-5.5 mt-6 px-12">
<div className="place-items-center"> <div className="place-items-center">
<TelegramIcon width={24} height={24} className="mb-1.5 stroke-primary" /> <TelegramIcon width={24} height={24} className="mb-1.5 stroke-primary" />
<span className='text-xs2 font-medium'>Telegram</span> <span className='text-xs2 font-medium'>Telegram</span>
</div> </div>
<div className="place-items-center"> <div className="place-items-center">
<Whatsapp size={24} className="mb-1.5 stroke-primary" /> <Whatsapp size={24} className="mb-1.5 stroke-primary" />
<span className='text-xs2 font-medium'>WhatsApp</span> <span className='text-xs2 font-medium'>WhatsApp</span>
</div> </div>
<div className="place-items-center"> <div className="place-items-center">
<Instagram size={24} className="mb-1.5 stroke-primary" /> <Instagram size={24} className="mb-1.5 stroke-primary" />
<span className='text-xs2 font-medium'>Instagram</span> <span className='text-xs2 font-medium'>Instagram</span>
</div> </div>
</div>
</motion.div>
</div> </div>
</BlurredOverlayContainer> </Modal>
</> </>
); );
} }
@@ -14,6 +14,7 @@ export type BlurredOverlayContainerProps = {
outDuration?: number outDuration?: number
children: ReactNode children: ReactNode
noBlur?: boolean noBlur?: boolean
onClick?: React.MouseEventHandler | undefined
} & React.ComponentProps<typeof motion.div> } & React.ComponentProps<typeof motion.div>
const BlurredOverlayContainer = ({ const BlurredOverlayContainer = ({
+32
View File
@@ -0,0 +1,32 @@
import { motion } from 'framer-motion'
import React from 'react'
import BlurredOverlayContainer, { BlurredOverlayContainerProps } from '../overlays/BlurredOverlayContainer'
type Props = {
} & BlurredOverlayContainerProps
function Modal({ visible, onClick, children, ...rest }: Props) {
return (
<BlurredOverlayContainer
visible={visible}
onClick={onClick}
inDuration={100}
outDuration={100}
outDelay={75}
{...rest}
>
<div className="absolute top-1/2 left-6 right-6 px-6">
<motion.div
animate={{ y: visible ? [10, 0] : [0, 10] }}
transition={{ duration: 0.1, ease: 'easeInOut' }}
className="top-1/2 absolute left-1/2 min-w-xs w-full max-w-sm text-center -translate-1/2 px-6 pt-8 pb-9 drop-shadow-container bg-white/63 rounded-4xl"
>
{children}
</motion.div>
</div>
</BlurredOverlayContainer>
)
}
export default Modal
+47
View File
@@ -0,0 +1,47 @@
import { BlurredOverlayContainerProps } from '@/components/overlays/BlurredOverlayContainer'
import Modal from '@/components/utils/Modal'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
export type PromptProps = {
title?: string,
description?: string,
textConfirm?: string,
textCancel?: string,
onConfirm: React.MouseEventHandler | undefined,
onCancel: React.MouseEventHandler | undefined,
children?: React.ReactNode | null
} & Omit<BlurredOverlayContainerProps, 'children'>
function Prompt({ title, description, textConfirm, textCancel, onConfirm, onCancel, visible, onClick, children = null, ...rest }: PromptProps) {
const { t } = useTranslation('common', {
keyPrefix: 'DefaultPrompt'
});
return (
<Modal
visible={visible}
onClick={onClick}
{...rest}
>
<h2 className="text-base font-medium">
{title ?? t('Heading')}
</h2>
<p className="text-xs font-medium mt-6">
{description ?? t('Description')}
</p>
{children}
<div className="grid grid-cols-2 gap-5.5 mt-8">
<Button onClick={onConfirm} className="text-sm h-11 font-normal cursor-pointer">
{textConfirm ?? t('ButtonOk')}
</Button>
<Button onClick={onCancel} className="bg-disabled! h-11 cursor-pointer text-disabled2! active:brightness-95 hover:brightness-105 text-sm font-normal">
{textCancel ?? t('ButtonCancel')}
</Button>
</div>
</Modal>
)
}
export default Prompt
+46
View File
@@ -0,0 +1,46 @@
import Prompt, { PromptProps } from '@/components/utils/Prompt'
import { useAuthStore } from '@/zustand/authStore';
import { useRouter } from 'next/navigation';
import React from 'react'
import { useTranslation } from 'react-i18next'
function LogoutPrompt({ ...rest }: Omit<PromptProps, 'onConfirm' | 'onCancel' | 'children'>) {
const { t } = useTranslation('common', {
keyPrefix: 'LogoutModal'
});
const authLogout = useAuthStore((state) => state.logout);
const router = useRouter();
const userIsAuthenticated = useAuthStore((state) => state.isAuthenticated);
const onConfirmLogout = (e: React.MouseEvent) => {
if (!e) return;
if (userIsAuthenticated) {
authLogout();
}
router.replace('/');
};
const toggleShareModalVisiblity = (e: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
console.log(e);
rest.onClick?.(e);
};
return (
<Prompt
title={t("Heading")}
description={t("Description")}
textConfirm={t("ButtonOk")}
textCancel={t("ButtonCancel")}
onConfirm={onConfirmLogout}
onCancel={toggleShareModalVisiblity}
{...rest}>
<></>
</Prompt>
)
}
export default LogoutPrompt
+17
View File
@@ -0,0 +1,17 @@
import { useState } from 'react'
const useToggle = () => {
const [active, setActive] = useState<boolean>(false);
const onToggle = (e?: React.MouseEvent | null) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setActive((prev) => !prev);
}
return { active, onToggle }
}
export default useToggle
+6
View File
@@ -23,6 +23,12 @@
"NavAriaLabel": "لینک‌های اصلی", "NavAriaLabel": "لینک‌های اصلی",
"ControlsAriaLabel": "پایین منو" "ControlsAriaLabel": "پایین منو"
}, },
"Prompt": {
"Heading": "نیازمند تایید",
"Description": "آیا ادامه میدهید؟",
"ButtonOk": "بله",
"ButtonCancel": "خیر"
},
"LogoutModal": { "LogoutModal": {
"Heading": "خروج از سیستم", "Heading": "خروج از سیستم",
"Description": "آیا میخواهید از سیستم خارج شوید؟", "Description": "آیا میخواهید از سیستم خارج شوید؟",