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
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