You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
564 lines
15 KiB
564 lines
15 KiB
"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<HTMLDivElement>,
|
|
"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<number | null>(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" ? (
|
|
<Button onClick={closeSheet}>{buttons}</Button>
|
|
) : 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<HTMLElement>(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 = (
|
|
<div
|
|
className={[
|
|
"fixed inset-0 z-50 flex items-end justify-center overflow-hidden",
|
|
isClosing ? "flutter-scrim-exit" : "flutter-scrim",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t["Information sheet"]}
|
|
tabIndex={-1}
|
|
dir={isRtl ? "rtl" : "ltr"}
|
|
onPointerDown={(event) => {
|
|
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();
|
|
}
|
|
}}
|
|
>
|
|
<section
|
|
ref={sheetRef}
|
|
{...props}
|
|
style={{
|
|
paddingBottom:
|
|
keyboardLift > 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 && (
|
|
<button
|
|
type="button"
|
|
onClick={closeSheet}
|
|
className="absolute top-3.5 end-3.5 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#9CA3AF] hover:bg-black/5 hover:text-[#4B5563] active:scale-95 transition-all cursor-pointer border-none bg-transparent"
|
|
aria-label={(t as any)?.["Close"] || "Close"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
<div className="mx-auto flex flex-col items-center w-full">
|
|
{isLoading ? (
|
|
<div className="flex h-[240px] w-full items-center justify-center animate-in fade-in duration-200">
|
|
<LoadingThreeDot className="text-[#F0445B]" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{resolvedIcon ? (
|
|
<Image
|
|
src={resolvedIcon.src}
|
|
alt={resolvedIcon.alt}
|
|
width={resolvedIcon.width}
|
|
height={resolvedIcon.height}
|
|
priority
|
|
/>
|
|
) : null}
|
|
|
|
<h2
|
|
className={[
|
|
resolvedIcon ? "mt-2.5" : "mt-0",
|
|
"w-full group-16 leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]",
|
|
].join(" ")}
|
|
>
|
|
{resolvedTitle}
|
|
</h2>
|
|
|
|
{description ? (
|
|
<div className="mt-3 group-12 text-[#4D4D4D]">
|
|
{description}
|
|
</div>
|
|
) : null}
|
|
|
|
{resolvedButtons ? (
|
|
<div className="mt-4 flex w-full flex-col gap-3">
|
|
{resolvedButtons}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
|
|
return createPortal(content, document.body);
|
|
}
|
|
|
|
export default InformationSheet;
|