admin/components/consumer/ConsumerModal.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

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>
)
}