"use client"; import Image, { type StaticImageData } from "next/image"; import type { HTMLAttributes, ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import Button from "./button"; import { LoadingThreeDot } from "./loading-three-dot"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { viewPaddingsBridge } from "@/lib/view-paddings"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 200; const NON_KEYBOARD_INPUT_TYPES = new Set([ "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit", ]); function isKeyboardInputTarget( target: EventTarget | null, ): target is HTMLElement { if (!target) return false; if (target instanceof HTMLTextAreaElement) { return !target.disabled && !target.readOnly; } if (target instanceof HTMLInputElement) { const type = (target.getAttribute("type") ?? "text").toLowerCase(); return ( !NON_KEYBOARD_INPUT_TYPES.has(type) && !target.disabled && !target.readOnly ); } return target instanceof HTMLElement && target.isContentEditable; } type InformationSheetPresetIcon = | "play" | "warning" | "coin" | "check" | "diamond" | "diamond-color" | "diamond-color.svg" | "diamond-color.png" | "/assets/images/diamond-color.png" | "/assets/images/diamond-color.svg" | "stash_play-solid.svg" | "warning.svg" | "coin.svg" | "Vectorcheck.svg"; type InformationSheetIcon = | InformationSheetPresetIcon | string | { src: string | StaticImageData; alt?: string; width?: number; height?: number; }; export type InformationSheetProps = Omit< HTMLAttributes, "children" | "title" > & { isOpen?: boolean; icon?: InformationSheetIcon | null; title?: ReactNode | ((controls: { close: () => void }) => ReactNode); description?: ReactNode; buttons?: ReactNode | ((controls: { close: () => void }) => ReactNode); closeOnOutside?: boolean; onClose?: () => void; isLoading?: boolean; showCloseButton?: boolean; }; const DEFAULT_ICON = { src: "/assets/images/stash_play-solid.svg", alt: "Play", width: 50, height: 50, } as const; const ICON_PRESETS: Record< InformationSheetPresetIcon, { src: string; alt: string; width: number; height: number; } > = { play: DEFAULT_ICON, "stash_play-solid.svg": DEFAULT_ICON, warning: { src: "/assets/images/Vector.svg", alt: "Warning", width: 36, height: 36, }, "warning.svg": { src: "/assets/images/Vector.svg", alt: "Warning", width: 36, height: 36, }, coin: { src: "/assets/images/Inner Plugdsain Iframe.svg", alt: "Coin", width: 56, height: 56, }, "coin.svg": { src: "/assets/images/Inner Plugdsain Iframe.svg", alt: "Coin", width: 56, height: 56, }, check: { src: "/assets/images/Vectofdasr.svg", alt: "Check", width: 36, height: 36, }, "Vectorcheck.svg": { src: "/assets/images/Vectorcheck.svg", alt: "Check", width: 36, height: 36, }, diamond: { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, "diamond-color": { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, "diamond-color.svg": { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, "diamond-color.png": { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, "/assets/images/diamond-color.png": { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, "/assets/images/diamond-color.svg": { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }, }; function resolveIcon(icon: InformationSheetIcon | null | undefined) { if (icon === null) { return null; } if (!icon) { return DEFAULT_ICON; } if (typeof icon === "string") { // If diamond PNG was requested, automatically upgrade to high-res vector SVG if (icon.includes("diamond-color")) { return { src: "/assets/images/diamond-color.svg", alt: "Subscription", width: 48, height: 48, }; } return ( ICON_PRESETS[icon as InformationSheetPresetIcon] ?? { src: icon, alt: "Information", width: DEFAULT_ICON.width, height: DEFAULT_ICON.height, } ); } return { src: icon.src, alt: icon.alt ?? "Information", width: icon.width ?? DEFAULT_ICON.width, height: icon.height ?? DEFAULT_ICON.height, }; } export function InformationSheet({ isOpen = true, icon, title, description, buttons, closeOnOutside = true, onClose, className, isLoading = false, showCloseButton = true, ...props }: InformationSheetProps) { const { locale, dictionary: t } = useI18n(); const isRtl = locale === "fa" || locale === "ar" || locale === "ur" || locale === "he" || locale === "ks"; const [mounted, setMounted] = useState(false); const [isRendered, setIsRendered] = useState(isOpen); const [isClosing, setIsClosing] = useState(false); const isClosingRef = useRef(false); const isMountedRef = useRef(true); const prevIsOpenRef = useRef(isOpen); const timerRef = useRef(null); useEffect(() => { isMountedRef.current = true; setMounted(true); return () => { isMountedRef.current = false; if (timerRef.current) clearTimeout(timerRef.current); }; }, []); const closeSheet = useCallback(() => { if (isClosingRef.current) { return; } isClosingRef.current = true; setIsClosing(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => { if (isMountedRef.current) { setIsRendered(false); setIsClosing(false); isClosingRef.current = false; } onClose?.(); }, EXIT_ANIMATION_MS); }, [onClose]); // Synchronize external isOpen prop changes safely without bounce useEffect(() => { if (prevIsOpenRef.current !== isOpen) { prevIsOpenRef.current = isOpen; if (isOpen) { if (timerRef.current) clearTimeout(timerRef.current); isClosingRef.current = false; setIsClosing(false); setIsRendered(true); } else if (!isClosingRef.current && isRendered) { closeSheet(); } } }, [isOpen, isRendered, closeSheet]); const resolvedIcon = resolveIcon(icon); useHardwareBackHandler(() => { closeSheet(); return true; }, isRendered && !isClosing); const controls = { close: closeSheet }; const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedButtons = typeof buttons === "string" ? ( ) : typeof buttons === "function" ? ( buttons(controls) ) : ( buttons ); useEffect(() => { if (!isRendered) { return; } const previousBodyOverflow = document.body.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow; document.body.style.overflow = "hidden"; document.documentElement.style.overflow = "hidden"; return () => { document.body.style.overflow = previousBodyOverflow; document.documentElement.style.overflow = previousHtmlOverflow; }; }, [isRendered]); const sheetRef = useRef(null); const [keyboardLift, setKeyboardLift] = useState(0); useEffect(() => { if (!isRendered) { return; } const KEYBOARD_THRESHOLD = 20; let keyboardVisible = false; let keyboardHeight = 0; let lastKeyboardHeight = 0; let activeInput: HTMLElement | null = null; let closedVpHeight = window.visualViewport?.height ?? window.innerHeight; const getDefaultKeyboardHeight = (): number => { if (typeof window !== "undefined" && window.innerHeight > 0) { return Math.round(window.innerHeight * 0.375); } return 285; }; const setLift = (nextLift: number) => { const lift = Math.max(0, Math.round(nextLift)); setKeyboardLift(lift); }; const handleFocusIn = (e: FocusEvent) => { if ( sheetRef.current?.contains(e.target as Node) && isKeyboardInputTarget(e.target) ) { activeInput = e.target; keyboardVisible = true; const expectedHeight = lastKeyboardHeight > 200 ? lastKeyboardHeight : getDefaultKeyboardHeight(); keyboardHeight = Math.max(keyboardHeight, expectedHeight); setLift(keyboardHeight); } }; const handleFocusOut = () => { window.setTimeout(() => { const active = document.activeElement; activeInput = sheetRef.current?.contains(active) && isKeyboardInputTarget(active) ? (active as HTMLElement) : null; if (!activeInput) { keyboardVisible = false; keyboardHeight = 0; setLift(0); } }, 50); }; const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { const nextHeight = Math.max(0, config.keyboardHeight); if (nextHeight > 0) { keyboardVisible = true; keyboardHeight = nextHeight; lastKeyboardHeight = Math.max(lastKeyboardHeight, nextHeight); setLift(nextHeight); } else { keyboardHeight = 0; keyboardVisible = false; setLift(0); } }); const handleViewportResize = () => { const vpHeight = window.visualViewport?.height ?? window.innerHeight; const reduction = closedVpHeight - vpHeight; if (reduction > KEYBOARD_THRESHOLD) { if (activeInput) { keyboardVisible = true; keyboardHeight = reduction; lastKeyboardHeight = Math.max(lastKeyboardHeight, reduction); setLift(reduction); } } else if (reduction <= KEYBOARD_THRESHOLD) { keyboardVisible = false; keyboardHeight = 0; setLift(0); if (!activeInput) { closedVpHeight = vpHeight; } } }; document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleFocusOut); window.visualViewport?.addEventListener("resize", handleViewportResize); return () => { document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); window.visualViewport?.removeEventListener( "resize", handleViewportResize, ); unsubscribeConfig(); }; }, [isRendered]); const isBackdropPointerDownRef = useRef(false); if (!isRendered || !mounted) { return null; } const content = (
{ isBackdropPointerDownRef.current = event.target === event.currentTarget; }} onClick={(event) => { if ( closeOnOutside && isBackdropPointerDownRef.current && event.target === event.currentTarget ) { console.log("[InformationSheet] Backdrop clicked -> closeSheet"); closeSheet(); } isBackdropPointerDownRef.current = false; }} onKeyDown={(event) => { if ( closeOnOutside && event.target === event.currentTarget && event.key === "Escape" ) { event.preventDefault(); closeSheet(); } }} >
0 ? `calc(max(24px, calc(24px + var(--safe-bottom))) + ${keyboardLift}px)` : undefined, transition: "padding-bottom 200ms cubic-bezier(0.16, 1, 0.3, 1)", maxHeight: "calc(100dvh - var(--safe-top) - 16px)", ...props.style, }} className={[ "relative w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] overflow-y-auto", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface", className, ] .filter(Boolean) .join(" ")} > {showCloseButton && ( )}
{isLoading ? (
) : ( <> {resolvedIcon ? ( {resolvedIcon.alt} ) : null}

{resolvedTitle}

{description ? (
{description}
) : null} {resolvedButtons ? (
{resolvedButtons}
) : null} )}
); return createPortal(content, document.body); } export default InformationSheet;