'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) { const [renderedOpen, setRenderedOpen] = useState(isOpen) const [contentSnapshot, setContentSnapshot] = useState({ children, footerChildren, title, titleIcon }) const closeRequestedRef = useRef(false) const closeFallbackRef = useRef | 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) => { 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 ( {!hideCloseButton && !isBusy ? : null} {!hideHeader ? ( {visualContent.titleIcon} {visualContent.title} ) : null} {visualContent.children} {!hideFooter ? ( {visualContent.footerChildren ?? ( )} ) : null} ) }