Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions client/src/api/notifications/notifications.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { apiProtected } from "../api.ts";

export const getMyNotifications = async () => {
const response = await apiProtected.get("/api/notifications");
return response.data;
};

export const markAllNotificationsAsRead = async () => {
await apiProtected.patch("/api/notifications/read-all");
};

export const markNotificationAsRead = async (id: string) => {
await apiProtected.patch(`/api/notifications/${id}/read`);
};

export const deleteAllReadNotifications = async () => {
await apiProtected.delete("/api/notifications/read-all");
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useEffect, useRef } from "react";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Button } from "../ui/button/Button";
import { AppNotification } from "../../store/notificationFeed.store.ts";
import {
chatRoutes,
studentBase,
studentPrivatesRoutesVariables,
teacherBase,
teacherPrivatesRoutesVariables,
} from "../../router/routesVariables/pathVariables.ts";
import { Badge } from "../ui/badge/Badge.tsx";
import { useMarkOneNotificationAsRead } from "../../features/notifications/mutation/useMarkOneNotificationAsRead.tsx";
import { useDeleteAllReadNotifications } from "../../features/notifications/mutation/useDeleteAllReadNotifications.ts";
import { unlockScroll } from "../../util/modalScroll.util.ts";
import { AnimatePresence, motion } from "framer-motion";
type ControlPanelTypes = {
classNames?: string;
options: AppNotification[];
openMenu: boolean;
setOpenMenu: (open: boolean) => void;
currentRole?: "student" | "teacher" | "moderator";
};

export const DropdownNotificationsMenu = ({
classNames,
options,
openMenu,
setOpenMenu,
currentRole,
}: ControlPanelTypes) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const { mutateAsync: markOneAsRead } = useMarkOneNotificationAsRead();
const { mutateAsync: clearReadNotifications, isPending } =
useDeleteAllReadNotifications();

const hasReadNotifications = options.some((item) => item.isRead);
const getNotificationLink = (option: AppNotification) => {
if (option.type === "chatMessages") {
return currentRole === "student"
? `${studentBase}/${chatRoutes.root}/${option.conversationId}`
: `${teacherBase}/${chatRoutes.root}/${option.conversationId}`;
}

return currentRole === "student"
? `${studentBase}/${studentPrivatesRoutesVariables.appointments}`
: `${teacherBase}/${teacherPrivatesRoutesVariables.appointments}`;
};

const onCloseNotificationsMenu = () => {
unlockScroll();
setOpenMenu(false);
};

const getNotificationItemClassName = (option: AppNotification) => {
if (option.isRead) {
return "bg-zinc-500/10 hover:bg-zinc-500/15";
}

if (option.type === "chatMessages") {
return "bg-blue-500/10 hover:bg-blue-500/15";
}

if (option.status === "approved") {
return "bg-green-500/10 hover:bg-green-500/15";
}

return "bg-red-500/10 hover:bg-red-500/15";
};

useEffect(() => {
if (!openMenu) {
return;
}

const onPointerDown = (e: PointerEvent) => {
const el = wrapperRef.current;
if (!el) {
return;
}
if (!el.contains(e.target as Node)) {
onCloseNotificationsMenu();
}
};

document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [openMenu]);

return (
<div
ref={wrapperRef}
className={twMerge("absolute right-0 top-[130%] z-400", classNames)}
>
<div
className={twMerge(
"w-90 overflow-hidden rounded-2xl border border-gray-500 bg-[#15141D] shadow-[0_12px_40px_rgba(0,0,0,0.35)]",
"transition-all duration-200 ease-in-out",
openMenu
? "visible translate-y-0 opacity-100"
: "invisible -translate-y-2 opacity-0 pointer-events-none",
)}
>
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<span className="text-sm font-medium text-light-100">
Notifications
</span>

{hasReadNotifications && (
<Button
variant="link"
type="button"
onClick={async () => {
await clearReadNotifications();
}}
disabled={isPending}
className="text-xs text-light-100 hover:text-light-100 disabled:opacity-50"
>
Clear read
</Button>
)}
</div>
<div className="scrollbar-thin max-h-105 overflow-y-auto">
{options.length === 0 ? (
<div className="p-4 text-sm text-light-500">
No notifications yet
</div>
) : (
<AnimatePresence initial={false}>
{options.map((option) => (
<motion.div
key={option.id}
layout
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 80 }}
transition={{ duration: 0.2 }}
>
{option.type === "chatMessages" ? (
<Button
as={NavLink}
variant="link"
to={getNotificationLink(option)}
onClick={async () => {
if (!option.isRead) {
await markOneAsRead(option.id);
}
onCloseNotificationsMenu();
}}
className={twMerge(
"flex w-full justify-start rounded-none border-b border-white/10 p-3 text-left",
getNotificationItemClassName(option),
)}
>
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1 text-sm font-medium text-light-100">
<span>{option.sender.name}</span>
<Badge title="Messages" />
</div>
<span className="line-clamp-1 text-sm text-light-400">
{option.message.text}
</span>
</div>
</Button>
) : (
<Button
as={NavLink}
variant="link"
to={getNotificationLink(option)}
onClick={async () => {
if (!option.isRead) {
await markOneAsRead(option.id);
}
onCloseNotificationsMenu();
}}
className={twMerge(
"flex w-full justify-start rounded-none border-b border-white/10 p-3 text-left",
getNotificationItemClassName(option),
)}
>
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1 text-sm font-medium text-light-100">
<span>{option.actor.name}</span>
<Badge title="Messages" />
</div>
<span className="text-sm text-light-400">
{option.status === "approved"
? "Approved your appointment"
: "Rejected your appointment"}
</span>
<span className="text-xs text-light-500">
{option.lesson} • {option.date} • {option.time}
</span>
</div>
</Button>
)}
</motion.div>
))}
</AnimatePresence>
)}
</div>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@ import Cross from "../../icons/Cross";
interface Props {
isOpen: boolean;
onClose: () => void;
returnTo: string | undefined;
}

