UNPKG

@ieltsrealtest/ui

Version:

Reusable UI components for IELTS Real Test platform, built with React and TypeScript.

175 lines (174 loc) 10.1 kB
"use client"; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FaBell } from "react-icons/fa"; const DEFAULT_VISIBLE_BATCH = 5; const NotificationsDropdown = ({ count = 0, items = [], isDev = false, }) => { const { USER_API_BASE_URL } = useMemo(() => { if (isDev) { return { USER_API_BASE_URL: "https://devapi.youready.net/ielts/user/api/", }; } return { USER_API_BASE_URL: "https://api.youready.net/ielts/user/api/", }; }, [isDev]); const [open, setOpen] = useState(false); const [visibleCount, setVisibleCount] = useState(DEFAULT_VISIBLE_BATCH); const containerRef = useRef(null); const isMountedRef = useRef(true); // Internal state when items aren't provided const [list, setList] = useState([]); const [loaded, setLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); useEffect(() => { return () => { isMountedRef.current = false; }; }, []); const baseData = useMemo(() => (items.length ? items : list), [items, list]); const unreadCount = useMemo(() => baseData.filter((n) => !n.read).length, [baseData]); const displayCount = count && count > 0 ? count : unreadCount; const sortedData = useMemo(() => { const getTimestamp = (value) => { if (!value) return 0; const time = Date.parse(value); return Number.isNaN(time) ? 0 : time; }; return baseData .map((item, index) => ({ item, index, isRead: Boolean(item.read), timestamp: getTimestamp(item.created_at), })) .sort((a, b) => { if (a.isRead !== b.isRead) { return a.isRead ? 1 : -1; } if (a.timestamp !== b.timestamp) { return b.timestamp - a.timestamp; } return a.index - b.index; }) .map(({ item }) => item); }, [baseData]); const visibleData = useMemo(() => sortedData.slice(0, visibleCount), [sortedData, visibleCount]); const hasMore = sortedData.length > visibleCount; useEffect(() => { const handlePointerDown = (event) => { if (containerRef.current && !containerRef.current.contains(event.target)) { setOpen(false); } }; const handleEsc = (event) => { if (event.key === "Escape") setOpen(false); }; const pointerEventsSupported = typeof window !== "undefined" && "onpointerdown" in window; const listener = handlePointerDown; if (pointerEventsSupported) { document.addEventListener("pointerdown", listener); } else { document.addEventListener("mousedown", listener); document.addEventListener("touchstart", listener); } document.addEventListener("keydown", handleEsc); return () => { if (pointerEventsSupported) { document.removeEventListener("pointerdown", listener); } else { document.removeEventListener("mousedown", listener); document.removeEventListener("touchstart", listener); } document.removeEventListener("keydown", handleEsc); }; }, []); const fetchNotifications = useCallback(async () => { if (items.length) return; setIsLoading(true); setHasError(false); try { const res = await fetch(`${USER_API_BASE_URL}/notifications/`, { method: "GET", credentials: "include", headers: { "Content-Type": "application/json" }, }); const data = await res.json(); if (!isMountedRef.current) return; const notifications = Array.isArray(data?.notifications) ? data.notifications : []; setList(notifications); setVisibleCount(DEFAULT_VISIBLE_BATCH); } catch { if (!isMountedRef.current) return; setList([]); setHasError(true); } finally { if (!isMountedRef.current) return; setIsLoading(false); setLoaded(true); } }, [items.length]); useEffect(() => { if (loaded || isLoading || items.length) return; void fetchNotifications(); }, [loaded, isLoading, items.length, fetchNotifications]); useEffect(() => { if (open) { setVisibleCount(DEFAULT_VISIBLE_BATCH); } }, [open]); const handleLoadMore = () => { setVisibleCount((prev) => prev + DEFAULT_VISIBLE_BATCH); }; const handleRetry = () => { if (!isLoading) { void fetchNotifications(); } }; const markRead = async (id) => { setList((prev) => prev.map((n) => (n._id === id ? { ...n, read: true } : n))); try { await fetch(`${USER_API_BASE_URL}/notifications/`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ noti_id: id }), }); } catch { // ignore network errors in UI } }; const markAllRead = async () => { setList((prev) => prev.map((n) => ({ ...n, read: true }))); try { await fetch(`${USER_API_BASE_URL}/notifications/`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, }); } catch { // ignore network errors in UI } }; const showLoading = ((!loaded && !items.length) || isLoading) && !hasError; const showEmpty = !showLoading && !hasError && sortedData.length === 0; return (_jsxs("div", { ref: containerRef, className: "relative", children: [_jsxs("button", { type: "button", "aria-label": "Notifications", className: "relative text-[#A11D33] hover:opacity-80 transition", onClick: () => setOpen((v) => !v), children: [_jsx(FaBell, { className: "text-2xl" }), displayCount > 0 && (_jsx("span", { className: "absolute -top-1 -right-1 bg-red-600 text-white text-[10px] leading-none px-1.5 py-0.5 rounded-full", children: displayCount }))] }), open && (_jsxs("div", { className: "absolute left-1/2 top-full mt-2 z-50 w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 sm:left-auto sm:right-0 sm:translate-x-0 sm:w-80 sm:max-w-none bg-white shadow-lg rounded-md border border-gray-100", children: [_jsxs("div", { className: "px-4 py-2 border-b border-gray-100 flex flex-wrap items-center justify-between gap-y-2 gap-x-3", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: "Notifications" }), _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-x-3 gap-y-1", children: [_jsxs("span", { className: "text-xs text-gray-500", children: [displayCount, " new"] }), unreadCount > 0 && (_jsx("button", { className: "text-xs text-[#A11D33] hover:underline whitespace-nowrap", onClick: markAllRead, children: "Mark all as read" }))] })] }), _jsx("div", { className: "max-h-[70vh] sm:max-h-80 overflow-auto", children: showLoading ? (_jsx("div", { className: "px-4 py-6 text-sm text-gray-500", children: "Loading..." })) : hasError ? (_jsxs("div", { className: "px-4 py-6 text-sm text-gray-500 flex flex-col items-center gap-2", children: [_jsx("span", { children: "Unable to load notifications." }), _jsx("button", { type: "button", className: "text-xs text-[#A11D33] hover:underline", onClick: handleRetry, children: "Try again" })] })) : showEmpty ? (_jsx("div", { className: "px-4 py-6 text-sm text-gray-500 text-center", children: "No notifications yet" })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: "divide-y divide-gray-100", children: visibleData.map((n) => (_jsx("li", { className: "px-4 py-3 hover:bg-gray-50", children: _jsxs("div", { className: "flex flex-wrap items-start gap-3 sm:flex-nowrap", children: [_jsx("div", { className: `mt-1 w-2.5 h-2.5 rounded-full ${n.read ? "bg-transparent border border-gray-200" : "bg-blue-500"}` }), _jsx("div", { className: "flex-1 min-w-0", children: n.href ? (_jsxs("a", { href: n.href, className: "block", children: [_jsx("p", { className: "text-sm font-medium text-gray-900", children: n.title }), n.content && (_jsx("p", { className: "mt-0.5 text-sm text-gray-600 line-clamp-2", children: n.content })), n.created_at && (_jsx("p", { className: "mt-1 text-xs text-gray-400", children: n.created_at }))] })) : (_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-gray-900", children: n.title }), n.content && (_jsx("p", { className: "mt-0.5 text-sm text-gray-600 line-clamp-2", children: n.content })), n.created_at && (_jsx("p", { className: "mt-1 text-xs text-gray-400", children: n.created_at }))] })) }), !n.read && (_jsx("button", { className: "text-xs text-[#A11D33] hover:underline whitespace-nowrap mt-2 sm:mt-0 sm:self-start", onClick: () => markRead(n._id), children: "Mark read" }))] }) }, n._id))) }), hasMore && (_jsx("div", { className: "px-4 py-2 text-center", children: _jsx("button", { type: "button", className: "text-sm text-[#A11D33] hover:underline", onClick: handleLoadMore, children: "See more" }) }))] })) }), _jsx("div", { className: "px-4 py-2 border-t border-gray-100 text-right", children: _jsx("a", { href: isDev ? "https://dev.youready.net/ielts/user/pages/profile/" : "https://www.youready.net/ielts/user/pages/profile/", className: "text-sm text-[#A11D33] hover:underline", children: "View all" }) })] }))] })); }; export default NotificationsDropdown;