Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import { Modal as HeroModal } from '@heroui/react'
|
|
import React, { type PropsWithChildren } from 'react'
|
|
|
|
import Button from '@/components/formElements/Button'
|
|
import { useMediaQuery } from '@/hooks/useMediaQuery'
|
|
import { cn } from '@/lib/cn'
|
|
import { texts } from '@/texts'
|
|
|
|
interface ModalProps {
|
|
isOpen: boolean
|
|
onOpenChange: (isOpen: boolean) => void
|
|
onClose?: () => void
|
|
onAccept?: () => void
|
|
onReject?: () => void
|
|
isLoading?: boolean
|
|
isDismissable?: boolean
|
|
title?: string
|
|
acceptBtnText?: string
|
|
rejectBtnText?: string
|
|
acceptBtnDisabled?: boolean
|
|
rejectBtnDisabled?: boolean
|
|
hideCloseButton?: boolean
|
|
hideHeader?: boolean
|
|
hideFooter?: boolean
|
|
className?: string
|
|
containerClassName?: string
|
|
bodyClassName?: string
|
|
headerClassName?: string
|
|
footerClassName?: string
|
|
headerChildren?: React.ReactNode
|
|
footerChildren?: React.ReactNode
|
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full'
|
|
radius?: 'none' | 'sm' | 'md' | 'lg'
|
|
shadow?: undefined | 'sm' | 'md' | 'lg'
|
|
/** `auto` (default): bottom sheet on mobile, centered on desktop. */
|
|
placement?: 'auto' | 'top' | 'center' | 'bottom'
|
|
scrollBehavior?: 'normal' | 'inside' | 'outside'
|
|
backdrop?: 'transparent' | 'opaque' | 'blur'
|
|
acceptDanger?: boolean
|
|
}
|
|
|
|
const Modal = ({
|
|
isOpen,
|
|
onOpenChange,
|
|
onClose,
|
|
onAccept,
|
|
onReject,
|
|
isLoading = false,
|
|
isDismissable = true,
|
|
title = '',
|
|
children,
|
|
acceptBtnText = texts.common.confirmPlain,
|
|
rejectBtnText = texts.common.close,
|
|
acceptBtnDisabled = false,
|
|
rejectBtnDisabled = false,
|
|
hideCloseButton = false,
|
|
hideHeader = false,
|
|
hideFooter = false,
|
|
className = '',
|
|
containerClassName = '',
|
|
bodyClassName = '',
|
|
headerClassName = '',
|
|
footerClassName = '',
|
|
headerChildren,
|
|
footerChildren,
|
|
size = 'md',
|
|
radius = 'md',
|
|
shadow = 'lg',
|
|
placement = 'auto',
|
|
scrollBehavior = 'inside',
|
|
backdrop = 'opaque',
|
|
acceptDanger = false,
|
|
}: PropsWithChildren<ModalProps>) => {
|
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
|
const resolvedPlacement = placement === 'auto' ? (isMobile ? 'bottom' : 'center') : placement
|
|
const isBottomSheet = resolvedPlacement === 'bottom' && isMobile
|
|
|
|
const getMaxWidth = (modalSize: string): string => {
|
|
const sizeMap: Record<string, string> = {
|
|
xs: 'max-w-[320px]',
|
|
sm: 'max-w-[384px]',
|
|
md: 'max-w-[448px]',
|
|
lg: 'max-w-[512px]',
|
|
xl: 'max-w-[640px]',
|
|
'2xl': 'max-w-[768px]',
|
|
'3xl': 'max-w-[896px]',
|
|
'4xl': 'max-w-[1024px]',
|
|
'5xl': 'max-w-[1280px]',
|
|
full: 'max-w-full',
|
|
}
|
|
|
|
return sizeMap[modalSize] || 'max-w-full'
|
|
}
|
|
|
|
const closeHandler = () => {
|
|
onOpenChange(false)
|
|
onClose?.()
|
|
}
|
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
onOpenChange(nextOpen)
|
|
if (!nextOpen) onClose?.()
|
|
}
|
|
const normalizedSize = size === 'full' ? 'full' : ['xs', 'sm', 'md', 'lg'].includes(size) ? (size as 'xs' | 'sm' | 'md' | 'lg') : 'lg'
|
|
const radiusClass = radius === 'none' ? 'rounded-none' : radius === 'sm' ? 'rounded-lg' : radius === 'lg' ? 'rounded-3xl' : 'rounded-2xl'
|
|
const shadowClass = shadow === 'sm' ? 'shadow-sm' : shadow === 'md' ? 'shadow-md' : shadow === 'lg' ? 'shadow-lg' : ''
|
|
|
|
return (
|
|
<HeroModal
|
|
isOpen={isOpen}
|
|
onOpenChange={handleOpenChange}
|
|
>
|
|
<HeroModal.Backdrop
|
|
className="z-[60]"
|
|
isDismissable={!isLoading && isDismissable}
|
|
variant={backdrop}
|
|
>
|
|
<HeroModal.Container
|
|
className={cn(size === 'full' && 'p-0', isBottomSheet && 'p-0', containerClassName)}
|
|
placement={resolvedPlacement}
|
|
scroll={scrollBehavior === 'outside' ? 'outside' : 'inside'}
|
|
size={normalizedSize}
|
|
>
|
|
<HeroModal.Dialog
|
|
aria-label={hideHeader ? title || texts.common.dialogAria : undefined}
|
|
className={cn(
|
|
!isBottomSheet && getMaxWidth(size),
|
|
// Cap to the overlay (already `--visual-viewport-height`), not 90dvh —
|
|
// iOS `dvh` does not shrink with the keyboard.
|
|
'max-h-full border border-secondary-40 bg-white p-4 text-right sm:p-6',
|
|
// Reinforce HeroUI placement (callers must not pass `m-0` / `!m-0`).
|
|
resolvedPlacement === 'bottom' && 'mt-auto',
|
|
resolvedPlacement === 'center' && 'my-auto',
|
|
resolvedPlacement === 'top' && 'mt-0',
|
|
// Do not add `mb-[var(--keyboard-inset)]`: HeroUI's overlay is already
|
|
// sized to the visual viewport, so a keyboard margin double-counts and
|
|
// collapses the sheet into the header/footer.
|
|
isBottomSheet
|
|
? 'mx-0 w-full max-w-none rounded-b-none rounded-t-3xl border-b-0 pb-[max(1rem,env(safe-area-inset-bottom))]'
|
|
: radiusClass,
|
|
shadowClass,
|
|
className
|
|
)}
|
|
>
|
|
{!hideCloseButton && !isLoading ? <HeroModal.CloseTrigger className="absolute end-4 top-4 sm:end-6 sm:top-6" /> : null}
|
|
{!hideHeader && (
|
|
<HeroModal.Header
|
|
className={cn('mb-4 flex items-center p-0 pb-4 text-right text-md font-bold text-secondary-10', headerClassName)}
|
|
>
|
|
{headerChildren}
|
|
<HeroModal.Heading>{title}</HeroModal.Heading>
|
|
</HeroModal.Header>
|
|
)}
|
|
<HeroModal.Body className={cn('overflow-y-auto overflow-x-clip p-1 -m-1 text-right text-text-main', bodyClassName)}>
|
|
{children}
|
|
</HeroModal.Body>
|
|
{!hideFooter && (
|
|
<HeroModal.Footer className={cn('mt-4 flex justify-start gap-2 p-0', footerClassName)}>
|
|
{footerChildren ?? (
|
|
<>
|
|
<Button
|
|
className="w-full"
|
|
color="warning"
|
|
disabled={rejectBtnDisabled || isLoading}
|
|
isLoading={isLoading}
|
|
variant="flat"
|
|
onClick={onReject ?? closeHandler}
|
|
>
|
|
{rejectBtnText}
|
|
</Button>
|
|
<Button
|
|
className={acceptDanger ? 'w-full bg-fourth/10 text-fourth' : 'w-full'}
|
|
color={acceptDanger ? 'danger' : 'primary'}
|
|
disabled={acceptBtnDisabled || isLoading}
|
|
isLoading={isLoading}
|
|
variant={acceptDanger ? 'flat' : 'solid'}
|
|
onClick={onAccept}
|
|
>
|
|
{acceptBtnText}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</HeroModal.Footer>
|
|
)}
|
|
</HeroModal.Dialog>
|
|
</HeroModal.Container>
|
|
</HeroModal.Backdrop>
|
|
</HeroModal>
|
|
)
|
|
}
|
|
|
|
export default Modal
|