@mounaji_npm/topnav-widgets
v0.1.2
Published
Modular top-nav widgets: NotificationBell, TodayMenuWidget, QuickSearch — theme-aware, token-driven
Maintainers
Readme
@mounaji_npm/topnav-widgets
Theme-aware, token-driven top-nav widgets for Mounaji SaaS applications. Designed to slot directly into the topNavLeft / topNavRight props of @mounaji_npm/saas-template's AppShell.
Widgets:
NotificationBell— reactive notification badge + expandable list with metadata detailTodayMenuWidget— daily menu quick-access dropdownQuickSearch— global search trigger
Install
npm install @mounaji_npm/topnav-widgetsPeer dependency: React ≥ 17
Exports
import { NotificationBell, TodayMenuWidget, QuickSearch } from '@mounaji_npm/topnav-widgets';NotificationBell
Bell icon widget with unread count badge. Opens a dropdown listing the latest events with support for expandable metadata detail panels. Refreshes reactively — the badge updates automatically when new events are emitted by the server, without requiring a page reload or any app-level polling code.
Basic usage
import { NotificationBell } from '@mounaji_npm/topnav-widgets';
<NotificationBell
isDark={isDark}
onFetch={() => fetch('/api/events').then(r => r.json())}
onMarkAllRead={() => fetch('/api/events/mark-all-read', { method: 'PATCH' })}
onNotificationClick={(n) => fetch(`/api/events/${n.id}/read`, { method: 'PATCH' })}
historyPath="/history"
onNavigate={(path) => router.push(path)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| notifications | Notification[] | [] | Optional static initial list |
| onFetch | async () => Notification[] | — | Lazy-fetch called every time the dropdown opens |
| onMarkAllRead | () => void | — | Called when "Marcar leídas" is clicked |
| onNotificationClick | (n: Notification) => void | — | Called when a row is clicked |
| historyPath | string | '/history' | Path for the "Ver historial completo →" footer link |
| onNavigate | (path: string) => void | — | Router push used by the history footer link |
| watchEvent | string | 'mn:events-updated' | DOM CustomEvent name to listen for background refresh |
| pollInterval | number (ms) | 60000 | Background poll interval in ms. Set 0 to disable polling. |
| isDark | boolean | true | Theme mode |
Notification shape
onFetch must return an array of objects with this shape (matches the events Supabase table):
{
id: string | number,
title: string, // Primary notification text
message?: string, // Fallback if title is absent
created_at?: string, // ISO timestamp → "hace 2 min" (preferred)
time?: string, // Static override if created_at is absent
read: boolean,
severity?: 'info' | 'warning' | 'danger' | 'success',
category?: 'user' | 'menu' | 'stock' | 'production' | 'system',
color?: string, // CSS color override
metadata?: object, // Key-value pairs shown in expandable detail panel
}Category icons:
| Category | Icon shape | Color |
|---|---|---|
| user | Person silhouette | Navy |
| menu | Arrow/send | Teal |
| stock | Warning triangle | Amber |
| production | Pulse/waveform | Green |
| system | Info circle / danger circle | Muted / Red |
Metadata detail panel:
When a notification has a non-empty metadata object, clicking the row expands a detail panel below it showing key-value pairs. Built-in label translations (Spanish):
nombre → Nombre tipo → Tipo fecha → Fecha
semana → Semana escuela → Escuela rol → Rol
ingrediente → Ingrediente faltante → Faltante
raciones → Raciones estado → Estado cantidad → CantidadReactive refresh system
NotificationBell manages its own reactivity internally. The app only needs to wire onFetch — no polling state, no useEffect, no setInterval in the app code.
How it works:
Server emits event → API route returns x-events-updated: 1
↓
App's API client detects header → calls signalEventsUpdate()
↓ (from @mounaji_npm/event-core)
window.dispatchEvent(new CustomEvent('mn:events-updated'))
↓
NotificationBell.useEffect([watchEvent]) fires
↓
Silent onFetch() → setItems(data) → badge updated ✅Background poll (every pollInterval ms) catches events not triggered by this client session — e.g. stock alerts computed server-side, or actions by other users.
To disable polling:
<NotificationBell pollInterval={0} onFetch={...} />To use a custom DOM event name:
<NotificationBell watchEvent="my-app:events-updated" onFetch={...} />Full wiring example (Next.js App Router)
// app/ClientShell.js
import { useCallback } from 'react';
import { NotificationBell } from '@mounaji_npm/topnav-widgets';
import { apiGet, apiPatch, apiClear } from '../lib/services/_apiClient.js';
function TopNavRight({ isDark, router }) {
const handleFetch = useCallback(() => {
apiClear('/api/events');
return apiGet('/api/events');
}, []);
const handleMarkAllRead = useCallback(async () => {
await apiPatch('/api/events/mark-all-read', {});
apiClear('/api/events');
}, []);
const handleClick = useCallback((n) => {
apiPatch(`/api/events/${n.id}/read`, {}).then(() => apiClear('/api/events'));
}, []);
return (
<NotificationBell
isDark={isDark}
onFetch={handleFetch}
onMarkAllRead={handleMarkAllRead}
onNotificationClick={handleClick}
historyPath="/history"
onNavigate={(path) => router.push(path)}
/>
);
}Server-side: emitting the refresh signal
For NotificationBell to refresh automatically after a mutation, the API route must include the x-events-updated: 1 header:
// app/api/menus/route.js
export const POST = async (req) => {
const { data } = await createMenu(await req.json());
return Response.json(data, {
status: 201,
headers: { 'x-events-updated': '1' }, // ← triggers NotificationBell refresh
});
};And the API client must detect it and dispatch the DOM event:
// lib/services/_apiClient.js
import { signalEventsUpdate } from '@mounaji_npm/event-core';
async function _request(path, options = {}) {
const res = await fetch(path, options);
if (res.headers.get('x-events-updated') === '1') {
apiClear('/api/events');
signalEventsUpdate();
}
// ...
}TodayMenuWidget
Quick-access dropdown showing today's menu plan grouped by meal type (Desayuno / Almuerzo / Merienda) and course.
Usage
import { TodayMenuWidget } from '@mounaji_npm/topnav-widgets';
<TodayMenuWidget
date="2026-04-26"
isDark={isDark}
onFetch={(date) => fetch(`/api/planning/${date}`).then(r => r.json())}
onNavigate={(path) => router.push(path)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| date | string | today (ISO) | Date to display — 'YYYY-MM-DD' |
| onFetch | async (date) => { plans: Plan[] } | — | Fetches planning data for the given date |
| onNavigate | (path: string) => void | — | Router push for "Ver planificación completa" |
| isDark | boolean | true | Theme mode |
| label | string | 'Menú Hoy' | Button label |
| planningPath | string | /planning/{date} | Override for the footer navigation link |
Expected data shape
onFetch should return an array of plan records (or { plans: PlanRecord[] }):
{
menu: {
id: string,
name: string,
meal_type: 'desayuno' | 'almuerzo' | 'merienda',
course?: 'entrada' | 'principal' | 'guarnicion' | 'postre' | 'solido' | 'bebida',
},
school?: { name: string },
student_count?: number,
}Meal type colors: Desayuno → amber, Almuerzo → green, Merienda → teal
Course colors: Entrada → teal, Principal → green, Postre → amber, Guarnición → purple
QuickSearch
Minimal search trigger for the TopNav. Currently a UI placeholder — wire onSearch to your search provider.
import { QuickSearch } from '@mounaji_npm/topnav-widgets';
<QuickSearch
isDark={isDark}
onSearch={(query) => router.push(`/search?q=${query}`)}
placeholder="Buscar..."
/>Theme system
All widgets read from CSS variables injected by @mounaji_npm/tokens. No hardcoded colors.
Variables used:
| Variable | Usage |
|---|---|
| --mn-color-nav-dark/light | Bell button background |
| --mn-color-card-dark/light | Dropdown panel background |
| --mn-border-dark/light | Panel and row borders |
| --mn-topnav-text-primary-dark/light | Primary text |
| --mn-topnav-text-secondary-dark/light | Secondary text |
| --mn-topnav-text-muted-dark/light | Muted/timestamp text |
| --mn-color-primary | Unread dot, "ver más" links, primary accents |
| --mn-color-danger | Unread badge on bell icon |
| --mn-color-warning | Warning severity notifications |
| --mn-color-success | Success severity notifications |
| --mn-shadow-lg | Dropdown shadow |
| --mn-radius-lg | Dropdown border radius |
TopNav-specific text tokens (--mn-topnav-text-*) fall back to --mn-nav-text-* if not set, so existing themes require no changes.
Integration with AppShell
Pass widgets into the topNavLeft / topNavRight slots of AppShell:
import { AppShell } from '@mounaji_npm/saas-template';
import { NotificationBell, TodayMenuWidget } from '@mounaji_npm/topnav-widgets';
<AppShell
modules={modules}
isDark={isDark}
topNavLeft={
<TodayMenuWidget
date={today}
isDark={isDark}
onFetch={(d) => apiGet(`/api/planning/${d}`)}
onNavigate={(p) => router.push(p)}
/>
}
topNavRight={
<NotificationBell
isDark={isDark}
onFetch={() => apiGet('/api/events')}
onMarkAllRead={() => apiPatch('/api/events/mark-all-read', {})}
onNotificationClick={(n) => apiPatch(`/api/events/${n.id}/read`, {})}
historyPath="/history"
onNavigate={(p) => router.push(p)}
/>
}
>
{children}
</AppShell>