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
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Transaction Notification Center — Implementation Checklist ✓

## Files to Create
- [x] `src/lib/notifications.ts` — Types, interfaces, and helper functions
- [x] `src/components/notifications/useNotifications.tsx` — React Context, Provider, and hook
- [x] `src/components/notifications/NotificationToast.tsx` — Floating toast component
- [x] `src/components/notifications/NotificationCenter.tsx` — Dropdown panel with notification list

## Files to Edit
- [x] `src/app/layout.tsx` — Wrap with NotificationProvider
- [x] `src/components/Navbar.tsx` — Add bell icon with unread count badge

## Verification
- [x] `npm run lint` — No linting errors
- [x] `npm run build` — Production build succeeds (17 routes)

2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
11 changes: 8 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import "leaflet/dist/leaflet.css";
import "./globals.css";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import { NotificationProvider } from "@/components/notifications/useNotifications";
import NotificationToast from "@/components/notifications/NotificationToast";

const inter = Inter({
variable: "--font-inter",
Expand Down Expand Up @@ -31,9 +33,12 @@ export default function RootLayout({
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-full flex flex-col bg-sand text-ink">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<NotificationProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<NotificationToast />
</NotificationProvider>
</body>
</html>
);
Expand Down
3 changes: 3 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from "next/link";
import { HiMenu, HiX } from "react-icons/hi";
import Logo from "./brand/Logo";
import ThemeToggle from "./ThemeToggle";
import NotificationCenter from "./notifications/NotificationCenter";
import { buttonClasses } from "./ui/Button";

const navLinks = [
Expand Down Expand Up @@ -33,6 +34,7 @@ export default function Navbar() {

<div className="ml-auto hidden items-center gap-2 sm:flex">
<ThemeToggle />
<NotificationCenter />
<Link href="/login" className={buttonClasses("outline", "sm")}>
Log in
</Link>
Expand All @@ -43,6 +45,7 @@ export default function Navbar() {

<div className="ml-auto flex items-center gap-2 sm:hidden">
<ThemeToggle />
<NotificationCenter />
<button
className="text-2xl text-ink"
aria-label={open ? "Close menu" : "Open menu"}
Expand Down
169 changes: 169 additions & 0 deletions src/components/notifications/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"use client";

import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { HiBell, HiX, HiTrash } from "react-icons/hi";
import type { TransactionNotification } from "@/lib/notifications";
import { useNotifications } from "./useNotifications";

// ── Small icon per type ──────────────────────────────────────────
function TypeDot({ type }: { type: TransactionNotification["type"] }) {
const dotColor = {
success: "bg-ok",
error: "bg-err",
pending: "bg-gold",
info: "bg-navy-2",
}[type];
return <span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />;
}

// ── Relative time helper ──────────────────────────────────────────
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

// ── Single notification row ──────────────────────────────────────
function NotificationRow({
notification,
onDismiss,
}: {
notification: TransactionNotification;
onDismiss: (id: string) => void;
}) {
return (
<div className="flex items-start gap-3 border-b border-line px-4 py-3 last:border-b-0">
<TypeDot type={notification.type} />

<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-ink">{notification.title}</p>
<p className="mt-0.5 text-xs text-muted">{notification.message}</p>
<p className="mt-1 text-[10px] text-muted">{timeAgo(notification.timestamp)}</p>

{/* Action buttons */}
{(notification.retryAction || notification.deepLink) && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{notification.retryAction && (
<button
type="button"
onClick={() => {
void notification.retryAction!.handler();
onDismiss(notification.id);
}}
className="inline-flex items-center gap-1 rounded-md bg-navy-tint px-2.5 py-1 text-[11px] font-semibold text-navy-2 transition-colors hover:bg-navy/15"
>
{notification.retryAction.label}
</button>
)}
{notification.deepLink && (
<Link
href={notification.deepLink.href}
onClick={() => onDismiss(notification.id)}
className="inline-flex items-center gap-1 rounded-md bg-gold/15 px-2.5 py-1 text-[11px] font-semibold text-gold-deep transition-colors hover:bg-gold/25"
>
{notification.deepLink.label}
</Link>
)}
</div>
)}
</div>

<button
type="button"
onClick={() => onDismiss(notification.id)}
aria-label="Dismiss"
className="shrink-0 rounded-full p-1 text-muted transition-colors hover:bg-line/70"
>
<HiX aria-hidden className="text-xs" />
</button>
</div>
);
}

// ── Notification center bell + dropdown ──────────────────────────
export default function NotificationCenter() {
const { notifications, dismissNotification, clearAll } = useNotifications();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

// Close on outside click (same pattern as WalletButton.tsx)
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);

const unread = notifications.length;
const hasNotifications = unread > 0;

return (
<div ref={rootRef} className="relative">
{/* Bell button */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={`Notifications${hasNotifications ? ` (${unread} unread)` : ""}`}
aria-expanded={open}
className="relative rounded-full p-2 text-muted transition-colors hover:bg-line/60"
>
<HiBell aria-hidden className="text-xl" />
{hasNotifications && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-err px-1 text-[10px] font-bold leading-none text-white">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>

{/* Dropdown panel */}
{open && (
<div className="absolute right-0 mt-2 w-80 rounded-xl border border-line bg-surface shadow-float z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-line px-4 py-3">
<span className="text-sm font-semibold text-ink">Notifications</span>
{hasNotifications && (
<button
type="button"
onClick={clearAll}
className="inline-flex items-center gap-1 text-xs font-medium text-muted transition-colors hover:text-err"
>
<HiTrash aria-hidden />
Clear all
</button>
)}
</div>

{/* List */}
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center">
<p className="text-sm text-muted">No notifications yet</p>
<p className="mt-1 text-xs text-muted">
Transaction updates will appear here.
</p>
</div>
) : (
<div className="max-h-80 overflow-y-auto">
{[...notifications].reverse().map((n) => (
<NotificationRow
key={n.id}
notification={n}
onDismiss={dismissNotification}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

133 changes: 133 additions & 0 deletions src/components/notifications/NotificationToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
HiX,
HiCheckCircle,
HiExclamationCircle,
HiClock,
HiInformationCircle,
} from "react-icons/hi";
import type { TransactionNotification } from "@/lib/notifications";
import { useNotifications } from "./useNotifications";

// ── Icon per type ─────────────────────────────────────────────────
function TypeIcon({ type }: { type: TransactionNotification["type"] }) {
switch (type) {
case "success":
return <HiCheckCircle aria-hidden className="text-lg text-ok" />;
case "error":
return <HiExclamationCircle aria-hidden className="text-lg text-err" />;
case "pending":
return <HiClock aria-hidden className="text-lg text-gold-deep" />;
case "info":
return <HiInformationCircle aria-hidden className="text-lg text-navy-2" />;
}
}

// ── Single toast card ──────────────────────────────────────────────
function ToastCard({
notification,
onDismiss,
}: {
notification: TransactionNotification;
onDismiss: (id: string) => void;
}) {
const [visible, setVisible] = useState(false);

useEffect(() => {
// Trigger entrance animation on mount
requestAnimationFrame(() => setVisible(true));
}, []);

const dismiss = useCallback(() => {
setVisible(false);
setTimeout(() => onDismiss(notification.id), 200);
}, [notification.id, onDismiss]);

const borderColor = {
success: "border-l-ok",
error: "border-l-err",
pending: "border-l-gold",
info: "border-l-navy-2",
}[notification.type];

return (
<div
role="alert"
className={`w-full max-w-sm rounded-xl border border-line bg-surface shadow-float backdrop-blur-md transition-all duration-300 ${visible ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
} ${borderColor} border-l-4`}
>
<div className="flex items-start gap-3 p-4">
<TypeIcon type={notification.type} />

<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-ink">{notification.title}</p>
<p className="mt-0.5 text-xs text-muted">{notification.message}</p>

{/* Action buttons — shown for error toasts mostly */}
{(notification.retryAction || notification.deepLink) && (
<div className="mt-3 flex flex-wrap items-center gap-2">
{notification.retryAction && (
<button
type="button"
onClick={() => {
void notification.retryAction!.handler();
dismiss();
}}
className="inline-flex items-center gap-1 rounded-lg bg-navy-tint px-3 py-1.5 text-xs font-semibold text-navy-2 transition-colors hover:bg-navy/15"
>
{notification.retryAction.label}
</button>
)}
{notification.deepLink && (
<Link
href={notification.deepLink.href}
onClick={dismiss}
className="inline-flex items-center gap-1 rounded-lg bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-deep transition-colors hover:bg-gold/25"
>
{notification.deepLink.label}
</Link>
)}
</div>
)}
</div>

<button
type="button"
onClick={dismiss}
aria-label="Dismiss notification"
className="shrink-0 rounded-full p-1 text-muted transition-colors hover:bg-line/70"
>
<HiX aria-hidden className="text-sm" />
</button>
</div>
</div>
);
}

// ── Toast stack (floating container) ───────────────────────────────
export default function NotificationToast() {
const { notifications, dismissNotification } = useNotifications();

// Only show the 3 most recent notifications to avoid clutter
const visible = notifications.slice(-3);

if (visible.length === 0) return null;

return (
<div
aria-live="polite"
aria-label="Notifications"
className="pointer-events-none fixed bottom-6 right-6 z-[100] flex flex-col-reverse items-end gap-3"
>
{visible.map((n) => (
<div key={n.id} className="pointer-events-auto">
<ToastCard notification={n} onDismiss={dismissNotification} />
</div>
))}
</div>
);
}

4 changes: 4 additions & 0 deletions src/components/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { NotificationProvider, useNotifications } from "./useNotifications";
export { default as NotificationToast } from "./NotificationToast";
export { default as NotificationCenter } from "./NotificationCenter";

Loading
Loading