strapi-plugin-notifier 1.0.2 → 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## [1.1.0] — 2026-06-11
4
+
5
+ ### Added
6
+ - **Batch sending** — `notifier.sendBatch(items[])` sends multiple notifications in one call; settings fetched once and opt-out/merge applied per item.
7
+ - **User opt-out** — new `notification-preference` content-type (`notifier_preferences` table). Each admin user can set `globalOptOut: true` to suppress all notifications, or `mutedTypes: ['info', ...]` to mute by type. Opt-out is applied at query time, covering broadcast and role-targeted notifications.
8
+ - New routes: `GET /notifier/preferences/me`, `PUT /notifier/preferences/me`
9
+ - **Notification merging** — when `merge.enabled` is `true` in settings, sending a notification whose key fields match an existing one (within `windowMinutes`) updates that notification's `mergeCount` instead of creating a new row.
10
+ - New notification fields: `mergeKey` (string), `mergeCount` (integer, default 1)
11
+ - New settings block: `merge.{ enabled, windowMinutes, keyFields, countBadge, rewriteMessage }`
12
+ - Inbox shows a coloured `×N` count badge when `countBadge` is on
13
+ - Merge settings editable at runtime via **Settings → Notifier → Merge**
14
+ - **Unit tests** — 61 tests covering `mergeWithDefaults`, preference service (isOptedOut, get, upsert), notification service (opt-out filtering, findMergeCandidate, mergeInto, createMany), and notifier service (send, sendBatch, merge paths, opt-out, convenience wrappers).
15
+
16
+ ### Migration notes
17
+ - A new `notifier_preferences` table is created automatically on next Strapi start — no manual migration needed.
18
+ - Two new columns (`mergeKey`, `mergeCount`) are added to the existing `notifier_notifications` table — existing rows are unaffected (columns are nullable / have defaults).
19
+ - Existing stored settings in the plugin store are unaffected; the new `merge` config block defaults to `enabled: false`.
20
+
21
+ ---
22
+
23
+ ## [1.0.3] — 2026-06-11
24
+
25
+ ### Fixed
26
+ - Settings link used `React.lazy()` wrapper — Strapi's `createSettingSection` validates `typeof Component === 'function'`; `lazy()` returns an object and fails that check. Changed to plain `() => import(...)`.
27
+ - `@strapi/admin` moved to `peerDependencies` so `pack-up` marks it external; previously `useFetchClient` was bundled into the plugin chunk, causing a blank admin panel when using a linked copy of the plugin.
28
+ - Admin route index exported an array instead of an object, preventing route registration.
29
+
30
+ ---
31
+
32
+ ## [1.0.2] — 2026-06-10
33
+
34
+ ### Added
35
+ - Initial public release.
36
+ - Live bell icon, notification inbox, targeting (broadcast / role / user), configurable retention, delivery settings, UI accent theme, and Settings panel.
package/README.md CHANGED
@@ -7,7 +7,10 @@ A first-class notification system for Strapi v5 admin panels. Adds a live bell i
7
7
  - **Live bell icon** — badge count updated by polling the server (interval configurable)
8
8
  - **Notification inbox** — filter by type, mark as read, dismiss, load more, clear all
9
9
  - **Targeting** — broadcast to all admins, or target by user ID or role code
10
- - **Configurable** — poll interval, page size, retention policy, and UI accent colours
10
+ - **Batch sending** — send multiple notifications in one call, settings fetched once
11
+ - **Merge** — collapse repeated notifications into one item with a count badge (fully configurable)
12
+ - **User opt-out** — each admin user can mute individual notification types or opt out entirely
13
+ - **Configurable** — poll interval, page size, retention policy, UI accent colours, and merge rules
11
14
  - **Settings panel** — runtime configuration via Settings → Notifier (persisted in Strapi plugin store)
12
15
  - **Retention cron** — daily cleanup at 3 AM, respects maxDays and maxPerUser limits
13
16
  - **Strapi v5 only**
@@ -51,6 +54,13 @@ export default {
51
54
  pollIntervalMs: 30_000, // how often the Bell polls (ms)
52
55
  pageSize: 20, // notifications per page in inbox
53
56
  },
