Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it, beforeEach } from "vitest";
import { SaleNotification } from "../types/notifications";

describe("Crate Sale Notifications System", () => {
let mockStore: Record<string, string> = {};

const fakeLocalStorage = {
getItem: (key: string) => mockStore[key] ?? null,
setItem: (key: string, value: string) => {
mockStore[key] = value;
},
removeItem: (key: string) => {
delete mockStore[key];
},
clear: () => {
mockStore = {};
},
};

beforeEach(() => {
fakeLocalStorage.clear();
});

it("persists notification state per wallet in storage", () => {
const walletA = "GBJEI26XQ6F2633USZ27P6T4H2AEL2JMYV4J43M7W3F7Z5L5K6MH7WVA";
const walletB = "GCXFSWUSLTYBGYSQCST6AQNWHQW4G5T7R2H7ZHYK37B4K2L5N6Q6MH7W";

const notifA: SaleNotification = {
id: "sale-101",
type: "sale",
sampleId: 1,
sampleTitle: "Midnight Drums",
amountXlm: "25.00",
timestamp: Date.now(),
read: false,
};

fakeLocalStorage.setItem(`crate_notifications_${walletA}`, JSON.stringify([notifA]));

const loadedA: SaleNotification[] = JSON.parse(
fakeLocalStorage.getItem(`crate_notifications_${walletA}`) || "[]"
);
const loadedB: SaleNotification[] = JSON.parse(
fakeLocalStorage.getItem(`crate_notifications_${walletB}`) || "[]"
);

expect(loadedA).toHaveLength(1);
expect(loadedA[0]?.sampleTitle).toBe("Midnight Drums");
expect(loadedA[0]?.read).toBe(false);

expect(loadedB).toHaveLength(0);
});

it("marks individual notification and all notifications as read", () => {
const notifs: SaleNotification[] = [
{
id: "sale-1",
type: "sale",
sampleTitle: "Hip Hop 808",
amountXlm: "15.00",
timestamp: Date.now() - 60000,
read: false,
},
{
id: "sale-2",
type: "sale",
sampleTitle: "Lofi Chords",
amountXlm: "20.00",
timestamp: Date.now() - 120000,
read: false,
},
];

// Mark single as read
const markedSingle = notifs.map((n) => (n.id === "sale-1" ? { ...n, read: true } : n));
expect(markedSingle.find((n) => n.id === "sale-1")?.read).toBe(true);
expect(markedSingle.find((n) => n.id === "sale-2")?.read).toBe(false);

// Mark all as read
const markedAll = notifs.map((n) => ({ ...n, read: true }));
expect(markedAll.every((n) => n.read)).toBe(true);
});

it("updates and stores cursor per wallet", () => {
const wallet = "GBJEI26...";
const cursorKey = `crate_events_cursor_${wallet}`;

fakeLocalStorage.setItem(cursorKey, "123456");
expect(fakeLocalStorage.getItem(cursorKey)).toBe("123456");

const nextLedger = 123500;
fakeLocalStorage.setItem(cursorKey, nextLedger.toString());
expect(fakeLocalStorage.getItem(cursorKey)).toBe("123500");
});
});
8 changes: 6 additions & 2 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link, useLocation } from "react-router-dom";
import WalletButton from "./WalletButton";
import NotificationDropdown from "./NotificationDropdown";

const NAV_LINKS = [
{ label: "Marketplace", path: "/marketplace" },
Expand Down Expand Up @@ -31,7 +32,7 @@ export default function Navbar() {
justifyContent: "space-between",
}}
>
<Link
<Link
to="/"
style={{
fontSize: "18px",
Expand Down Expand Up @@ -71,7 +72,10 @@ export default function Navbar() {
))}
</nav>

<WalletButton />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<NotificationDropdown />
<WalletButton />
</div>
</div>
</nav>
);
Expand Down
242 changes: 242 additions & 0 deletions src/components/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { useState, useRef, useEffect } from "react";
import { useNotifications } from "../hooks/useNotifications";
import { SaleNotification } from "../types/notifications";

function formatTimeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
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`;
}

export default function NotificationDropdown() {
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
clearAll,
} = useNotifications();

const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

return (
<div ref={dropdownRef} style={{ position: "relative" }}>
{/* Bell Trigger Button */}
<button
type="button"
aria-label={`Notifications (${unreadCount} unread)`}
onClick={() => setIsOpen(!isOpen)}
style={{
position: "relative",
width: "36px",
height: "36px",
borderRadius: "var(--radius)",
background: isOpen ? "var(--surface-3)" : "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text-primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.15s",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>

{unreadCount > 0 && (
<span
style={{
position: "absolute",
top: "-4px",
right: "-4px",
background: "var(--accent)",
color: "#000",
fontSize: "10px",
fontWeight: 800,
padding: "1px 5px",
borderRadius: "10px",
lineHeight: 1.2,
}}
>
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>

{/* Dropdown Menu */}
{isOpen && (
<div
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
width: "320px",
maxHeight: "420px",
background: "var(--surface-1)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
boxShadow: "0 10px 30px rgba(0,0,0,0.5)",
zIndex: 1000,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ fontWeight: 700, fontSize: "14px", color: "var(--text-primary)" }}>
Notifications
</div>
{notifications.length > 0 && (
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={markAllAsRead}
style={{
background: "none",
border: "none",
color: "var(--accent)",
fontSize: "11px",
fontWeight: 600,
cursor: "pointer",
padding: 0,
}}
>
Mark read
</button>
<span style={{ color: "var(--text-tertiary)", fontSize: "11px" }}>•</span>
<button
type="button"
onClick={clearAll}
style={{
background: "none",
border: "none",
color: "var(--text-secondary)",
fontSize: "11px",
cursor: "pointer",
padding: 0,
}}
>
Clear
</button>
</div>
)}
</div>

{/* Body */}
<div style={{ overflowY: "auto", flex: 1, maxHeight: "350px" }}>
{notifications.length === 0 ? (
<div
style={{
padding: "32px 16px",
textAlign: "center",
color: "var(--text-secondary)",
fontSize: "13px",
}}
>
No sales notifications yet
</div>
) : (
notifications.map((notif: SaleNotification) => (
<div
key={notif.id}
onClick={() => markAsRead(notif.id)}
style={{
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
background: notif.read ? "transparent" : "var(--surface-2)",
cursor: "pointer",
transition: "background 0.15s",
display: "flex",
gap: "12px",
alignItems: "flex-start",
}}
>
<span style={{ fontSize: "16px", marginTop: "2px" }}>
{notif.type === "sale" ? "💰" : "💸"}
</span>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: "13px",
fontWeight: notif.read ? 500 : 700,
color: "var(--text-primary)",
marginBottom: "2px",
}}
>
{notif.type === "sale" ? "License Purchased" : "Earnings Withdrawn"}
</div>
<div style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
{notif.sampleTitle ?? `Sample #${notif.sampleId}`}
</div>
<div
style={{
fontSize: "11px",
color: "var(--text-tertiary)",
marginTop: "4px",
display: "flex",
justifyContent: "space-between",
}}
>
<span style={{ color: "var(--accent)", fontWeight: 600 }}>
+{notif.amountXlm} XLM
</span>
<span>{formatTimeAgo(notif.timestamp)}</span>
</div>
</div>
{!notif.read && (
<span
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: "var(--accent)",
marginTop: "6px",
}}
/>
)}
</div>
))
)}
</div>
</div>
)}
</div>
);
}
Loading