@ieltsrealtest/ui
Version:
Reusable UI components for IELTS Real Test platform, built with React and TypeScript.
175 lines (174 loc) • 10.1 kB
JavaScript
"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;