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
146 changes: 96 additions & 50 deletions src/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useNotifications } from "@/hooks/useNotifications";
import type { RefractNotification } from "@/lib/notifications";
import { groupNotifications } from "@/lib/notificationGroups";

const SEVERITY_ICONS: Record<RefractNotification["severity"], string> = {
info: "ℹ",
Expand All @@ -12,7 +13,10 @@ const SEVERITY_ICONS: Record<RefractNotification["severity"], string> = {
critical: "⚡",
};

const SEVERITY_COLORS: Record<RefractNotification["severity"], { bg: string; text: string; border: string }> = {
const SEVERITY_COLORS: Record<
RefractNotification["severity"],
{ bg: string; text: string; border: string }
> = {
info: { bg: "bg-pm-violet/10", text: "text-pm-violet", border: "border-pm-violet/20" },
warning: { bg: "bg-pm-amber/10", text: "text-pm-amber", border: "border-pm-amber/20" },
success: { bg: "bg-pm-green/10", text: "text-pm-green", border: "border-pm-green/20" },
Expand All @@ -21,7 +25,9 @@ const SEVERITY_COLORS: Record<RefractNotification["severity"], { bg: string; tex

export function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const groups = useMemo(() => groupNotifications(notifications), [notifications]);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -79,6 +85,10 @@ export function NotificationCenter() {
)}
</button>

<span className="sr-only" role="status" aria-live="polite">
{unreadCount > 0 ? `${unreadCount} unread notifications` : ""}
</span>

{isOpen && (
<div
role="dialog"
Expand Down Expand Up @@ -108,60 +118,96 @@ export function NotificationCenter() {
<div className="max-h-[360px] overflow-y-auto space-y-2.5 pr-1">
{notifications.length === 0 ? (
<div className="py-8 text-center text-xs text-pm-text/40">
<span className="mb-2 block text-2xl" aria-hidden="true">🔔</span>
<span className="mb-2 block text-2xl" aria-hidden="true">
🔔
</span>
No notifications right now. You&apos;re all caught up!
</div>
) : (
notifications.map((item) => {
const colors = SEVERITY_COLORS[item.severity];
const icon = SEVERITY_ICONS[item.severity];

groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
const multi = group.items.length > 1;
return (
<div
key={item.id}
className={`relative flex flex-col gap-1 rounded-lg border p-3 transition-all ${
item.read
? "border-white/5 bg-white/[0.02] opacity-70"
: `${colors.border} ${colors.bg} shadow-sm`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px] font-bold ${colors.bg} ${colors.text}`}
aria-hidden="true"
>
{icon}
<section key={group.key} aria-label={group.key}>
{multi && (
<button
type="button"
aria-expanded={!isCollapsed}
onClick={() =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(group.key)) next.add(group.key);
return next;
})
}
className="mb-1.5 flex w-full items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-pm-text/50 hover:text-pm-text"
>
<span>
{group.key.replace(":", " ")} · {group.items.length}
</span>
<h4 className="text-xs font-bold text-pm-text">{item.title}</h4>
</div>
{!item.read && (
<button
type="button"
onClick={() => markAsRead(item.id)}
className="text-[10px] text-pm-text/40 hover:text-pm-text"
title="Mark as read"
>
Mark read
</button>
)}
</div>
<p className="text-[11px] leading-relaxed text-pm-text/70">{item.message}</p>
{item.href && (
<div className="mt-1 flex justify-end">
<Link
href={item.href}
onClick={() => {
markAsRead(item.id);
setIsOpen(false);
}}
className="text-[11px] font-semibold text-pm-violet hover:underline"
>
View Details →
</Link>
</div>
{group.unread > 0 && (
<span className="text-pm-violet">{group.unread} new</span>
)}
</button>
)}
</div>
<div className="space-y-2.5">
{!isCollapsed &&
group.items.map((item) => {
const colors = SEVERITY_COLORS[item.severity];
const icon = SEVERITY_ICONS[item.severity];

return (
<div
key={item.id}
className={`relative flex flex-col gap-1 rounded-lg border p-3 transition-all ${
item.read
? "border-white/5 bg-white/[0.02] opacity-70"
: `${colors.border} ${colors.bg} shadow-sm`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px] font-bold ${colors.bg} ${colors.text}`}
aria-hidden="true"
>
{icon}
</span>
<h4 className="text-xs font-bold text-pm-text">{item.title}</h4>
</div>
{!item.read && (
<button
type="button"
onClick={() => markAsRead(item.id)}
className="text-[10px] text-pm-text/40 hover:text-pm-text"
title="Mark as read"
>
Mark read
</button>
)}
</div>
<p className="text-[11px] leading-relaxed text-pm-text/70">
{item.message}
</p>
{item.href && (
<div className="mt-1 flex justify-end">
<Link
href={item.href}
onClick={() => {
markAsRead(item.id);
setIsOpen(false);
}}
className="text-[11px] font-semibold text-pm-violet hover:underline"
>
View Details →
</Link>
</div>
)}
</div>
);
})}
</div>
</section>
);
})
)}
Expand Down
35 changes: 33 additions & 2 deletions src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { realtimeClient } from "@/lib/realtime/client";
import { mergeNotifications } from "@/lib/notificationGroups";
import { useWallet } from "@/lib/wallet/WalletProvider";
import { useHolderPolicies } from "./useHolderPolicies";
import { useClaims } from "./useClaims";
Expand All @@ -23,6 +25,31 @@ export function useNotifications() {
const { data: oracleReadings } = useOracleStatus();

const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [pushed, setPushed] = useState<RefractNotification[]>([]);

// Realtime delivers new items; REST-derived items below supply history.
// If the topic is disabled or unhealthy nothing arrives and REST still works.
useEffect(() => {
return realtimeClient.subscribe("notifications", (payload) => {
const item = payload as Partial<RefractNotification> | null;
if (!item || typeof item.id !== "string" || typeof item.timestamp !== "number") return;
setPushed((prev) => mergeNotifications([item as RefractNotification], prev));
});
}, []);

// Reflect read state changes made in other tabs.
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== STORAGE_KEY || !e.newValue) return;
try {
setReadIds(new Set(JSON.parse(e.newValue)));
} catch {
// Ignore malformed value
}
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);

useEffect(() => {
try {
Expand All @@ -45,14 +72,18 @@ export function useNotifications() {
};

const notifications = useMemo(() => {
return deriveNotifications({
const derived = deriveNotifications({
policies,
claims,
lockupExpiresAt,
oracleReadings,
readIds,
});
}, [policies, claims, lockupExpiresAt, oracleReadings, readIds]);
return mergeNotifications(
pushed.map((n) => ({ ...n, read: readIds.has(n.id) })),
derived
);
}, [policies, claims, lockupExpiresAt, oracleReadings, readIds, pushed]);

const unreadCount = useMemo(() => {
return notifications.filter((n) => !n.read).length;
Expand Down
35 changes: 35 additions & 0 deletions src/hooks/useRealtimeRevalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { realtimeClient } from "@/lib/realtime/client";
import { createRefreshScheduler, TOPIC_REVALIDATION_TAGS } from "@/lib/realtime/revalidation";

/**
* Refreshes the current route's Server Components when realtime topics that
* feed them push events. router.refresh() preserves client state, scroll and
* subscriptions. Mount once near the app root.
*/
export function useRealtimeRevalidation({
enabled = true,
isBlocked,
}: { enabled?: boolean; isBlocked?: () => boolean } = {}) {
const router = useRouter();

useEffect(() => {
if (typeof window === "undefined") return;
const scheduler = createRefreshScheduler({
refresh: () => router.refresh(),
enabled: () => enabled,
isBlocked,
log: (m) => console.debug(m),
});
const unsubs = Object.keys(TOPIC_REVALIDATION_TAGS).map((topic) =>
realtimeClient.subscribe(topic, () => scheduler.notify(topic))
);
return () => {
unsubs.forEach((u) => u());
scheduler.cancel();
};
}, [router, enabled, isBlocked]);
}
36 changes: 36 additions & 0 deletions src/lib/notificationGroups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { groupNotifications, mergeNotifications } from "./notificationGroups";
import type { RefractNotification } from "./notifications";

const n = (
id: string,
timestamp: number,
extra: Partial<RefractNotification> = {}
): RefractNotification => ({
id,
type: "claim_payout",
title: id,
message: "",
timestamp,
read: false,
severity: "info",
...extra,
});

describe("notification groups", () => {
it("dedupes by id and orders by timestamp", () => {
const merged = mergeNotifications([n("a", 1), n("b", 3)], [n("a", 1), n("c", 2)]);
expect(merged.map((x) => x.id)).toEqual(["b", "c", "a"]);
});

it("groups by key with unread counts, newest group first", () => {
const groups = groupNotifications([
n("1", 1, { groupKey: "claim:x" }),
n("2", 5, { groupKey: "claim:x", read: true }),
n("3", 9, { groupKey: "policy:y" }),
]);
expect(groups.map((g) => g.key)).toEqual(["policy:y", "claim:x"]);
expect(groups[1]).toMatchObject({ unread: 1, latest: 5 });
expect(groups[1].items.map((x) => x.id)).toEqual(["2", "1"]);
});
});
30 changes: 30 additions & 0 deletions src/lib/notificationGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { RefractNotification } from "./notifications";

export interface NotificationGroup {
key: string;
items: RefractNotification[];
unread: number;
latest: number;
}

/** Merges two copies of the notification lists, deduplicating by id (first wins). */
export function mergeNotifications(...lists: RefractNotification[][]): RefractNotification[] {
const byId = new Map<string, RefractNotification>();
for (const list of lists) for (const n of list) if (!byId.has(n.id)) byId.set(n.id, n);
return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp);
}

/** Groups by `groupKey` (falling back to type); groups ordered by latest item, items by timestamp desc. */
export function groupNotifications(items: RefractNotification[]): NotificationGroup[] {
const groups = new Map<string, NotificationGroup>();
for (const n of items) {
const key = n.groupKey ?? n.type;
const g = groups.get(key) ?? { key, items: [], unread: 0, latest: 0 };
g.items.push(n);
if (!n.read) g.unread += 1;
g.latest = Math.max(g.latest, n.timestamp);
groups.set(key, g);
}
for (const g of groups.values()) g.items.sort((a, b) => b.timestamp - a.timestamp);
return [...groups.values()].sort((a, b) => b.latest - a.latest);
}
4 changes: 4 additions & 0 deletions src/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface RefractNotification {
timestamp: number;
read: boolean;
href?: string;
/** Groups related events (e.g. per policy or claim); defaults to type. */
groupKey?: string;
severity: "info" | "warning" | "success" | "critical";
}

Expand Down Expand Up @@ -55,6 +57,7 @@ export function deriveNotifications({
const id = `policy-exp-${policy.id}`;
notifications.push({
id,
groupKey: `policy:${policy.id}`,
type: "policy_expiring",
title: `Policy Expiring Soon`,
message: `Your ${policy.coverageTypeName} policy (${formatUsd(
Expand All @@ -77,6 +80,7 @@ export function deriveNotifications({
const payoutFormatted = formatUsd(fromStroops(claim.payout));
notifications.push({
id,
groupKey: `claim:${claim.policyId}`,
type: "claim_payout",
title: `Claim Payout Disbursed`,
message: `${payoutFormatted} was automatically disbursed for policy ${claim.policyId.slice(0, 8)}… (${claim.reason}).`,
Expand Down
Loading