export const SignInConfirmation: React.FC<Props> = ({ isOpen, onClose }) => {
export const SignInConfirmation: React.FC<Props> = ({
isOpen,
onClose,
returnTo,
}) => {
const navigate = useNavigate();

const handleSignIn = () => {
const back = returnTo ?? location.pathname + location.search;
navigate(authRoutesVariables.loginStudent, {
replace: true,
state: { returnTo: back },
});

onClose();
navigate(authRoutesVariables.loginStudent);
};

if (!isOpen) return null;
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/controlPanel/ControlPanelTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DropdownMenu } from "./DropdownMenu.tsx";
import { Button } from "../ui/button/Button.tsx";
import ArrowDown from "../icons/ArrowDown.tsx";
import { useState } from "react";
import React, { useState } from "react";
import { LinkOption } from "../../types/linkOptionsType.ts";

type ControlPanelTriggerProps = {
Expand All @@ -24,7 +24,7 @@ export const ControlPanelTrigger = ({ options }: ControlPanelTriggerProps) => {
className="items-center gap-2.5"
onClick={() => setOpenMenu((prev) => !prev)}
>
<span className="text-light-100">Sign in </span>
<span className="text-light-100">Sign in</span>
<ArrowDown />
</Button>
</DropdownMenu>
Expand Down
91 changes: 62 additions & 29 deletions client/src/components/controlPanel/IndicatorTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useState } from "react";
import React, { useState } from "react";
import { DropdownMenu } from "./DropdownMenu";
import { Button } from "../ui/button/Button";
import ArrowDown from "../icons/ArrowDown";
import DefaultAvatarIcon from "../icons/DefaultAvatarIcon";
import { useAuthSessionStore } from "../../store/authSession.store";
import { getAvatarUrl } from "../../api/upload/upload.api";
import type { LinkOption } from "../../types/linkOptionsType";
import { useNotificationFeedStore } from "../../store/notificationFeed.store.ts";
import { twMerge } from "tailwind-merge";
import { lockScroll, unlockScroll } from "../../util/modalScroll.util.ts";
import { NotificationBar } from "../notificationBar/NotificationBar.tsx";

type Props = {
options: LinkOption[];
Expand All @@ -14,46 +18,75 @@ type Props = {

export const IndicatorTrigger = ({ options, variant = "private" }: Props) => {
const [openMenu, setOpenMenu] = useState(false);
const [openNotificationMenu, setOpenNotificationMenu] = useState(false);
const user = useAuthSessionStore((s) => s.user);

const avatarUrl = getAvatarUrl(user?.profileImageUrl || null);
const notifications = useNotificationFeedStore((s) => s.items);
const unreadNotifications = useNotificationFeedStore(
(s) => s.items.filter((item) => !item.isRead).length,
);

const onOpenNotificationsMenu = () => {
setOpenNotificationMenu((prev) => {
const next = !prev;

if (next) {
lockScroll();
} else {
unlockScroll();
}

return next;
});
};

const wrapperClass =
variant === "private" ? "hidden md:flex items-center" : "flex items-center";

return (
<div className={wrapperClass}>
<div className={twMerge("flex gap-5", wrapperClass)}>
<NotificationBar
options={notifications}
openMenu={openNotificationMenu}
onOpenNotificationsMenu={onOpenNotificationsMenu}
unreadNotifications={unreadNotifications}
setOpenNotificationMenu={setOpenNotificationMenu}
currentRole={user?.role}
/>
<div className="bg-[#E4E4E4] w-px h-8.25" />
<DropdownMenu
openMenu={openMenu}
options={options}
setOpenMenu={setOpenMenu}
>
<Button
type="button"
variant="link"
className="flex items-center gap-2.5 text-light-300 hover:text-light-100 transition-colors"
onClick={() => setOpenMenu((prev) => !prev)}
aria-expanded={openMenu}
aria-haspopup="menu"
>
<span className="w-9.5 h-9.5 rounded-full overflow-hidden">
{avatarUrl ? (
<img
className="w-full h-full object-cover"
src={avatarUrl}
alt="userPhoto"
/>
) : (
<DefaultAvatarIcon className="w-full h-full" />
)}
</span>

<span className="hidden md:block">
{user?.firstName ? user.firstName : user?.email}
</span>

<ArrowDown />
</Button>
<div className="flex items-center gap-3">
<Button
type="button"
variant="link"
className="flex items-center gap-2.5 text-light-300 hover:text-light-100 transition-colors"
onClick={() => setOpenMenu((prev) => !prev)}
aria-expanded={openMenu}
aria-haspopup="menu"
>
<span className="w-9.5 h-9.5 rounded-full overflow-hidden">
{avatarUrl ? (
<img
className="w-full h-full object-cover"
src={avatarUrl}
alt="userPhoto"
/>
) : (
<DefaultAvatarIcon className="w-full h-full" />
)}
</span>

<span className="hidden md:block">
{user?.firstName ? user.firstName : user?.email}
</span>

<ArrowDown />
</Button>
</div>
</DropdownMenu>
</div>
);
Expand Down
Loading
Loading