Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* ConsumerModal — the ONLY overlay primitive allowed in the consumer app.
|
|
*
|
|
* Purpose
|
|
* - Bottom-sheet dialog for confirmations, forms, and flow steps under
|
|
* `app/(consumer)/**` and shared consumer surfaces (auth/PWA/event/review).
|
|
* - Wraps HeroUI Modal with قبیله tokens (`rounded-consumer-modal`, safe-area padding,
|
|
* RTL body) and a default dual-action footer via `ConsumerActionButtons`.
|
|
*
|
|
* When to use
|
|
* - Any modal, sheet, or dialog shown to end users in the consumer shell.
|
|
*
|
|
* When NOT to use
|
|
* - Admin dashboard → `components/modals/Modal` (never this file).
|
|
* - Do not invent parallel overlays (HeroUI Drawer, ad-hoc fixed banners, admin Modal).
|
|
*
|
|
* Agent constraints
|
|
* - Cursor rules: `consumer-modals`, `consumer-mobile-first`.
|
|
* - Always bottom sheet (every viewport). No centered / top / auto placement.
|
|
* - The sheet slides up from the bottom when opening and back down when closing.
|
|
* - Close button is hidden by default (`hideCloseButton`); rely on cancel / backdrop.
|
|
* - Browser/Android Back dismisses the sheet via `useCloseOnHistoryBack` (same URL;
|
|
* nested sheets stack). Opt out with `closeOnHistoryBack={false}`; Back is trapped
|
|
* while `!isDismissable` or busy (`isLoading` / `isSubmitting` / `isCancelLoading`).
|
|
* - Legacy prop aliases (`accept*` / `reject*` / `onAccept` / `onReject`) exist for
|
|
* migrations — prefer `submit*` / `cancel*` / `onSubmit` / `onCancel` in new code.
|
|
* - Persian copy via `@/texts`, never hardcoded.
|
|
*
|
|
* Related
|
|
* - Footer buttons: `ConsumerActionButtons`
|
|
* - Sticky page CTAs that replace BottomNav: `ConsumerFormActionBar` (not a modal)
|
|
* - History Back: `hooks/useCloseOnHistoryBack`
|
|
* - Guidelines: `frontend/docs/consumer-ui-guidelines.md`
|
|
*/
|
|
|
|
import { type FocusEvent, type PropsWithChildren, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { Modal as HeroModal } from '@heroui/react'
|
|
|
|
import ConsumerActionButtons from '@/components/consumer/ConsumerActionButtons'
|
|
import { useCloseOnHistoryBack } from '@/hooks/useCloseOnHistoryBack'
|
|
import { cn } from '@/lib/cn'
|
|
import { scrollElementIntoContainer } from '@/lib/scrollElementIntoContainer'
|
|
import { texts } from '@/texts'
|
|
|
|
type ConsumerModalSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full'
|
|
|
|
/** Keep close callbacks aligned with the CSS exit animation. */
|
|
const EXIT_DURATION_MS = 220
|
|
|
|
export interface ConsumerModalProps {
|
|
/** Compatibility aliases for consumer flows migrated from the legacy Modal. */
|
|
acceptDanger?: boolean
|
|
acceptBtnDisabled?: boolean
|
|
acceptBtnText?: string
|
|
backdrop?: 'transparent' | 'opaque' | 'blur'
|
|
bodyClassName?: string
|
|
cancelDisabled?: boolean
|
|
cancelIcon?: ReactNode
|
|
cancelLabel?: string
|
|
className?: string
|
|
/**
|
|
* When true (default), opening the sheet pushes a history entry so browser/Android
|
|
* Back closes it without leaving the page. Nested modals stack correctly.
|
|
*/
|
|
closeOnHistoryBack?: boolean
|
|
containerClassName?: string
|
|
footerChildren?: ReactNode
|
|
hideCloseButton?: boolean
|
|
hideFooter?: boolean
|
|
hideHeader?: boolean
|
|
isLoading?: boolean
|
|
isCancelLoading?: boolean
|
|
isDismissable?: boolean
|
|
isOpen: boolean
|
|
isSubmitting?: boolean
|
|
onAccept?: () => void
|
|
onCancel?: () => void
|
|
onClose?: () => void
|
|
onOpenChange: (isOpen: boolean) => void
|
|
onReject?: () => void
|
|
onSubmit?: () => void
|
|
rejectBtnDisabled?: boolean
|
|
rejectBtnText?: string
|
|
radius?: 'none' | 'sm' | 'md' | 'lg'
|
|
size?: ConsumerModalSize
|
|
/**
|
|
* Stacking tier for nested sheets (auth profile → city/birthdate pickers).
|
|
* Level 0 = z-60 (default). Each step adds 10 so the child sits above its parent.
|
|
*/
|
|
stackLevel?: 0 | 1 | 2
|
|
submitDisabled?: boolean
|
|
submitIcon?: ReactNode
|
|
submitLabel?: string
|
|
title?: string
|
|
titleIcon?: ReactNode
|
|
}
|
|
|
|
export default function ConsumerModal({
|
|
acceptDanger = false,
|
|
acceptBtnDisabled,
|
|
acceptBtnText,
|
|
backdrop = 'opaque',
|
|
bodyClassName,
|
|
cancelDisabled = false,
|
|
cancelIcon,
|
|
cancelLabel = texts.common.cancel,
|
|
children,
|
|
className,
|
|
closeOnHistoryBack = true,
|
|
containerClassName,
|
|
footerChildren,
|
|
hideCloseButton = true,
|
|
hideFooter = false,
|
|
hideHeader = false,
|
|
isLoading = false,
|
|
isCancelLoading = false,
|
|
isDismissable = true,
|
|
isOpen,
|
|
isSubmitting = false,
|
|
onAccept,
|
|
onCancel,
|
|
onClose,
|
|
onOpenChange,
|
|
onReject,
|
|
onSubmit,
|
|
radius = 'lg',
|
|
rejectBtnDisabled,
|
|
rejectBtnText,
|
|
submitDisabled = false,
|
|
submitIcon,
|
|
submitLabel = texts.common.select,
|
|
size = 'md',
|
|
stackLevel = 0,
|
|
title = '',
|
|
titleIcon,
|
|
}: PropsWithChildren<ConsumerModalProps>) {
|
|
const [renderedOpen, setRenderedOpen] = useState(isOpen)
|
|
const [contentSnapshot, setContentSnapshot] = useState({ children, footerChildren, title, titleIcon })
|
|
const closeRequestedRef = useRef(false)
|
|
const closeFallbackRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
if (
|
|
isOpen &&
|
|
(contentSnapshot.children !== children ||
|
|
contentSnapshot.footerChildren !== footerChildren ||
|
|
contentSnapshot.title !== title ||
|
|
contentSnapshot.titleIcon !== titleIcon)
|
|
) {
|
|
setContentSnapshot({ children, footerChildren, title, titleIcon })
|
|
}
|
|
|
|
const visualContent = isOpen ? { children, footerChildren, title, titleIcon } : contentSnapshot
|
|
const resolvedSubmitting = isSubmitting || isLoading
|
|
const resolvedCancelLabel = rejectBtnText ?? cancelLabel
|
|
const resolvedSubmitLabel = acceptBtnText ?? submitLabel
|
|
const resolvedSubmitDisabled = acceptBtnDisabled ?? submitDisabled
|
|
const isBusy = resolvedSubmitting || isCancelLoading
|
|
const normalizedSize: 'xs' | 'sm' | 'md' | 'lg' | 'full' =
|
|
size === 'full' ? 'full' : size === 'xs' || size === 'sm' || size === 'md' || size === 'lg' ? size : 'lg'
|
|
const appMaxWidth = 'var(--consumer-app-max-width)'
|
|
const maxWidthClass = {
|
|
xs: `max-w-[min(320px,${appMaxWidth})]`,
|
|
sm: `max-w-[min(384px,${appMaxWidth})]`,
|
|
md: `max-w-[${appMaxWidth}]`,
|
|
lg: `max-w-[min(512px,${appMaxWidth})]`,
|
|
xl: `max-w-[min(640px,${appMaxWidth})]`,
|
|
'2xl': `max-w-[min(768px,${appMaxWidth})]`,
|
|
'3xl': `max-w-[min(896px,${appMaxWidth})]`,
|
|
'4xl': `max-w-[min(1024px,${appMaxWidth})]`,
|
|
'5xl': `max-w-[min(1280px,${appMaxWidth})]`,
|
|
full: `max-w-[${appMaxWidth}]`,
|
|
}[size]
|
|
const topRadiusClass =
|
|
radius === 'none'
|
|
? 'rounded-t-none'
|
|
: radius === 'sm'
|
|
? 'rounded-t-consumer-control'
|
|
: radius === 'md'
|
|
? 'rounded-t-consumer-card'
|
|
: 'rounded-t-consumer-modal'
|
|
|
|
const stackZClass = stackLevel === 2 ? 'z-[80]' : stackLevel === 1 ? 'z-[70]' : 'z-[60]'
|
|
|
|
const completeRequestedClose = useCallback(() => {
|
|
if (!closeRequestedRef.current) return
|
|
|
|
closeRequestedRef.current = false
|
|
if (closeFallbackRef.current) {
|
|
clearTimeout(closeFallbackRef.current)
|
|
closeFallbackRef.current = null
|
|
}
|
|
|
|
onOpenChange(false)
|
|
onCancel?.()
|
|
onReject?.()
|
|
onClose?.()
|
|
}, [onCancel, onClose, onOpenChange, onReject])
|
|
|
|
const requestClose = useCallback(() => {
|
|
if (closeRequestedRef.current) return
|
|
|
|
closeRequestedRef.current = true
|
|
setRenderedOpen(false)
|
|
}, [])
|
|
|
|
useCloseOnHistoryBack({
|
|
// Drop the history entry as soon as the exit animation starts so Back mid-close
|
|
// does not leave the page or dismiss a parent sheet.
|
|
isOpen: isOpen && renderedOpen,
|
|
enabled: closeOnHistoryBack,
|
|
allowClose: isDismissable && !isBusy,
|
|
onRequestClose: requestClose,
|
|
})
|
|
|
|
useLayoutEffect(() => {
|
|
if (isOpen) {
|
|
closeRequestedRef.current = false
|
|
setRenderedOpen(true)
|
|
|
|
return
|
|
}
|
|
|
|
setRenderedOpen(false)
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (renderedOpen || !closeRequestedRef.current) return
|
|
|
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
|
|
|
closeFallbackRef.current = setTimeout(completeRequestedClose, reduceMotion ? 0 : EXIT_DURATION_MS)
|
|
|
|
return () => {
|
|
if (closeFallbackRef.current) {
|
|
clearTimeout(closeFallbackRef.current)
|
|
closeFallbackRef.current = null
|
|
}
|
|
}
|
|
}, [completeRequestedClose, renderedOpen])
|
|
|
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
if (!nextOpen) {
|
|
requestClose()
|
|
|
|
return
|
|
}
|
|
|
|
onOpenChange(true)
|
|
}
|
|
|
|
/**
|
|
* After the keyboard settles, keep the focused control visible inside the
|
|
* modal body only. Do not call `scrollIntoView` — on mobile it can shift
|
|
* `visualViewport.offsetTop` and open a hole through the fixed backdrop.
|
|
*/
|
|
const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => {
|
|
const target = event.target
|
|
|
|
if (!(target instanceof HTMLElement) || !target.matches('input, textarea, select, [contenteditable="true"]')) return
|
|
|
|
const body = event.currentTarget
|
|
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(() => {
|
|
scrollElementIntoContainer(body, target)
|
|
})
|
|
})
|
|
}
|
|
|
|
return (
|
|
<HeroModal
|
|
isOpen={renderedOpen}
|
|
onOpenChange={handleOpenChange}
|
|
>
|
|
<HeroModal.Backdrop
|
|
className={cn('consumer-modal-backdrop', stackZClass)}
|
|
isDismissable={!isBusy && isDismissable}
|
|
variant={backdrop}
|
|
>
|
|
<HeroModal.Container
|
|
className={cn('consumer-modal-container mx-auto w-full max-w-[var(--consumer-app-max-width)] p-0', containerClassName)}
|
|
placement="bottom"
|
|
scroll="inside"
|
|
size={normalizedSize}
|
|
>
|
|
<HeroModal.Dialog
|
|
aria-label={hideHeader ? visualContent.title || texts.common.dialogAria : undefined}
|
|
className={cn(
|
|
'consumer-overlay mx-auto mt-auto flex max-h-full w-full flex-col gap-4 overflow-hidden rounded-b-none border-0 bg-consumer-surface p-5 pb-[max(20px,env(safe-area-inset-bottom))] text-right',
|
|
maxWidthClass,
|
|
topRadiusClass,
|
|
className
|
|
)}
|
|
>
|
|
{!hideCloseButton && !isBusy ? <HeroModal.CloseTrigger className="absolute end-4 top-4" /> : null}
|
|
{!hideHeader ? (
|
|
<HeroModal.Header className="!flex-row flex w-full items-center justify-center gap-2 rounded-consumer-card bg-[#E2E2E24D] px-4 py-2 text-base font-medium text-tertiary-900">
|
|
{visualContent.titleIcon}
|
|
<HeroModal.Heading>{visualContent.title}</HeroModal.Heading>
|
|
</HeroModal.Header>
|
|
) : null}
|
|
|
|
<HeroModal.Body
|
|
// flex-1 + min-h-0 تا body داخل max-h شیت جمع شود و overflow-y واقعاً اسکرول کند
|
|
// (flex-none باعث میشد body به ارتفاع محتوا برسد و اسکرول بیاثر بماند)
|
|
className={cn('m-0 min-h-0 min-w-0 w-full flex-1 overflow-y-auto p-0 text-right text-text-main', bodyClassName)}
|
|
onFocusCapture={handleFocusCapture}
|
|
>
|
|
{visualContent.children}
|
|
</HeroModal.Body>
|
|
|
|
{!hideFooter ? (
|
|
<HeroModal.Footer className="w-full flex-none p-0">
|
|
{visualContent.footerChildren ?? (
|
|
<ConsumerActionButtons
|
|
cancelDisabled={cancelDisabled || rejectBtnDisabled || resolvedSubmitting}
|
|
cancelIcon={cancelIcon}
|
|
cancelLabel={resolvedCancelLabel}
|
|
isCancelLoading={isCancelLoading}
|
|
isPrimaryLoading={resolvedSubmitting}
|
|
primaryDanger={acceptDanger}
|
|
primaryDisabled={resolvedSubmitDisabled || isCancelLoading}
|
|
primaryIcon={submitIcon}
|
|
primaryLabel={resolvedSubmitLabel}
|
|
primaryType="button"
|
|
onCancel={requestClose}
|
|
onPrimary={onSubmit ?? onAccept}
|
|
/>
|
|
)}
|
|
</HeroModal.Footer>
|
|
) : null}
|
|
</HeroModal.Dialog>
|
|
</HeroModal.Container>
|
|
</HeroModal.Backdrop>
|
|
</HeroModal>
|
|
)
|
|
}
|