Skip to content

Commit

Permalink
Merge pull request #36 from trycompai/lewis/notifications
Browse files Browse the repository at this point in the history
Notifications / Novu
  • Loading branch information
carhartlewis authored Feb 10, 2025
2 parents 07ba223 + ec3182e commit cb10949
Show file tree
Hide file tree
Showing 18 changed files with 809 additions and 15 deletions.
4 changes: 4 additions & 0 deletions apps/app/languine.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ files:
languages.fr: ad225f707802ba118c22987186dd38e8
languages.no: da550ca06bcacbd30b7c6ed32c864c70
languages.pt: 30e32c7c4cf434e9c75e60c14c442541
common.notifications.inbox: 3882d32c66e7e768145ecd8f104b0c08
common.notifications.archive: e727b00944f81e1d0a95c12886ac4641
common.notifications.archive_all: 449e2fbab2a05e2f9f3cc1717f58eee0
common.notifications.no_notifications: bb76b111a28211312083c68b2efabae7
common.actions.save: c9cc8cce247e49bae79f15173ce97354
common.actions.edit: 7dce122004969d56ae2e0245cb754d35
common.actions.delete: f2a6c498fb90ee345d997f888fce3b18
Expand Down
2 changes: 2 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@nangohq/frontend": "^0.48.4",
"@nangohq/node": "^0.48.4",
"@novu/headless": "^2.0.4",
"@novu/react": "^2.6.5",
"@prisma/instrumentation": "^6.3.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-table": "^8.20.6",
Expand Down Expand Up @@ -86,6 +87,7 @@
},
"devDependencies": {
"@bubba/db": "workspace:*",
"@bubba/notifications": "workspace:*",
"@trigger.dev/build": "3.3.13",
"@types/node": "^22.13.0",
"@types/react": "^19.0.8",
Expand Down
6 changes: 6 additions & 0 deletions apps/app/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { getI18n } from "@/locales/server";
import { Button } from "@bubba/ui/button";
import { Icons } from "@bubba/ui/icons";
import { Skeleton } from "@bubba/ui/skeleton";
import { Inbox } from "@novu/react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { Suspense } from "react";
import { AssistantButton } from "./assistant/button";
import { FeedbackForm } from "./feedback-form";
import { MobileMenu } from "./mobile-menu";
import { NotificationCenter } from "./notification-center";

export async function Header() {
const t = await getI18n();
Expand Down Expand Up @@ -38,6 +41,9 @@ export async function Header() {
</Link>
</Button>
</div>

<NotificationCenter />

<Suspense fallback={<Skeleton className="h-8 w-8 rounded-full" />}>
<UserMenu />
</Suspense>
Expand Down
230 changes: 230 additions & 0 deletions apps/app/src/components/notification-center.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"use client";

import { useNotifications } from "@/hooks/use-notifications";
import { useI18n } from "@/locales/client";
import { Button } from "@bubba/ui/button";
import { Icons } from "@bubba/ui/icons";
import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover";
import { ScrollArea } from "@bubba/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@bubba/ui/tabs";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { useEffect, useState } from "react";

function EmptyState({ description }: { description: string }) {
return (
<div className="h-[460px] flex items-center justify-center flex-col space-y-4">
<div className="w-12 h-12 rounded-full bg-accent flex items-center justify-center">
<Icons.Inbox className="size-16" />
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}

function NotificationItem({
id,
setOpen,
description,
createdAt,
recordId,
from,
to,
markMessageAsRead,
type,
}: {
id: string;
setOpen: (open: boolean) => void;
description: string;
createdAt: string;
recordId: string;
from: string;
to: string;
markMessageAsRead: (id: string) => void;
type: string;
}) {
switch (type) {
case "inapp_task_reminder":
return (
<div className="flex items-between justify-between space-x-4 px-3 py-3 hover:bg-secondary">
<Link
className="flex items-between justify-between space-x-4"
onClick={() => setOpen(false)}
href={`/tasks/${recordId}`}
>
<div>
<div className="h-9 w-9 flex items-center justify-center space-y-0 border rounded-full">
<Icons.Match />
</div>
</div>
<div>
<p className="text-sm">{description}</p>
<span className="text-xs text-muted">
{formatDistanceToNow(new Date(createdAt))} ago
</span>
</div>
</Link>
{markMessageAsRead && (
<div>
<Button
size="icon"
variant="secondary"
className="rounded-full bg-transparent hover:bg-[#1A1A1A]"
onClick={() => markMessageAsRead(id)}
>
<Icons.Inventory2 />
</Button>
</div>
)}
</div>
);
default:
return null;
}
}

export function NotificationCenter() {
const t = useI18n();

const [isOpen, setOpen] = useState(false);
const {
hasUnseenNotifications,
notifications,
markMessageAsRead,
markAllMessagesAsSeen,
markAllMessagesAsRead,
} = useNotifications();

const unreadNotifications = notifications.filter(
(notification) => !notification.read,
);

const archivedNotifications = notifications.filter(
(notification) => notification.read,
);

useEffect(() => {
if (isOpen && hasUnseenNotifications) {
markAllMessagesAsSeen();
}
}, [hasUnseenNotifications, isOpen]);

return (
<Popover onOpenChange={setOpen} open={isOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 flex items-center relative"
>
{hasUnseenNotifications && (
<div className="w-1.5 h-1.5 bg-[#FFD02B] rounded-full absolute top-0 right-0" />
)}
<Icons.Notifications size={16} />
</Button>
</PopoverTrigger>
<PopoverContent
className="h-[535px] w-screen md:w-[400px] p-0 overflow-hidden relative"
align="end"
sideOffset={10}
>
<Tabs defaultValue="inbox">
<TabsList className="w-full justify-start bg-transparent border-b-[1px] rounded-none py-6">
<TabsTrigger value="inbox" className="font-normal">
{t("common.notifications.inbox")}
</TabsTrigger>
<TabsTrigger value="archive" className="font-normal">
{t("common.notifications.archive")}
</TabsTrigger>
</TabsList>

<Link
href="/settings/notifications"
className="absolute right-[11px] top-1.5"
>
<Button
variant="secondary"
size="icon"
className="rounded-full bg-transparent hover:bg-accent"
onClick={() => setOpen(false)}
>
<Icons.Settings className="text-muted" size={16} />
</Button>
</Link>

<TabsContent value="inbox" className="relative mt-0">
{!unreadNotifications.length && (
<EmptyState description="No new notifications" />
)}

{unreadNotifications.length > 0 && (
<ScrollArea className="pb-12 h-[485px]">
<div className="divide-y">
{unreadNotifications.map((notification) => {
return (
<NotificationItem
key={notification.id}
id={notification.id}
markMessageAsRead={markMessageAsRead}
setOpen={setOpen}
description={notification.payload.description}
createdAt={notification.createdAt}
recordId={notification.payload.recordId}
type={notification.payload.type}
from={notification.payload?.from}
to={notification.payload?.to}
/>
);
})}
</div>
</ScrollArea>
)}

{unreadNotifications.length > 0 && (
<div className="h-12 w-full absolute bottom-0 flex items-center justify-center border-t-[1px]">
<Button
variant="secondary"
className="bg-transparent"
onClick={markAllMessagesAsRead}
>
{t("common.notifications.archive_all")}
</Button>
</div>
)}
</TabsContent>

<TabsContent value="archive" className="mt-0">
{!archivedNotifications.length && (
<EmptyState
description={t("common.notifications.no_notifications")}
/>
)}

{archivedNotifications.length > 0 && (
<ScrollArea className="h-[490px]">
<div className="divide-y">
{archivedNotifications.map((notification) => {
return (
<NotificationItem
key={notification.id}
id={notification.id}
setOpen={setOpen}
description={notification.payload.description}
createdAt={notification.createdAt}
recordId={notification.payload.recordId}
type={notification.payload.type}
from={notification.payload?.from}
to={notification.payload?.to}
markMessageAsRead={markMessageAsRead}
/>
);
})}
</div>
</ScrollArea>
)}
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
);
}
2 changes: 2 additions & 0 deletions apps/app/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_VERCEL_URL: z.string().optional(),
NEXT_PUBLIC_NOVU_IDENTIFIER: z.string().optional(),
},

runtimeEnv: {
Expand All @@ -49,6 +50,7 @@ export const env = createEnv({
VERCEL_TEAM_ID: process.env.VERCEL_TEAM_ID,
VERCEL_PROJECT_ID: process.env.VERCEL_PROJECT_ID,
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
NEXT_PUBLIC_NOVU_IDENTIFIER: process.env.NEXT_PUBLIC_NOVU_IDENTIFIER,
},

skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION,
Expand Down
Loading

0 comments on commit cb10949

Please sign in to comment.