Skip to content

Commit

Permalink
feat: add a user notifications page (#1478)
Browse files Browse the repository at this point in the history
  • Loading branch information
takanome-dev authored Aug 8, 2023
1 parent 397c36e commit 022dc69
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 72 deletions.
13 changes: 3 additions & 10 deletions components/atoms/NotificationsCard/notification-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,14 @@ import Link from "next/link";
import React from "react";
import { FaRegSmile, FaUserCircle } from "react-icons/fa";

import { getNotificationURL } from "lib/utils/get-notification-url";

interface NotificationCard {
type: "highlight_reaction" | "follow" | "collaboration";
message: string;
id: string;
}

const getSourceURL = (type: string, id: string) => {
switch (type) {
case "highlight_reaction":
return `/feed/${id}`;
case "follow":
return `/user/${id}`;
}
};

const NotificationCard = ({ type, message, id }: NotificationCard) => {
const Icons = {
highlight_reaction: FaRegSmile,
Expand All @@ -25,7 +18,7 @@ const NotificationCard = ({ type, message, id }: NotificationCard) => {
};

const Icon = Icons[type];
const linkURL = getSourceURL(type, id);
const linkURL = getNotificationURL(type, id);

return (
<div className="flex items-center gap-3 p-2 transition cursor-pointer rounded-xl group hover:text-sauced-orange hover:bg-sauced-light">
Expand Down
70 changes: 8 additions & 62 deletions components/molecules/AuthSection/auth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import Button from "components/atoms/Button/button";
import Text from "components/atoms/Typography/text";
import GitHubIcon from "img/icons/github-icon.svg";
import Icon from "components/atoms/Icon/icon";
import NotificationCard from "components/atoms/NotificationsCard/notification-card";
import { Spinner } from "components/atoms/SpinLoader/spin-loader";
import { Popover, PopoverContent, PopoverTrigger } from "../Popover/popover";
import DropdownList from "../DropdownList/dropdown-list";
import OnboardingButton from "../OnboardingButton/onboarding-button";
import userAvatar from "../../../img/ellipse-1.png";
Expand All @@ -30,38 +27,17 @@ const AuthSection: React.FC = ({}) => {
const router = useRouter();
const currentPath = router.asPath;

const { signIn, signOut, user, sessionToken } = useSupabaseAuth();
const { signIn, signOut, user } = useSupabaseAuth();
const { onboarded, session } = useSession(true);
const [notifications, setNotifications] = useState<DbUserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [userInfo, setUserInfo] = useState<DbUser | undefined>(undefined);
const [host, setHost] = useState<string>("");

useEffect(() => {
if (typeof window !== "undefined") {
setHost(window.location.origin as string);
}
}, []);

// Fetch user notifications
const fetchNotifications = async () => {
if (userInfo && userInfo.notification_count > 0) {
setLoading(true);
const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/notifications`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});
setLoading(false);
if (req.ok) {
const notifications = await req.json();
setNotifications(notifications.data as DbUserNotification[]);
}
} else {
return;
}
};

useEffect(() => {
if (session && !userInfo) {
setUserInfo(session);
Expand Down Expand Up @@ -128,42 +104,12 @@ const AuthSection: React.FC = ({}) => {
) : (
""
)}
<Popover
onOpenChange={(state) => {
// reset the notification state to empty when the popover is closed
if (!loading && !state) setUserInfo(undefined);
}}
>
<PopoverTrigger onClick={async () => await fetchNotifications()} asChild>
<button className="relative cursor-pointer">
{userInfo && userInfo.notification_count > 0 && (
<span className="absolute right-0 block w-2 h-2 bg-orange-300 rounded-full"></span>
)}
<IoNotifications className="text-xl text-light-slate-9" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="bg-white !rounded-xl p-1 ">
{loading ? (
<div className="flex items-center justify-center py-2">
<Spinner />
</div>
) : (
<>
{notifications.length > 0 ? (
<div className="space-y-1">
{notifications.map(({ type, message, id, meta_id }) => (
<NotificationCard key={id} message={message} type={type} id={meta_id} />
))}
</div>
) : (
<div className="px-3 py-2 text-sm text-center text-light-slate-9">
You don&apos;t have any unread notifications
</div>
)}
</>
)}
</PopoverContent>
</Popover>
<button className="relative cursor-pointer" onClick={() => router.push(`/user/notifications`)}>
{userInfo && userInfo.notification_count > 0 && (
<span className="absolute right-0 block w-2 h-2 bg-orange-300 rounded-full"></span>
)}
<IoNotifications className="text-xl text-light-slate-9" />
</button>

<DropdownList menuContent={authMenu.authed}>
<div className="flex justify-end min-w-[60px] gap-2">
Expand Down
16 changes: 16 additions & 0 deletions lib/utils/get-notification-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Returns the URL for a notification based on its type and ID or username.
* @param type - The type of the notification.
* @param id - The ID or username associated with the notification.
* @returns The URL for the notification.
*/
export const getNotificationURL = (type: string, id: string | number) => {
switch (type) {
case "highlight_reaction":
return `/feed/${id}`;
case "follow":
return `/user/${id}`;
default:
return "/";
}
};
215 changes: 215 additions & 0 deletions pages/user/notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router";
import clsx from "clsx";
import Link from "next/link";

import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
import useSupabaseAuth from "lib/hooks/useSupabaseAuth";
import Title from "components/atoms/Typography/title";
import Button from "components/atoms/Button/button";
import SkeletonWrapper from "components/atoms/SkeletonLoader/skeleton-wrapper";
import PaginationResults from "components/molecules/PaginationResults/pagination-result";
import Pagination from "components/molecules/Pagination/pagination";
import DashContainer from "components/atoms/DashedContainer/DashContainer";
import Avatar from "components/atoms/Avatar/avatar";
import { getAvatarByUsername } from "lib/utils/github";
import { getNotificationURL } from "lib/utils/get-notification-url";
import changeCapitalization from "lib/utils/change-capitalization";
import ProfileLayout from "layouts/profile";
import { WithPageLayout } from "interfaces/with-page-layout";

interface NotificationResponse {
data: DbUserNotification[];
meta: Meta;
}

const Notifications: WithPageLayout = () => {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [notificationsResponse, setNotificationsResponse] = useState<NotificationResponse>();
const [filter, setFilter] = useState<"all" | "follow" | "highlight_reaction">("all");

const topRef = useRef<HTMLDivElement>(null);
const { sessionToken, user } = useSupabaseAuth(true);

const router = useRouter();
const username = user?.user_metadata.user_name as string;

const fetchNotifications = async (page = 1) => {
if (!sessionToken) return;
setLoading(true);
const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/notifications?limit=10&page=${page}`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});
const notifications = await req.json();
setNotificationsResponse(notifications);
setLoading(false);
};

const notifications = useMemo(() => {
if (!notificationsResponse?.data) return [];
if (filter === "all")
return notificationsResponse?.data?.sort(
(a, b) => new Date(b.notified_at).getTime() - new Date(a.notified_at).getTime()
);
return notificationsResponse?.data
.filter((notification) => notification.type === filter)
?.sort((a, b) => new Date(b.notified_at).getTime() - new Date(a.notified_at).getTime());
}, [notificationsResponse?.data, filter]);

useEffect(() => {
fetchNotifications();

if (sessionToken) {
router.push(`/user/notifications?filter=all`);
}
}, [sessionToken]);

const handlePageChange = (page: number) => {
setPage(page);
fetchNotifications(page);
if (topRef.current) {
topRef.current.scrollIntoView({
behavior: "smooth",
});
}
};

return (
<div className="w-full ">
<div
className="container flex flex-col justify-between w-full px-2 pt-16 mx-auto overflow-hidden md:px-16 md:flex-row md:gap-20 lg:gap-40"
ref={topRef}
>
<div className="flex flex-col gap-4 w-80">
<Title className="!text-2xl !text-light-slate-12" level={3}>
Notifications
</Title>
<aside className="flex flex-col gap-2">
<Button
variant="link"
className={clsx("hover:text-orange-600", filter === "all" ? "text-orange-600" : "text-light-slate-11")}
onClick={() => {
setFilter("all");
router.push(`/user/notifications?filter=all`);
}}
>
All
</Button>
<Button
variant="link"
className={clsx("hover:text-orange-600", filter === "follow" ? "text-orange-600" : "text-light-slate-11")}
onClick={() => {
setFilter("follow");
router.push(`/user/notifications?filter=follow`);
}}
>
Follows
</Button>
<Button
variant="link"
className={clsx(
"hover:text-orange-600",
filter === "highlight_reaction" ? "text-orange-600" : "text-light-slate-11"
)}
onClick={() => {
setFilter("highlight_reaction");
router.push(`/user/notifications?filter=highlight_reaction`);
}}
>
Reactions
</Button>
</aside>
</div>
<div className="flex-1 mt-10 md:mt-0">
{loading ? (
<div className="flex flex-col gap-4">
{Array.from({ length: 8 }).map((_, index) => (
<div className="flex gap-2" key={index}>
<SkeletonWrapper width={50} height={50} />
<div className="">
<SkeletonWrapper height={20} width={500} classNames="mb-2" />
<SkeletonWrapper height={10} width={100} />
</div>
</div>
))}
</div>
) : notifications?.length <= 0 && !loading ? (
<DashContainer>
<div className="text-center">
<p>
You don&apos;t have any{" "}
{changeCapitalization(filter === "highlight_reaction" ? "reaction" : filter, true)} notifications yet!{" "}
<br />
</p>
</div>
</DashContainer>
) : (
<div className="flex flex-col gap-2 mb-10">
{notifications?.map((notification) => (
<div className="p-2 border bg-light-slate-2 rounded-lg flex items-center gap-4" key={notification.id}>
<Avatar
initialsClassName="text-[100px] leading-none"
initials={notification.meta_id.charAt(0)}
hasBorder
avatarURL={getAvatarByUsername(notification.meta_id, 300)}
size={50}
isCircle
/>
<div className="flex flex-col gap-2">
<p className="text-light-slate-12 flex gap-2">
<Link href={getNotificationURL(notification.type, notification.meta_id)} className="font-bold">
{notification.meta_id}
</Link>
<span>{notification.message.replace(notification.meta_id, " ")}</span>
</p>
<span className="text-xs font-normal text-light-slate-11">
{formatDistanceToNowStrict(new Date(notification.notified_at), { addSuffix: true })}
</span>
</div>
</div>
))}
</div>
)}
{notifications?.length > 0 && (notificationsResponse?.meta?.pageCount ?? 0) > 1 && (
<div className="my-10 flex px-2 items-center justify-between">
<div className="flex items-center w-max gap-x-4">
<PaginationResults
metaInfo={
notificationsResponse?.meta ?? {
itemCount: 0,
limit: 0,
page: 0,
hasNextPage: false,
hasPreviousPage: false,
pageCount: 0,
}
}
total={notificationsResponse?.meta?.itemCount ?? 0}
entity={"notifications"}
/>
</div>
<Pagination
pages={[]}
totalPage={notificationsResponse?.meta?.pageCount ?? 0}
page={notificationsResponse?.meta?.page ?? 0}
pageSize={notificationsResponse?.meta?.itemCount ?? 0}
goToPage
hasNextPage={notificationsResponse?.meta?.hasNextPage ?? false}
hasPreviousPage={notificationsResponse?.meta?.hasPreviousPage ?? false}
onPageChange={(pageNumber) => handlePageChange(pageNumber)}
/>
</div>
)}
</div>
</div>
</div>
);
};

Notifications.PageLayout = ProfileLayout;
Notifications.isPrivateRoute = true;
export default Notifications;
24 changes: 24 additions & 0 deletions tests/lib/utils/get-notifications-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getNotificationURL } from "lib/utils/get-notification-url";

describe("[lib] getNotificationURL()", () => {
it("should return the correct URL for a highlight reaction notification", () => {
const type = "highlight_reaction";
const idOrUsername = 30;
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/feed/30");
});

it("should return the correct URL for a follow notification", () => {
const type = "follow";
const idOrUsername = "takanome-dev";
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/user/takanome-dev");
});

it("should return the correct URL for an unknown notification", () => {
const type = "unknown";
const idOrUsername = "unknown";
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/");
});
});

0 comments on commit 022dc69

Please sign in to comment.