@mounaji_npm/notifications
@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/notifications
Peer 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,
NotificationRecipientsPanel,
} from '@mounaji_npm/notifications';
// Server-side only (Node.js / Next.js Route Handlers)
import { createResendSender, createPushSender } 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 (PWA)
Real Web Push via the browser Push API + a Service Worker. The client registers the SW,
requests permission and stores the subscription; delivery is done server-side with
createPushSender (see below). Needs a VAPID key pair (npx web-push generate-vapid-keys).
import { PushChannel } from '@mounaji_npm/notifications';
const push = new PushChannel({
vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
serviceWorkerUrl: '/sw.js',
subscribeEndpoint: '/api/push/subscribe',
});
await push.enable(); // call from a user gesture (button click): registers SW + subscribes
await push.disable(); // unsubscribe this device
| Option | Description |
|---|---|
vapidPublicKey |
App's VAPID public key (safe for the browser) |
serviceWorkerUrl |
Path to your SW file (default /sw.js) |
subscribeEndpoint |
Your API route that stores the subscription (default /api/push/subscribe) |
The SW must handle push / notificationclick events to show the OS notification.
Custom 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: 'chef@restaurant.com', // 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 tree
UI 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 (whatsapp) appear with a "Pronto" badge and are disabled.
NotificationRecipientsPanel
Admin panel to decide WHO receives important-event notifications and through which
channels. It is fully adapter-driven — it has no opinion about your RBAC or storage
backend, so any app can reuse it by wiring four functions. Update the component here and
every consuming app inherits the change with a single npm install.
import { NotificationRecipientsPanel } from '@mounaji_npm/notifications';
<NotificationRecipientsPanel
isDark={isDark}
roleLabels={{ admin: 'Administrador', cocinero: 'Cocinero' }}
fetchUsers={() => api.get('/api/rbac/users')} // → [{ id, email, name, roles: [name] }]
fetchRoles={() => api.get('/api/rbac/roles')} // → [{ id, name, label? }]
loadSettings={() => api.get('/api/admin/notification-settings')}
saveSettings={(s) => api.put('/api/admin/notification-settings', s)}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
isDark |
boolean |
false |
Dark/light mode (uses --mn-* tokens) |
fetchUsers |
() => Promise<User[]> |
— | Users list. User = { id, email, name?, roles: (string | {name})[] } |
fetchRoles |
() => Promise<Role[]> |
— | Roles list. Role = { id?, name, label? } |
loadSettings |
() => Promise<Settings> |
— | Current settings (see shape below) |
saveSettings |
(s: Settings) => Promise<any> |
— | Persists settings; may throw to show an error |
roleLabels |
Record<string,string> |
{} |
Pretty labels per role name |
channels |
{ key, label, desc }[] |
email + push | Which channel toggles to render |
Settings shape (the value passed to saveSettings):
{
email_enabled: boolean,
push_enabled: boolean,
recipient_roles: string[], // role names — every user with the role is included
recipient_user_ids: string[], // specific user ids
extra_emails: string[], // free-form external addresses
}
Data contract. The component never talks to a database — it only consumes the arrays
your adapters return. On the server, expand the saved recipient_roles / recipient_user_ids
into actual emails however your backend models users (e.g. join your roles → users tables),
then deliver via EmailChannel / createPushSender.
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 <noreply@yourdomain.com>', // optional, defaults to placeholder
});
// Send a raw email
await sender.sendEmail({
to: 'user@example.com',
subject: 'Bienvenido a Mounaji',
html: '<h1>Hola!</h1>',
text: 'Hola!',
});
// Send a notification object — HTML template is auto-generated
await sender.sendNotification({
to: 'chef@restaurant.com',
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 <noreply@yourdomain.com>',
});
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 |
Server-side: createPushSender
Import from @mounaji_npm/notifications/server — Node.js only. Requires the optional
web-push dependency (npm i web-push) and a VAPID key pair.
// app/api/push/send/route.js
import { createPushSender } from '@mounaji_npm/notifications/server';
export const runtime = 'nodejs'; // web-push needs Node's crypto (never Edge)
const pusher = createPushSender({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: 'mailto:soporte@example.com',
});
// Fan out to all stored subscriptions:
const { gone } = await pusher.sendToMany(subscriptions, { title, message, url, severity });
// `gone` = endpoints that returned 410/404 — delete them from your store.
| Method | Description |
|---|---|
sendTo(subscription, notification) |
Send to one subscription → { ok, gone } |
sendToMany(subscriptions, notification) |
Send to many → { gone: string[] } (expired endpoints) |
notification: { title, message?, url?, severity?, ... }. The matching client SW receives
the payload and calls showNotification.
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 toggles
Adding 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() |