@asteby/metacore-notifications 38.0.0 → 38.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,101 @@ import { NotificationsDropdown } from '@asteby/metacore-notifications'
51
51
  - `metadata.addon_key` / `apartado` — module chip (Inventario, Almacén, POS, …)
52
52
  - `metadata.color` — optional CSS/hex override for the icon disc
53
53
 
54
+ ## Host with its own contract
55
+
56
+ Everything below is optional; without these props the dropdown behaves exactly
57
+ as before (`GET apiBasePath` → `{ data: [] }`, one `PATCH ${apiBasePath}/${id}`
58
+ per item, unread count computed over the loaded page, empty message while
59
+ loading or on a failed request). Only the live-toast id changed (`ntf-<id>`).
60
+
61
+ | Prop | Purpose |
62
+ |---|---|
63
+ | `listParams` | Object or `(perPage) => object` replacing the default `orderBy/orderDir/per_page` query. |
64
+ | `parseList(body)` | Reads the response: `{ items, unreadCount?, total? }`. A server `unreadCount` wins over the local count. |
65
+ | `normalizeItem(raw)` | Maps each row **and each live payload** to a `NotificationItem` (`read_at` → `is_read`, `action_url` → `link`…). `NotificationItem.id` may be `string \| number`. |
66
+ | `onMarkRead(ids)` / `onMarkAllRead()` | Replace the default PATCHes. Optimistic update, rolled back if the promise rejects. With only `onMarkRead`, "mark all" calls it with the unread ids. |
67
+ | `onDelete(id)` | Shows a trash button per row (second click confirms; rolled back on failure). Labels: `delete`, `confirmDelete`. |
68
+ | `fetchUnreadCount` + `unreadPollMs` | Server counter, polled only while the tab is visible (and on becoming visible). `0`/unset = no polling. |
69
+ | `badgeMax`, `triggerAriaLabel(unread)` | Badge cap (default `99` → `99+`) and trigger `aria-label`. |
70
+ | `onIngest(payload, item)` | Called once per NEW live notification (after dedup) — sound, query invalidation, hidden-tab push belong to the host. |
71
+ | `footer`, `closeOnClick`, `renderAvatar`, `item.user` | Extra footer (`ReactNode` or `(close) => ReactNode`), menu close behaviour, custom avatar or `user: { name, avatar }` with initials fallback. |
72
+ | `richText`, `reloadAfterPermission` | See below; `reloadAfterPermission={false}` skips the page reload after granting push permission. |
73
+ | `showLoadStates` (default `false`) | Opt in to the "loading" and "error + retry" states (`labels.loading / error / retry`). Off, the list shows the usual empty message while loading and after a failed request, as before. |
74
+
75
+ `onNotificationClick` receives the full normalized item (metadata included).
76
+ When it is present nothing is opened by default; without it only absolute
77
+ `http(s)` links are opened (`window.open`), relative links are a no-op — pass
78
+ `onNotificationClick` to navigate inside a SPA. The live toast delegates its
79
+ click to the same handler.
80
+
81
+ `closeOnClick`: Radix already closes the menu when an item is selected, so the
82
+ unset default keeps closing. `true` closes explicitly, `false` keeps it open.
83
+
84
+ Deleting from the keyboard: focus the row (arrow keys) and press `Delete`
85
+ (Supr); a live region announces `labels.confirmDeleteKey` ("Pulsa Supr otra vez
86
+ para eliminar") and a second `Delete` on the same row confirms. Moving focus
87
+ away cancels. The trash button is a pointer-only affordance (`aria-hidden`,
88
+ `tabIndex=-1`) because an interactive control inside a `role="menuitem"` is not
89
+ valid ARIA and Radix only focuses items.
90
+
91
+ Concurrency: optimistic rollbacks are incremental (they only count rows still
92
+ present and currently read/unread, so live notifications that arrived meanwhile
93
+ are kept), a failed delete restores the row with its current read flag, and
94
+ `fetchUnreadCount` results that overlap an in-flight action are discarded (the
95
+ next poll reconciles). `listParams` as a function must be deterministic (no
96
+ `Date.now()`): it is serialized every render to detect changes. Links are opened
97
+ with `window.open(url, '_blank', 'noopener,noreferrer')`; live toasts use the id
98
+ `ntf-<id>` so numeric ids cannot collide with sonner's own.
99
+
100
+ ### Example: `{ notifications, unread_count }` + `read_at`
101
+
102
+ ```tsx
103
+ <NotificationsDropdown
104
+ apiClient={api}
105
+ apiBasePath='/notifications'
106
+ listParams={(n) => ({ limit: n })}
107
+ parseList={(body) => {
108
+ const b = body as { notifications: unknown[]; unread_count: number }
109
+ return { items: b.notifications, unreadCount: b.unread_count }
110
+ }}
111
+ normalizeItem={(raw) => {
112
+ const r = raw as Record<string, any>
113
+ return {
114
+ id: r.id, // numeric ids are fine
115
+ title: r.title,
116
+ message: r.body ?? '',
117
+ type: r.type ?? 'info',
118
+ is_read: r.read_at != null,
119
+ created_at: r.created_at,
120
+ link: r.action_url,
121
+ image: r.image_url,
122
+ metadata: r.meta_payload,
123
+ }
124
+ }}
125
+ onMarkRead={(ids) => api.post('/notifications/mark-read', { notification_ids: ids }).then(() => {})}
126
+ onMarkAllRead={() => api.post('/notifications/mark-read', { mark_all: true }).then(() => {})}
127
+ onDelete={(id) => api.delete(`/notifications/${id}`).then(() => {})}
128
+ fetchUnreadCount={() => api.get('/notifications/unread-count').then((r) => r.data.count)}
129
+ unreadPollMs={60_000}
130
+ onNotificationClick={(n) => navigate({ to: n.link })}
131
+ onIngest={(payload, item) => playSound()}
132
+ footer={(close) => <Link to='/notifications' onClick={close}>Ver todas</Link>}
133
+ richText={false}
134
+ />
135
+ ```
136
+
137
+ ### Rich text and XSS
138
+
139
+ By default (`richText` true) bodies go through `formatNotificationBodyHtml`,
140
+ a small **regex/tokenizer sanitizer** (allowlist: `strong b em i u mark code br
141
+ span`, no attributes; any other `<` is escaped) rendered with
142
+ `dangerouslySetInnerHTML`. It has hostile-input tests (`<img onerror>`,
143
+ `javascript:` hrefs, `on*` attributes, `<svg onload>`, malformed nesting,
144
+ `<b/onclick=…>`), but it is not a DOM-based sanitizer. The body of campaigns
145
+ and other **user-authored content is untrusted**: pass `richText={false}` to
146
+ the dropdown (and to `showNotificationToast`) so it is painted as plain text
147
+ escaped by React.
148
+
54
149
  ## API
55
150
 
56
151
  - `GET {apiBasePath}` → `{ data: NotificationItem[] }`
@@ -2,6 +2,10 @@ import type { NotificationsDropdownProps } from './types';
2
2
  /**
3
3
  * Bell-icon dropdown: REST list + live ingest (SSE preferred, WebSocket
4
4
  * fallback) + optional canonical card toast on every new item.
5
+ *
6
+ * Every prop beyond `apiClient` / `apiBasePath` is optional; without them the
7
+ * component keeps its original contract (`{ data: [] }` list, one PATCH per
8
+ * item, local unread count).
5
9
  */
6
- export declare function NotificationsDropdown({ apiClient, apiBasePath, enableBadge, onNotificationClick, perPage, locale, labels: labelsOverride, resolveImageUrl, subscribeToNotifications, sseUrl, sseAccessToken, preferSse, showToastOnIngest, }: NotificationsDropdownProps): import("react").JSX.Element;
10
+ export declare function NotificationsDropdown({ apiClient, apiBasePath, enableBadge, onNotificationClick, perPage, locale, labels: labelsOverride, resolveImageUrl, subscribeToNotifications, sseUrl, sseAccessToken, preferSse, showToastOnIngest, listParams, parseList, normalizeItem, onMarkRead, onMarkAllRead, onDelete, fetchUnreadCount, unreadPollMs, badgeMax, triggerAriaLabel, onIngest, footer, closeOnClick, renderAvatar, richText, reloadAfterPermission, showLoadStates, }: NotificationsDropdownProps): import("react").JSX.Element;
7
11
  //# sourceMappingURL=dropdown.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dropdown.d.ts","sourceRoot":"","sources":["../src/dropdown.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAIV,0BAA0B,EAC3B,MAAM,SAAS,CAAA;AAuBhB;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,WAAW,EACX,WAAkB,EAClB,mBAAmB,EACnB,OAAY,EACZ,MAAW,EACX,MAAM,EAAE,cAAc,EACtB,eAAe,EACf,wBAAwB,EACxB,MAAM,EACN,cAAc,EACd,SAAS,EACT,iBAAwB,GACzB,EAAE,0BAA0B,+BA4N5B"}
1
+ {"version":3,"file":"dropdown.d.ts","sourceRoot":"","sources":["../src/dropdown.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAMV,0BAA0B,EAC3B,MAAM,SAAS,CAAA;AA+ChB;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,WAAW,EACX,WAAkB,EAClB,mBAAmB,EACnB,OAAY,EACZ,MAAW,EACX,MAAM,EAAE,cAAc,EACtB,eAAe,EACf,wBAAwB,EACxB,MAAM,EACN,cAAc,EACd,SAAS,EACT,iBAAwB,EACxB,UAAU,EACV,SAAS,EACT,aAAa,EACb,UAAU,EACV,aAAa,EACb,QAAQ,EACR,gBAAgB,EAChB,YAAY,EACZ,QAAa,EACb,gBAAgB,EAChB,QAAQ,EACR,MAAM,EACN,YAAY,EACZ,YAAY,EACZ,QAAe,EACf,qBAA4B,EAC5B,cAAsB,GACvB,EAAE,0BAA0B,+BAke5B"}