@mounaji_npm/notifications
v0.1.0
Published
Notification system — multi-channel (web, email, push), provider, hooks, and UI components.
Maintainers
Readme
@mounaji_npm/notifications
Multi-channel notification system — in-app web notifications, email via Resend, and an extensible channel architecture ready for push, WhatsApp, and any custom delivery channel.
Includes: React provider, hooks, bell widget, real-time toasts, history page, and preferences panel.
Install
npm install @mounaji_npm/notificationsPeer dependencies:
npm install react # >=17.0.0@mounaji_npm/event-core is a direct dependency — installed automatically.
Quick start
// 1. Wrap your app with NotificationProvider
import { NotificationProvider, WebChannel, EmailChannel } from '@mounaji_npm/notifications';
export default function RootLayout({ children }) {
const webChannel = new WebChannel({
onFetch: () => fetch('/api/events').then(r => r.json()),
onMarkRead: (ids) => fetch('/api/events/mark-read', { method: 'POST', body: JSON.stringify({ ids }) }),
onMarkAllRead: () => fetch('/api/events/mark-all-read', { method: 'POST' }),
});
const emailChannel = new EmailChannel({
apiEndpoint: '/api/notifications/send-email',
});
return (
<NotificationProvider channels={[webChannel, emailChannel]}>
{children}
<NotificationToast isDark={true} />
</NotificationProvider>
);
}
// 2. Add the bell to your TopNav
import { NotificationBell } from '@mounaji_npm/notifications';
<NotificationBell isDark={isDark} historyPath="/notifications" onNavigate={router.push} />
// 3. Add the history page
import { NotificationHistoryPage } from '@mounaji_npm/notifications';
export default function NotificationsPage() {
return <NotificationHistoryPage isDark={isDark} />;
}Exports
import {
// Channels
BaseChannel, WebChannel, EmailChannel, PushChannel, ChannelRegistry,
// Service
NotificationService,
// Provider & context
NotificationProvider, useNotificationContext,
// Hooks
useNotifications, useUnreadCount,
// UI Components
NotificationBell, NotificationToast,
NotificationHistoryPage, NotificationPreferences,
} from '@mounaji_npm/notifications';
// Server-side only (Node.js / Next.js Route Handlers)
import { createResendSender } from '@mounaji_npm/notifications/server';Channels
The channel system is the delivery layer. Each channel implements a standard interface, and the NotificationService orchestrates across all registered channels.
WebChannel — In-app notifications
Fetches and updates stored notifications via adapter functions you provide. Does not hardcode any API path or database client — stays portable across backends.
import { WebChannel } from '@mounaji_npm/notifications';
const webChannel = new WebChannel({
onFetch: async () => fetch('/api/events').then(r => r.json()),
onMarkRead: async (ids) => fetch('/api/events/mark-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
}),
onMarkAllRead: async () => fetch('/api/events/mark-all-read', { method: 'POST' }),
});| Constructor option | Type | Description |
|---|---|---|
| onFetch | async () => Notification[] | Fetches the notification list |
| onMarkRead | async (ids: string[]) => void | Marks specific notifications as read |
| onMarkAllRead | async () => void | Marks all notifications as read |
EmailChannel — Email via API endpoint
Calls your own API route, which uses createResendSender server-side.
The Resend API key never enters the client bundle.
import { EmailChannel } from '@mounaji_npm/notifications';
// Option A — POST to your API route
const emailChannel = new EmailChannel({
apiEndpoint: '/api/notifications/send-email',
});
// Option B — custom async function (e.g. a Next.js Server Action)
const emailChannel = new EmailChannel({
onSend: async (payload) => sendEmailServerAction(payload),
});| Constructor option | Type | Description |
|---|---|---|
| apiEndpoint | string | Your POST endpoint that proxies to Resend |
| onSend | async (payload) => void | Alternative to apiEndpoint |
| enabled | boolean | Defaults to true |
PushChannel — Web Push / FCM (Phase 2)
Architecture stub — registered in the system but disabled until Phase 2.
import { PushChannel } from '@mounaji_npm/notifications';
const pushChannel = new PushChannel({
// Phase 2 options: vapidPublicKey, subscriptionEndpoint, etc.
});
// isEnabled() returns false until implementedCustom channels
Extend BaseChannel to add any delivery channel — WhatsApp, Slack, SMS, webhooks, etc.
import { BaseChannel } from '@mounaji_npm/notifications';
class WhatsAppChannel extends BaseChannel {
name = 'whatsapp';
get canSend() { return true; }
constructor({ token, phoneNumberId }) {
super();
this._token = token;
this._phoneNumberId = phoneNumberId;
}
async send({ to, title, message }) {
await fetch(`https://graph.facebook.com/v18.0/${this._phoneNumberId}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${this._token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: `${title}\n${message ?? ''}` },
}),
});
}
}BaseChannel interface
| Property / Method | Description |
|---|---|
| name | string — unique channel identifier |
| canFetch | boolean (getter) — can retrieve stored notifications |
| canSend | boolean (getter) — can deliver outbound notifications |
| isEnabled() | Returns true if channel is active |
| fetch() | Returns Notification[] |
| markRead(ids) | Marks specific notifications as read |
| markAllRead() | Marks all as read |
| send(notification) | Sends a notification through this channel |
NotificationService
Orchestrates all registered channels. Typically created internally by NotificationProvider, but can be instantiated directly for non-React usage or advanced control.
import { NotificationService, WebChannel, EmailChannel } from '@mounaji_npm/notifications';
const service = new NotificationService({
channels: [webChannel, emailChannel],
});
// Fetch in-app notifications (from the first canFetch channel)
const notifications = await service.fetch();
// Mark specific notifications as read (across all fetch channels)
await service.markRead(['id1', 'id2']);
// Mark all as read
await service.markAllRead();
// Send via specific channels
await service.send({
title: 'Stock crítico: Harina 000',
to: '[email protected]', // used by email channel
channels: ['web', 'email'], // omit to use all enabled send channels
severity: 'danger',
metadata: { ingredient: 'Harina 000', available: '2kg', required: '15kg' },
});
// Add a channel dynamically
service.addChannel(new WhatsAppChannel({ token, phoneNumberId }));API reference
| Method | Description |
|---|---|
| fetch() | Fetches from the primary canFetch channel |
| markRead(ids) | Marks read on all canFetch channels |
| markAllRead() | Marks all read on all canFetch channels |
| send(notification) | Sends via channels listed in notification.channels, or all enabled canSend channels |
| addChannel(channel) | Register a channel at runtime |
| registry | Direct access to the ChannelRegistry instance |
NotificationProvider
React context root. Wraps your app (or a subtree) to enable all hooks and connected components.
Handles:
- Initial fetch on mount
- Background poll every
pollIntervalmilliseconds - Real-time bridge — listens to
mn:events-updatedDOM event dispatched by your API client
import { NotificationProvider, WebChannel, EmailChannel } from '@mounaji_npm/notifications';
<NotificationProvider
channels={[webChannel, emailChannel]}
config={{
pollInterval: 60_000, // background poll interval in ms (0 = disabled)
watchEvent: 'mn:events-updated', // DOM event to listen for (default value shown)
}}
>
{children}
</NotificationProvider>Advanced: Pass a pre-built
NotificationServiceinstance viaserviceinstead ofchannels:<NotificationProvider service={myService}>...</NotificationProvider>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| channels | BaseChannel[] | [] | Channel instances to register |
| service | NotificationService | — | Pre-built service; overrides channels if provided |
| config.pollInterval | number | 60_000 | Background poll in ms; 0 to disable |
| config.watchEvent | string | 'mn:events-updated' | DOM event that triggers a silent re-fetch |
Hooks
useNotifications()
Primary hook. Must be used inside <NotificationProvider> — throws otherwise.
import { useNotifications } from '@mounaji_npm/notifications';
const {
notifications, // Notification[] — full list (read + unread)
unreadCount, // number
loading, // boolean — true during the initial fetch or manual refresh
refresh, // async () => void — manual re-fetch (shows loading spinner)
markRead, // (id: string) => void — optimistic update + persists via channel
markAllRead, // () => void — optimistic update + persists via channel
send, // (notification) => Promise<void> — send via service
service, // NotificationService — direct access
} = useNotifications();useUnreadCount()
Lightweight hook — returns only the unread count. Returns 0 safely if there is no provider in the tree (no throw), making it safe for standalone components.
import { useUnreadCount } from '@mounaji_npm/notifications';
const unreadCount = useUnreadCount(); // 0 if no provider in treeUI Components
All components read Mounaji design tokens (--mn-* CSS variables) and fall back gracefully to built-in defaults.
NotificationBell
Context-aware bell widget for the TopNav. Requires <NotificationProvider> in the tree.
import { NotificationBell } from '@mounaji_npm/notifications';
<NotificationBell
isDark={isDark}
historyPath="/notifications"
onNavigate={router.push}
/>| Prop | Type | Default | Description |
|---|---|---|---|
| isDark | boolean | true | Dark/light mode |
| historyPath | string | '/notifications' | Path for the "Ver historial completo" link |
| onNavigate | (path) => void | — | Navigation callback (e.g. router.push) |
Features:
- Unread count badge (red dot on the bell icon)
- Dropdown panel with the 8 most recent notifications
- Click to expand metadata details per row
- "Marcar leídas" button (marks all read)
- "+N más" when over 8 notifications
- Clicking "Ver historial completo" calls
onNavigate(historyPath)
NotificationToast
Real-time toast stack that appears in a corner when new unread notifications arrive. Does not toast notifications that exist on initial load — only new arrivals after mount.
Place it as a sibling inside <NotificationProvider>.
import { NotificationToast } from '@mounaji_npm/notifications';
<NotificationProvider channels={[...]}>
<App />
<NotificationToast isDark={isDark} position="bottom-right" duration={5000} />
</NotificationProvider>| Prop | Type | Default | Description |
|---|---|---|---|
| isDark | boolean | true | Dark/light mode |
| position | string | 'bottom-right' | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' |
| duration | number | 5000 | Auto-dismiss in ms |
| maxVisible | number | 5 | Max toasts shown simultaneously |
NotificationHistoryPage
Full-page notification history. Designed to fill a page layout — set max-width, centered.
import { NotificationHistoryPage } from '@mounaji_npm/notifications';
// app/notifications/page.jsx (Next.js App Router)
export default function NotificationsPage() {
return <NotificationHistoryPage isDark={isDark} />;
}| Prop | Type | Default | Description |
|---|---|---|---|
| isDark | boolean | true | Dark/light mode |
| onNavigate | (path) => void | — | Navigation callback |
Features:
- Filter tabs: Todas / Sin leer (with unread count badge)
- Category filter pills: all categories + custom registered ones
- Notifications grouped by date: Hoy / Ayer / Esta semana / Anteriores
- Expandable rows with full metadata
- "Cargar más" pagination (25 per page)
- "Marcar todas como leídas" button
NotificationPreferences
User-facing panel to enable/disable notification channels.
import { NotificationPreferences } from '@mounaji_npm/notifications';
<NotificationPreferences
isDark={isDark}
onSave={(prefs) => saveUserPreferences(prefs)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
| isDark | boolean | true | Dark/light mode |
| onSave | (prefs: Record<string, boolean>) => void | — | Called with channel enable/disable map |
Channel rows are derived from the NotificationService.registry — only registered channels appear.
Channels not yet implemented (push, whatsapp) appear with a "Pronto" badge and are disabled.
Server-side: createResendSender
Import from @mounaji_npm/notifications/server — never import in client code.
This export path is not bundled by Vite and runs in Node.js only.
import { createResendSender } from '@mounaji_npm/notifications/server';
const sender = createResendSender({
apiKey: process.env.RESEND_API_KEY, // required
from: 'Mounaji <[email protected]>', // optional, defaults to placeholder
});
// Send a raw email
await sender.sendEmail({
to: '[email protected]',
subject: 'Bienvenido a Mounaji',
html: '<h1>Hola!</h1>',
text: 'Hola!',
});
// Send a notification object — HTML template is auto-generated
await sender.sendNotification({
to: '[email protected]',
notification: {
title: 'Stock crítico: Harina 000',
severity: 'danger',
metadata: { available: '2kg', required: '15kg' },
},
});Wire-up in a Next.js Route Handler
// app/api/notifications/send-email/route.js
import { createResendSender } from '@mounaji_npm/notifications/server';
const sender = createResendSender({
apiKey: process.env.RESEND_API_KEY,
from: 'Mounaji <[email protected]>',
});
export async function POST(req) {
const body = await req.json();
// body is the payload sent by EmailChannel.send()
await sender.sendNotification({ to: body.to, notification: body });
return Response.json({ ok: true });
}Wire-up in a Next.js Server Action
// lib/actions/notifications.js
'use server';
import { createResendSender } from '@mounaji_npm/notifications/server';
const sender = createResendSender({ apiKey: process.env.RESEND_API_KEY });
export async function sendEmailAction(payload) {
return sender.sendNotification({ to: payload.to, notification: payload });
}createResendSender options
| Option | Required | Description |
|---|---|---|
| apiKey | ✅ | Resend API key — from process.env.RESEND_API_KEY |
| from | — | Sender address shown in the email; defaults to placeholder |
sender.sendEmail(params)
| Param | Required | Description |
|---|---|---|
| to | ✅ | Recipient email or array of emails |
| subject | ✅ | Email subject line |
| html | — | HTML body |
| text | — | Plain text body |
| fromOverride | — | Override the from address for this send |
sender.sendNotification(params)
| Param | Required | Description |
|---|---|---|
| to | ✅ | Recipient email or array of emails |
| notification | ✅ | Notification object (title, severity, metadata, ...) |
| subject | — | Override subject; defaults to notification.title |
| html | — | Override HTML; auto-generated from notification if omitted |
| text | — | Override plain text; auto-generated if omitted |
Architecture overview
<NotificationProvider> ← React context root
channels: [WebChannel, EmailChannel]
config: { pollInterval, watchEvent }
│
├── NotificationService ← orchestrator
│ └── ChannelRegistry ← Map<name, BaseChannel>
│ ├── WebChannel ← canFetch, in-app storage
│ ├── EmailChannel ← canSend, Resend via API
│ └── PushChannel ← stub (Phase 2)
│
├── State: notifications[], loading, unreadCount
│
├── Real-time: window 'mn:events-updated' → silentRefresh()
├── Polling: setInterval(silentRefresh, pollInterval)
│
└── Context value exposed to:
useNotifications() ← full context
useUnreadCount() ← unreadCount only (safe w/o provider)
<NotificationBell /> ← dropdown widget
<NotificationToast /> ← corner toast stack
<NotificationHistoryPage />← full history page
<NotificationPreferences />← channel togglesAdding a custom channel (WhatsApp example — Phase 2)
// 1. Extend BaseChannel
import { BaseChannel } from '@mounaji_npm/notifications';
class WhatsAppChannel extends BaseChannel {
name = 'whatsapp';
get canSend() { return true; }
constructor({ token, phoneNumberId }) {
super();
this._token = token;
this._phoneNumberId = phoneNumberId;
}
async send({ to, title, message }) {
const text = message ? `*${title}*\n${message}` : title;
await fetch(`https://graph.facebook.com/v18.0/${this._phoneNumberId}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${this._token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }),
});
}
}
// 2. Register it in the provider
<NotificationProvider
channels={[
webChannel,
emailChannel,
new WhatsAppChannel({
token: process.env.WHATSAPP_TOKEN,
phoneNumberId: process.env.WHATSAPP_PHONE_ID,
}),
]}
>
// 3. Send via WhatsApp
const { send } = useNotifications();
await send({
title: 'Stock crítico: Harina 000',
to: '+5491112345678',
channels: ['whatsapp'],
severity: 'danger',
});Notification shape
The object shape expected by all components and produced by @mounaji_npm/event-core's createEvent:
{
id: string; // UUID
type: string; // EVENT_TYPES constant
category: string; // EVENT_CATEGORIES constant
severity: 'info' | 'success' | 'warning' | 'danger';
priority: 'critical' | 'high' | 'normal' | 'low';
source: string | null;
target: string | null;
title: string;
message?: string; // optional longer description
metadata?: Record<string, unknown>; // shown in expandable panel
read: boolean;
created_at: string; // ISO 8601
color?: string; // CSS color override for the icon bubble
}Related packages
| Package | Role |
|---|---|
| @mounaji_npm/event-core | Event types, bus, registry, DOM bridge — the foundation |
| @mounaji_npm/topnav-widgets | Standalone NotificationBell (no provider required) |
| @mounaji_npm/api-client | HTTP client that dispatches signalEventsUpdate() |
