Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 3 additions & 2 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
currencyValues,
cryptoRates,
} from "@/components/dashboard/home";
import Image from "next/image";

const transactionData = [
{
Expand Down Expand Up @@ -131,10 +132,10 @@ export default function DashboardContent() {
variant="ghost"
className="flex p-[0.625rem] justify-start w-fit items-center gap-[0.5rem] rounded-lg bg-bg-dropdown hover:bg-bg-dropdown-hover"
>
<img
<Image
src={
currencyOptions.find((c) => c.code === selectedCurrency)
?.icon
?.icon || ""
}
alt={selectedCurrency}
width={16}
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/home/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import React from "react";
import { Button } from "@/components/ui/button";

interface ActionButtonProps {
icon: React.ComponentType<any>;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
label: string;
variant?: "primary" | "secondary";
onClick?: () => void;
}

export function ActionButton({
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/home/currency-dropdown-item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import Image from "next/image";

export interface CurrencyOption {
code: string;
Expand All @@ -19,7 +20,7 @@ export function CurrencyDropdownItem({
return (
<DropdownMenuItem onClick={onClick}>
<div className="flex items-center space-x-2">
<img src={currency.icon} alt={currency.code} width={16} height={16} />
<Image src={currency.icon} alt={currency.code} width={16} height={16} />
<span>
{currency.code} - {currency.name}
</span>
Expand Down
31 changes: 28 additions & 3 deletions components/header/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Image from "next/image";
// import { useState } from "react";
import { usePathname } from "next/navigation";
import { Button } from "../ui/button";
import { useEffect, useRef, useState } from "react";
import NotificationDropdown from "../notifications/NotificationDropdown";

// const navItems = ["Dashboard", "Convert"];

Expand All @@ -20,11 +22,26 @@ const pageNames: Record<string, string> = {
};

export default function Navbar() {
const [notifOpen, setNotifOpen] = useState(false);
const notifRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();

const currentPageName = pageNames[pathname] || "Dashboard";
const { toggleMobile, isCollapsed, isMobileOpen } = useSidebarStore();

useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
notifRef.current &&
!notifRef.current.contains(event.target as Node)
) {
setNotifOpen(false);
}
}

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

return (
<nav
className={`flex fixed z-40 top-0 px-4 py-6 justify-between items-center self-stretch backdrop-blur-2xl transition-all duration-300 left-0 right-0
Expand Down Expand Up @@ -57,8 +74,16 @@ export default function Navbar() {
{/* Right section */}
<div className="flex items-center space-x-4">
{/* Notification/Settings Icon */}
<div className="md:w-9 md:h-9 w-7 h-7 bg-gray-50 rounded-full flex items-center justify-center">
<BellDot className="w-5 h-5 text-black" />
<div className="relative" ref={notifRef}>
<button
aria-label="Notifications"
onClick={() => setNotifOpen((prev) => !prev)}
className="md:w-9 md:h-9 w-7 h-7 bg-gray-50 rounded-full flex items-center justify-center"
>
<BellDot className="w-5 h-5 text-black" />
</button>

{notifOpen && <NotificationDropdown setIsOpen={setNotifOpen} />}
</div>

{/* Avatar */}
Expand Down
119 changes: 119 additions & 0 deletions components/notifications/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {
useMarkAllAsRead,
useNotifications,
} from "@/hooks/notification/useNotifications";
import {
ArrowLeft,
CalendarDays,
CircleUserRound,
Download,
RefreshCw,
Settings,
} from "lucide-react";
import { useState } from "react";

interface NotificationDropdownProps {
setIsOpen: (isOpen: boolean) => void;
}

const notifications = [
{
id: 1,
icon: <CircleUserRound className="text-gray-600" />,
message: "Your KYC documents have been submitted for review.",
createdAt: "3 mins ago",
type: "info",
},
{
id: 2,
icon: <Download className="text-[#009411]" />,
message: "Deposit of ₦50,000 received successfully.",
createdAt: "1 hr ago",
type: "success",
},
{
id: 3,
icon: <RefreshCw className="text-[#E58600]" />,
message: "Swap from BNB to ETH was successful.",
createdAt: "5 hr ago",
type: "swap",
},
];

export default function NotificationDropdown({
setIsOpen,
}: NotificationDropdownProps) {
// For now, I will use the dummy data for notifications because the userId is not available yet. But eventually, the userId will be passed into this custom hook and the data will be looped over.
const { data } = useNotifications("123e4567-e89b-12d3-a456-426614174000");

const { mutate: markAllAsRead, isPending } = useMarkAllAsRead();
const [checked, setChecked] = useState(false);

const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const isChecked = e.target.checked;
setChecked(isChecked);

if (isChecked) {
// This userId should be replaced with the real one
markAllAsRead("123e4567-e89b-12d3-a456-426614174000");
}
};

console.log(data);

return (
<div className="max-sm:fixed max-sm:inset-0 max-sm:h-screen md:absolute right-0 top-12 w-[400px] bg-white border border-gray-200 rounded-lg shadow-lg z-50">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center space-x-2">
<ArrowLeft
className="cursor-pointer md:hidden"
onClick={() => setIsOpen(false)}
/>
<h2 className="text-xl font-semibold text-gray-900">Notifications</h2>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={handleCheckboxChange}
disabled={isPending}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span>Mark all as read</span>
</label>
<button className="p-1 hover:bg-gray-100 rounded-full transition-colors">
<Settings className="w-5 h-5 text-gray-600" />
</button>
</div>
</div>
<div className="md:max-h-64 overflow-y-auto p-4 space-y-3">
{notifications.map((notification) => (
<div
key={notification.id}
className="flex items-start space-x-3 p-4 hover:bg-gray-50 transition-colors border border-gray-200 rounded-xl"
>
<div className="flex-shrink-0 mt-0.5">{notification?.icon}</div>

<div className="flex-1 min-w-0">
<p className="text-gray-900 text-sm leading-relaxed">
{notification.message}
</p>

<div className="flex items-center mt-2 text-xs text-teal-600">
<CalendarDays className="w-3 h-3 mr-1" />
{notification.createdAt}
</div>
</div>
</div>
))}
</div>
{/* Change this later to the data coming from the useNotifications hook */}
{notifications.length === 0 && (
<div className="p-4 text-center text-gray-500">
No new notifications
</div>
)}
</div>
);
}
32 changes: 32 additions & 0 deletions hooks/notification/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { notificationService } from "@/services/api/notificationService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

export function useNotifications(userId: string) {
return useQuery({
queryKey: ["notifications"],
queryFn: () => notificationService.getNotifications(userId),
});
}

export function useMarkNotificationRead() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (notificationId: string) =>
notificationService.markAsRead(notificationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
}

export function useMarkAllAsRead() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (userId: string) => notificationService.markAllAsRead(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
}
9 changes: 8 additions & 1 deletion services/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export const API_ENDPOINTS = {
REFRESH: "/auth/refresh",
},


// Transactions endpoints
TRANSACTIONS: {
ALL: "/transactions",
Expand All @@ -34,4 +33,12 @@ export const API_ENDPOINTS = {
GET: (id: string) => `/transactions/${id}`,
UPDATE: (id: string) => `/transactions/${id}`,
},

// Notification endpoints
NOTIFICATIONS: {
CREATE: "/notifications",
GET: (userId: string) => `/notifications/unread/${userId}`,
MARK_READ: (notifId: string) => `/notifications/mark-read/${notifId}`,
MARK_ALL_READ: (userId: string) => `/notifications/mark-all-read/${userId}`,
},
};
86 changes: 86 additions & 0 deletions services/api/notificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import axiosClient from "./axiosClient";
import { API_ENDPOINTS } from "./config";

export interface Notification {
id: string;
userId: string;
type: NotificationType;
category: NotificationCategory;
title: string;
message: string;
isRead: boolean;
priority: NotificationPriority;
relatedEntityType?: string;
relatedEntityId?: string;
channel: NotificationChannel;
expirationDate?: string;
actionUrl?: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}

export enum NotificationCategory {
INFO = "INFO",
WARNING = "WARNING",
SUCCESS = "SUCCESS",
ERROR = "ERROR",
CRITICAL = "CRITICAL",
}

export enum NotificationChannel {
IN_APP = "IN_APP",
EMAIL = "EMAIL",
SMS = "SMS",
PUSH_NOTIFICATION = "PUSH_NOTIFICATION",
BOTH = "both",
}

export enum NotificationPriority {
LOW = "LOW",
MEDIUM = "MEDIUM",
HIGH = "HIGH",
URGENT = "URGENT",
}

export enum NotificationType {
SYSTEM = "SYSTEM",
PROJECT = "PROJECT",
TRANSACTION = "TRANSACTION",
MESSAGING = "MESSAGING",
CONTRIBUTION = "CONTRIBUTION",
INVITATION = "INVITATION",
SWAP_COMPLETED = "swap_completed",
WALLET_UPDATED = "wallet_updated",
TRANSACTION_FAILED = "transaction_failed",
DEPOSIT_CONFIRMED = "deposit_confirmed",
WITHDRAWAL_PROCESSED = "withdrawal_processed",
DEPOSIT_PENDING = "deposit_pending",
}

const getNotifications = async (userId: string): Promise<Notification[]> => {
const response = await axiosClient.get(
API_ENDPOINTS.NOTIFICATIONS.GET(userId)
);
return response.data;
};

const markAsRead = async (notifId: string): Promise<{ message: string }> => {
const response = await axiosClient.patch(
API_ENDPOINTS.NOTIFICATIONS.MARK_READ(notifId)
);
return response.data;
};

const markAllAsRead = async (userId: string): Promise<{ message: string }> => {
const response = await axiosClient.patch(
API_ENDPOINTS.NOTIFICATIONS.MARK_ALL_READ(userId)
);
return response.data;
};

export const notificationService = {
getNotifications,
markAsRead,
markAllAsRead,
};