57
+ merge: {
58
+ enabled: false, // enable notification merging
59
+ windowMinutes: 60, // look back this many minutes for a merge candidate
60
+ keyFields: ['title', 'type'], // fields that must match to merge
61
+ countBadge: true, // show "×N" badge on merged items in the inbox
62
+ rewriteMessage: false, // rewrite the message to "N× title" when merging
63
+ },
54
64
  ui: {
55
65
  theme: {
56
66
  accent: {
@@ -102,30 +112,125 @@ notifier.send({
102
112
  });
103
113
  ```
104
114
 
115
+ ### Batch sending
116
+
117
+ Send multiple notifications in one call. Settings (including merge config) are fetched once and applied to all items — more efficient than looping over `send()`.
118
+
119
+ ```typescript
120
+ await notifier.sendBatch([
121
+ { title: 'New job application', type: 'info', to: { role: 'strapi-editor' } },
122
+ { title: 'Export ready', type: 'success', to: { userId: 42 } },
123
+ { title: 'Low disk space', type: 'warning' }, // broadcast
124
+ ]);
125
+ ```
126
+
127
+ Opt-out and merge rules are applied per item within the batch.
128
+
105
129
  ### Notification options
106
130
 
107
- | Field | Type | Required | Default |
108
- |-----------|-------------------------------------------|----------|-------------|
109
- | `title` | `string` | Yes | — |
110
- | `message` | `string` | No | — |
111
- | `type` | `'info' \| 'success' \| 'warning' \| 'error'` | No | `'info'` |
112
- | `url` | `string` | No | — |
131
+ | Field | Type | Required | Default |
132
+ |-----------|-----------------------------------------------|----------|-----------|
133
+ | `title` | `string` | Yes | — |
134
+ | `message` | `string` | No | — |
135
+ | `type` | `'info' \| 'success' \| 'warning' \| 'error'` | No | `'info'` |
136
+ | `url` | `string` | No | — |
137
+ | `to` | `{ userId?: number; role?: string }` | No | broadcast |
138
+
139
+ ## Notification merging
140
+
141
+ When `merge.enabled` is `true`, sending a notification whose key fields match an existing notification (created within `windowMinutes`) updates that notification instead of creating a new one.
142
+
143
+ **Example — a form that can be submitted many times:**
144
+
145
+ ```typescript
146
+ // config/plugins.ts
147
+ merge: { enabled: true, windowMinutes: 60, keyFields: ['title', 'type'], countBadge: true }
148
+
149
+ // In your lifecycle / service:
150
+ notifier.toRole('strapi-editor', {
151
+ title: 'New job application',
152
+ type: 'info',
153
+ });
154
+ ```
155
+
156
+ After five submissions the inbox shows a single item: **New job application ×5**.
157
+
158
+ **Merge key fields** (`title`, `type`, `url`) — any combination. Two notifications are considered the same if all selected fields match AND the recipient matches AND the most recent one was created within the window.
159
+
160
+ **Display options:**
161
+
162
+ | Option | Default | Effect |
163
+ |------------------|---------|--------------------------------------------------|
164
+ | `countBadge` | `true` | Shows a coloured "×N" pill next to the title |
165
+ | `rewriteMessage` | `false` | Replaces the message with `"N× <title>"` |
166
+
167
+ Both can be toggled from the **Settings → Notifier → Merge** panel at runtime.
168
+
169
+ ## User opt-out
170
+
171
+ Each admin user can control which notifications they receive. Opt-out is applied at query time so it covers broadcast and role-targeted notifications too.
172
+
173
+ ### Via the API
174
+
175
+ ```http
176
+ GET /notifier/preferences/me
177
+ PUT /notifier/preferences/me
178
+ ```
179
+
180
+ **Get current preferences:**
181
+ ```http
182
+ GET /notifier/preferences/me
183
+ → { "data": { "userId": 5, "globalOptOut": false, "mutedTypes": ["info"] } }
184
+ ```
185
+
186
+ **Mute info and success notifications:**
187
+ ```http
188
+ PUT /notifier/preferences/me
189
+ Content-Type: application/json
190
+
191
+ { "mutedTypes": ["info", "success"] }
192
+ ```
193
+
194
+ **Opt out entirely:**
195
+ ```http
196
+ PUT /notifier/preferences/me
197
+ Content-Type: application/json
198
+
199
+ { "globalOptOut": true }
200
+ ```
201
+
202
+ **Re-enable:**
203
+ ```http
204
+ PUT /notifier/preferences/me
205
+ Content-Type: application/json
206
+
207
+ { "globalOptOut": false, "mutedTypes": [] }
208
+ ```
209
+
210
+ ### Preference fields
211
+
212
+ | Field | Type | Default | Description |
213
+ |----------------|--------------------|---------|------------------------------------------------|
214
+ | `globalOptOut` | `boolean` | `false` | Suppress all notifications for this user |
215
+ | `mutedTypes` | `NotificationType[]` | `[]` | Suppress only these notification types |
113
216
 
114
217
  ## API routes
115
218
 
116
219
  All routes require `admin::isAuthenticatedAdmin`. The plugin mounts under `/notifier/`.
117
220
 
118
- | Method | Path | Description |
119
- |--------|---------------------------------|--------------------------------------|
120
- | GET | `/notifier/notifications` | List notifications (paginated) |
121
- | PUT | `/notifier/notifications/read-all` | Mark all as read |
122
- | DELETE | `/notifier/notifications` | Clear all notifications |
123
- | PUT | `/notifier/notifications/:id/read` | Mark one as read |
124
- | DELETE | `/notifier/notifications/:id` | Clear one notification |
125
- | GET | `/notifier/config` | Fetch UI config (safe for frontend) |
126
- | GET | `/notifier/settings` | Get full settings (requires permission) |
127
- | PUT | `/notifier/settings` | Update settings (requires permission) |
128
- | DELETE | `/notifier/settings` | Reset settings to defaults |
221
+ | Method | Path | Description |
222
+ |--------|------------------------------------|----------------------------------------|
223
+ | GET | `/notifier/notifications` | List notifications (paginated) |
224
+ | PUT | `/notifier/notifications/read-all` | Mark all as read |
225
+ | DELETE | `/notifier/notifications` | Clear all notifications |
226
+ | PUT | `/notifier/notifications/:id/read` | Mark one as read |
227
+ | DELETE | `/notifier/notifications/:id` | Clear one notification |
228
+ | GET | `/notifier/config` | Fetch UI config (safe for frontend) |
229
+ | GET | `/notifier/settings` | Get full settings |
230
+ | PUT | `/notifier/settings` | Update settings |
231
+ | DELETE | `/notifier/settings` | Reset settings to defaults |
232
+ | GET | `/notifier/preferences/me` | Get current user's notification prefs |
233
+ | PUT | `/notifier/preferences/me` | Update current user's notification prefs |
129
234
 
130
235
  Query parameters for `GET /notifier/notifications`:
131
236
 
@@ -141,9 +246,14 @@ Two permissions are registered under the **Notifier** plugin section in **Settin
141
246
  - `plugin::notifier.settings.read` — view the settings panel
142
247
  - `plugin::notifier.settings.update` — save or reset settings
143
248
 
144
- ## Content type
249
+ ## Content types
250
+
251
+ | UID | Collection | Description |
252
+ |------------------------------------------|-----------------------------|--------------------------------------|
253
+ | `plugin::notifier.notification` | `notifier_notifications` | Notification records |
254
+ | `plugin::notifier.notification-preference` | `notifier_preferences` | Per-user opt-out preferences |
145
255
 
146
- Notifications are stored in `plugin::notifier.notification` (collection: `notifier_notifications`). The content type is hidden from Content Manager and Content-Type Builder by default.
256
+ Both are hidden from Content Manager and Content-Type Builder by default.
147
257
 
148
258
  ## License
149
259
 
@@ -1,12 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const jsxRuntime = require("react/jsx-runtime");
4
- const React = require("react");
4
+ const react = require("react");
5
5
  const designSystem = require("@strapi/design-system");
6
- require("react-dom/client");
6
+ const strapiAdmin = require("@strapi/admin/strapi-admin");
7
+ const index = require("./index-CKh_iBP2.js");
7
8
  const icons = require("@strapi/icons");
8
- const index = require("./index-7WJGsVEY.js");
9
- require("@strapi/icons/symbols");
10
9
  const toStoreItem = (n) => ({
11
10
  id: String(n.id),
12
11
  title: n.title,
@@ -17,11 +16,11 @@ const toStoreItem = (n) => ({
17
16
  url: n.url
18
17
  });
19
18
  const useNotificationActions = () => {
20
- const { get, put, del } = index.useFetchClient();
21
- const [isLoading, setIsLoading] = React.useState(false);
22
- const [pagination, setPagination] = React.useState(null);
23
- const currentPage = React.useRef(1);
24
- const fetchPage = React.useCallback(
19
+ const { get, put, del } = strapiAdmin.useFetchClient();
20
+ const [isLoading, setIsLoading] = react.useState(false);
21
+ const [pagination, setPagination] = react.useState(null);
22
+ const currentPage = react.useRef(1);
23
+ const fetchPage = react.useCallback(
25
24
  async (page, append = false) => {
26
25
  const { pageSize } = index.getConfig();
27
26
  setIsLoading(true);
@@ -45,12 +44,12 @@ const useNotificationActions = () => {
45
44
  },
46
45
  [get]
47
46
  );
48
- const fetchAll = React.useCallback(() => fetchPage(1, false), [fetchPage]);
49
- const loadMore = React.useCallback(() => {
47
+ const fetchAll = react.useCallback(() => fetchPage(1, false), [fetchPage]);
48
+ const loadMore = react.useCallback(() => {
50
49
  if (!pagination || currentPage.current >= pagination.pageCount) return;
51
50
  fetchPage(currentPage.current + 1, true);
52
51
  }, [fetchPage, pagination]);
53
- const handleMarkAsRead = React.useCallback(
52
+ const handleMarkAsRead = react.useCallback(
54
53
  async (id) => {
55
54
  index.markAsRead(id);
56
55
  try {
@@ -60,14 +59,14 @@ const useNotificationActions = () => {
60
59
  },
61
60
  [put]
62
61
  );
63
- const handleMarkAllAsRead = React.useCallback(async () => {
62
+ const handleMarkAllAsRead = react.useCallback(async () => {
64
63
  index.markAllAsRead();
65
64
  try {
66
65
  await put("/notifier/notifications/read-all");
67
66
  } catch {
68
67
  }
69
68
  }, [put]);
70
- const handleClear = React.useCallback(
69
+ const handleClear = react.useCallback(
71
70
  async (id) => {
72
71
  index.clearNotification(id);
73
72
  try {
@@ -77,7 +76,7 @@ const useNotificationActions = () => {
77
76
  },
78
77
  [del]
79
78
  );
80
- const handleClearAll = React.useCallback(async () => {
79
+ const handleClearAll = react.useCallback(async () => {
81
80
  index.clearAll();
82
81
  try {
83
82
  await del("/notifier/notifications");
@@ -150,8 +149,8 @@ function InboxHeader({
150
149
  ] });
151
150
  }
152
151
  const useNotifications = () => {
153
- const [notifications, setNotifications] = React.useState(index.getNotifications);
154
- React.useEffect(() => index.subscribeToNotifications(setNotifications), []);
152
+ const [notifications, setNotifications] = react.useState(index.getNotifications);
153
+ react.useEffect(() => index.subscribeToNotifications(setNotifications), []);
155
154
  return notifications;
156
155
  };
157
156
  const UNREAD_BG = {
@@ -205,6 +204,24 @@ function NotificationItem({
205
204
  children: notification.title
206
205
  }
207
206
  ),
207
+ notification.mergeCount != null && notification.mergeCount > 1 && /* @__PURE__ */ jsxRuntime.jsxs(
208
+ designSystem.Typography,
209
+ {
210
+ variant: "pi",
211
+ style: {
212
+ background: accent,
213
+ color: "#fff",
214
+ borderRadius: "10px",
215
+ padding: "0 6px",
216
+ flexShrink: 0,
217
+ fontWeight: 600
218
+ },
219
+ children: [
220
+ "×",
221
+ notification.mergeCount
222
+ ]
223
+ }
224
+ ),
208
225
  notification.url && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
209
226
  ] }),
210
227
  notification.message && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", style: { marginTop: 2 }, children: notification.message }),
@@ -270,7 +287,7 @@ function NotificationList({
270
287
  ] });
271
288
  }
272
289
  function Index() {
273
- const [filter, setFilter] = React.useState("all");
290
+ const [filter, setFilter] = react.useState("all");
274
291
  const {
275
292
  fetchAll,
276
293
  loadMore,
@@ -281,7 +298,7 @@ function Index() {
281
298
  isLoading,
282
299
  hasMore
283
300
  } = useNotificationActions();
284
- React.useEffect(() => {
301
+ react.useEffect(() => {
285
302
  fetchAll();
286
303
  }, []);
287
304
  return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { padding: 8, style: { height: "100%" }, children: [
@@ -1,10 +1,9 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { useState, useRef, useCallback, useEffect } from "react";
3
3
  import { Box, Flex, Typography, Button, IconButton, Loader } from "@strapi/design-system";
4
- import "react-dom/client";
4
+ import { useFetchClient } from "@strapi/admin/strapi-admin";
5
+ import { g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, u as usePluginConfig } from "./index-BgPnE501.mjs";
5
6
  import { Cross } from "@strapi/icons";
6
- import { u as useFetchClient, g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, f as usePluginConfig } from "./index-CNYabBMJ.mjs";
7
- import "@strapi/icons/symbols";
8
7
  const toStoreItem = (n) => ({
9
8
  id: String(n.id),
10
9
  title: n.title,
@@ -203,6 +202,24 @@ function NotificationItem({
203
202
  children: notification.title
204
203
  }
205
204
  ),
205
+ notification.mergeCount != null && notification.mergeCount > 1 && /* @__PURE__ */ jsxs(
206
+ Typography,
207
+ {
208
+ variant: "pi",
209
+ style: {
210
+ background: accent,
211
+ color: "#fff",
212
+ borderRadius: "10px",
213
+ padding: "0 6px",
214
+ flexShrink: 0,
215
+ fontWeight: 600
216
+ },
217
+ children: [
218
+ "×",
219
+ notification.mergeCount
220
+ ]
221
+ }
222
+ ),
206
223
  notification.url && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
207
224
  ] }),
208
225
  notification.message && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", style: { marginTop: 2 }, children: notification.message }),
@@ -1,15 +1,24 @@
1
- import { jsxs, jsx } from "react/jsx-runtime";
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from "react";
3
- import { Box, Flex, Typography, Button, NumberInput, Divider, TextInput } from "@strapi/design-system";
4
- import "react-dom/client";
5
- import "@strapi/icons";
6
- import { u as useFetchClient } from "./index-CNYabBMJ.mjs";
7
- import "@strapi/icons/symbols";
3
+ import { Box, Flex, Typography, Button, NumberInput, Toggle, Divider, Checkbox, TextInput } from "@strapi/design-system";
4
+ import { useFetchClient } from "@strapi/admin/strapi-admin";
8
5
  const DEFAULT = {
9
6
  retention: { maxDays: 90, maxPerUser: 500 },
10
7
  delivery: { pollIntervalMs: 3e4, pageSize: 20 },
8
+ merge: {
9
+ enabled: false,
10
+ windowMinutes: 60,
11
+ keyFields: ["title", "type"],
12
+ countBadge: true,
13
+ rewriteMessage: false
14
+ },
11
15
  ui: { theme: { accent: { info: "#4945ff", success: "#5cb85c", warning: "#f0ad4e", error: "#ee5e52" } } }
12
16
  };
17
+ const MERGE_KEY_OPTIONS = [
18
+ { value: "title", label: "Title" },
19
+ { value: "type", label: "Type" },
20
+ { value: "url", label: "URL" }
21
+ ];
13
22
  function SettingsPage() {
14
23
  const { get, put, del } = useFetchClient();
15
24
  const [settings, setSettings] = useState(DEFAULT);
@@ -54,6 +63,13 @@ function SettingsPage() {
54
63
  return next;
55
64
  });
56
65
  };
66
+ const toggleMergeKeyField = (field) => {
67
+ setSettings((prev) => {
68
+ const current = prev.merge.keyFields;
69
+ const next = current.includes(field) ? current.filter((f) => f !== field) : [...current, field];
70
+ return { ...prev, merge: { ...prev.merge, keyFields: next } };
71
+ });
72
+ };
57
73
  return /* @__PURE__ */ jsxs(Box, { padding: 8, children: [
58
74
  /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 6, children: [
59
75
  /* @__PURE__ */ jsx(Typography, { variant: "alpha", children: "Notifier Settings" }),
@@ -113,6 +129,79 @@ function SettingsPage() {
113
129
  )
114
130
  ] })
115
131
  ] }),
132
+ /* @__PURE__ */ jsxs(Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
133
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 4, children: [
134
+ /* @__PURE__ */ jsxs(Box, { children: [
135
+ /* @__PURE__ */ jsx(Typography, { variant: "delta", as: "h2", children: "Merge" }),
136
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: "Collapse repeated notifications into one, with a count badge." })
137
+ ] }),
138
+ /* @__PURE__ */ jsx(
139
+ Toggle,
140
+ {
141
+ label: "Enable merging",
142
+ offLabel: "Off",
143
+ onLabel: "On",
144
+ checked: settings.merge.enabled,
145
+ onChange: (e) => set("merge.enabled", e.target.checked)
146
+ }
147
+ )
148
+ ] }),
149
+ settings.merge.enabled && /* @__PURE__ */ jsxs(Fragment, { children: [
150
+ /* @__PURE__ */ jsx(Divider, {}),
151
+ /* @__PURE__ */ jsxs(Box, { paddingTop: 4, children: [
152
+ /* @__PURE__ */ jsx(Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: /* @__PURE__ */ jsx(
153
+ NumberInput,
154
+ {
155
+ label: "Window (minutes)",
156
+ hint: "How far back to look for a notification to merge into.",
157
+ value: settings.merge.windowMinutes,
158
+ onValueChange: (v) => set("merge.windowMinutes", v),
159
+ style: { flex: 1 }
160
+ }
161
+ ) }),
162
+ /* @__PURE__ */ jsx(Typography, { variant: "sigma", paddingBottom: 2, children: "Merge key fields" }),
163
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", paddingBottom: 3, children: "Two notifications are considered the same if all selected fields match." }),
164
+ /* @__PURE__ */ jsx(Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: MERGE_KEY_OPTIONS.map(({ value, label }) => /* @__PURE__ */ jsx(
165
+ Checkbox,
166
+ {
167
+ checked: settings.merge.keyFields.includes(value),
168
+ onCheckedChange: () => toggleMergeKeyField(value),
169
+ children: label
170
+ },
171
+ value
172
+ )) }),
173
+ /* @__PURE__ */ jsx(Typography, { variant: "sigma", paddingBottom: 2, children: "Display" }),
174
+ /* @__PURE__ */ jsxs(Flex, { gap: 6, wrap: "wrap", children: [
175
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
176
+ /* @__PURE__ */ jsx(
177
+ Toggle,
178
+ {
179
+ label: "Count badge",
180
+ offLabel: "Off",
181
+ onLabel: "On",
182
+ checked: settings.merge.countBadge,
183
+ onChange: (e) => set("merge.countBadge", e.target.checked)
184
+ }
185
+ ),
186
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: "Show ×N badge on merged items" }) })
187
+ ] }),
188
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
189
+ /* @__PURE__ */ jsx(
190
+ Toggle,
191
+ {
192
+ label: "Rewrite message",
193
+ offLabel: "Off",
194
+ onLabel: "On",
195
+ checked: settings.merge.rewriteMessage,
196
+ onChange: (e) => set("merge.rewriteMessage", e.target.checked)
197
+ }
198
+ ),
199
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: 'Replace message with "N× title"' }) })
200
+ ] })
201
+ ] })
202
+ ] })
203
+ ] })
204
+ ] }),
116
205
  /* @__PURE__ */ jsxs(Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
117
206
  /* @__PURE__ */ jsx(Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Theme" }),
118
207
  /* @__PURE__ */ jsx(Divider, {}),
@@ -1,24 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const jsxRuntime = require("react/jsx-runtime");
4
- const React = require("react");
4
+ const react = require("react");
5
5
  const designSystem = require("@strapi/design-system");
6
- require("react-dom/client");
7
- require("@strapi/icons");
8
- const index = require("./index-7WJGsVEY.js");
9
- require("@strapi/icons/symbols");
6
+ const strapiAdmin = require("@strapi/admin/strapi-admin");
10
7
  const DEFAULT = {
11
8
  retention: { maxDays: 90, maxPerUser: 500 },
12
9
  delivery: { pollIntervalMs: 3e4, pageSize: 20 },
10
+ merge: {
11
+ enabled: false,
12
+ windowMinutes: 60,
13
+ keyFields: ["title", "type"],
14
+ countBadge: true,
15
+ rewriteMessage: false
16
+ },
13
17
  ui: { theme: { accent: { info: "#4945ff", success: "#5cb85c", warning: "#f0ad4e", error: "#ee5e52" } } }
14
18
  };
19
+ const MERGE_KEY_OPTIONS = [
20
+ { value: "title", label: "Title" },
21
+ { value: "type", label: "Type" },
22
+ { value: "url", label: "URL" }
23
+ ];
15
24
  function SettingsPage() {
16
- const { get, put, del } = index.useFetchClient();
17
- const [settings, setSettings] = React.useState(DEFAULT);
18
- const [isSaving, setIsSaving] = React.useState(false);
19
- const [isResetting, setIsResetting] = React.useState(false);
20
- const [saveError, setSaveError] = React.useState(null);
21
- React.useEffect(() => {
25
+ const { get, put, del } = strapiAdmin.useFetchClient();
26
+ const [settings, setSettings] = react.useState(DEFAULT);
27
+ const [isSaving, setIsSaving] = react.useState(false);
28
+ const [isResetting, setIsResetting] = react.useState(false);
29
+ const [saveError, setSaveError] = react.useState(null);
30
+ react.useEffect(() => {
22
31
  get("/notifier/settings").then(({ data }) => {
23
32
  if (data) setSettings(data);
24
33
  }).catch(() => {
@@ -56,6 +65,13 @@ function SettingsPage() {
56
65
  return next;
57
66
  });
58
67
  };
68
+ const toggleMergeKeyField = (field) => {
69
+ setSettings((prev) => {
70
+ const current = prev.merge.keyFields;
71
+ const next = current.includes(field) ? current.filter((f) => f !== field) : [...current, field];
72
+ return { ...prev, merge: { ...prev.merge, keyFields: next } };
73
+ });
74
+ };
59
75
  return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { padding: 8, children: [
60
76
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 6, children: [
61
77
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "alpha", children: "Notifier Settings" }),
@@ -115,6 +131,79 @@ function SettingsPage() {
115
131
  )
116
132
  ] })
117
133
  ] }),
134
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
135
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 4, children: [
136
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
137
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", as: "h2", children: "Merge" }),
138
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Collapse repeated notifications into one, with a count badge." })
139
+ ] }),
140
+ /* @__PURE__ */ jsxRuntime.jsx(
141
+ designSystem.Toggle,
142
+ {
143
+ label: "Enable merging",
144
+ offLabel: "Off",
145
+ onLabel: "On",
146
+ checked: settings.merge.enabled,
147
+ onChange: (e) => set("merge.enabled", e.target.checked)
148
+ }
149
+ )
150
+ ] }),
151
+ settings.merge.enabled && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
152
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Divider, {}),
153
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { paddingTop: 4, children: [
154
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: /* @__PURE__ */ jsxRuntime.jsx(
155
+ designSystem.NumberInput,
156
+ {
157
+ label: "Window (minutes)",
158
+ hint: "How far back to look for a notification to merge into.",
159
+ value: settings.merge.windowMinutes,
160
+ onValueChange: (v) => set("merge.windowMinutes", v),
161
+ style: { flex: 1 }
162
+ }
163
+ ) }),
164
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", paddingBottom: 2, children: "Merge key fields" }),
165
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", paddingBottom: 3, children: "Two notifications are considered the same if all selected fields match." }),
166
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: MERGE_KEY_OPTIONS.map(({ value, label }) => /* @__PURE__ */ jsxRuntime.jsx(
167
+ designSystem.Checkbox,
168
+ {
169
+ checked: settings.merge.keyFields.includes(value),
170
+ onCheckedChange: () => toggleMergeKeyField(value),
171
+ children: label
172
+ },
173
+ value
174
+ )) }),
175
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", paddingBottom: 2, children: "Display" }),
176
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 6, wrap: "wrap", children: [
177
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
178
+ /* @__PURE__ */ jsxRuntime.jsx(
179
+ designSystem.Toggle,
180
+ {
181
+ label: "Count badge",
182
+ offLabel: "Off",
183
+ onLabel: "On",
184
+ checked: settings.merge.countBadge,
185
+ onChange: (e) => set("merge.countBadge", e.target.checked)
186
+ }
187
+ ),
188
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Show ×N badge on merged items" }) })
189
+ ] }),
190
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
191
+ /* @__PURE__ */ jsxRuntime.jsx(
192
+ designSystem.Toggle,
193
+ {
194
+ label: "Rewrite message",
195
+ offLabel: "Off",
196
+ onLabel: "On",
197
+ checked: settings.merge.rewriteMessage,
198
+ onChange: (e) => set("merge.rewriteMessage", e.target.checked)
199
+ }
200
+ ),
201
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: 'Replace message with "N× title"' }) })
202
+ ] })
203
+ ] })
204
+ ] })
205
+ ] })
206
+ ] }),
118
207
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
119
208
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Theme" }),
120
209
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Divider, {}),