@eifi1/ui-kit 0.44.0 → 0.46.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.
Files changed (285) hide show
  1. package/README.md +29 -14
  2. package/dist/admin/admin-action-log-labels.js +9 -0
  3. package/dist/admin/admin-action-log-labels.js.map +1 -1
  4. package/dist/admin/admin-action-log.d.ts +18 -1
  5. package/dist/admin/admin-action-log.js +25 -16
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/admin-area-labels.d.ts +15 -0
  8. package/dist/admin/admin-area-labels.js +6 -1
  9. package/dist/admin/admin-area-labels.js.map +1 -1
  10. package/dist/admin/admin-overview.d.ts +16 -1
  11. package/dist/admin/admin-overview.js +12 -1
  12. package/dist/admin/admin-overview.js.map +1 -1
  13. package/dist/admin/mail-test-card-labels.d.ts +3 -0
  14. package/dist/admin/mail-test-card-labels.js +17 -0
  15. package/dist/admin/mail-test-card-labels.js.map +1 -0
  16. package/dist/admin/mail-test-card.d.ts +75 -0
  17. package/dist/admin/mail-test-card.js +82 -0
  18. package/dist/admin/mail-test-card.js.map +1 -0
  19. package/dist/components/appearance-labels.d.ts +10 -0
  20. package/dist/components/appearance-labels.js +6 -1
  21. package/dist/components/appearance-labels.js.map +1 -1
  22. package/dist/components/data-table-column-settings.js +2 -2
  23. package/dist/components/data-table-column-settings.js.map +1 -1
  24. package/dist/components/data-table-head.js +2 -2
  25. package/dist/components/data-table-head.js.map +1 -1
  26. package/dist/components/data-table-mobile.js +17 -2
  27. package/dist/components/data-table-mobile.js.map +1 -1
  28. package/dist/components/data-table-row-actions.d.ts +18 -2
  29. package/dist/components/data-table-row-actions.js +86 -2
  30. package/dist/components/data-table-row-actions.js.map +1 -1
  31. package/dist/components/data-table-shared.d.ts +2 -0
  32. package/dist/components/data-table-shared.js +17 -2
  33. package/dist/components/data-table-shared.js.map +1 -1
  34. package/dist/components/data-table.d.ts +43 -2
  35. package/dist/components/data-table.js +10 -10
  36. package/dist/components/data-table.js.map +1 -1
  37. package/dist/components/date-format-setting.d.ts +29 -0
  38. package/dist/components/date-format-setting.js +36 -0
  39. package/dist/components/date-format-setting.js.map +1 -0
  40. package/dist/components/swipeable-row.d.ts +8 -0
  41. package/dist/components/swipeable-row.js.map +1 -1
  42. package/dist/components/tabs.d.ts +3 -2
  43. package/dist/components/tabs.js +1 -1
  44. package/dist/components/tabs.js.map +1 -1
  45. package/dist/components/translation-review-editor.js +1 -0
  46. package/dist/components/translation-review-editor.js.map +1 -1
  47. package/dist/components/translation-review-labels.d.ts +5 -0
  48. package/dist/components/translation-review-labels.js +2 -0
  49. package/dist/components/translation-review-labels.js.map +1 -1
  50. package/dist/components/translation-review.d.ts +18 -8
  51. package/dist/components/translation-review.js +64 -7
  52. package/dist/components/translation-review.js.map +1 -1
  53. package/dist/components/write-lock-labels.js +2 -1
  54. package/dist/components/write-lock-labels.js.map +1 -1
  55. package/dist/components/write-lock.d.ts +33 -3
  56. package/dist/components/write-lock.js +9 -0
  57. package/dist/components/write-lock.js.map +1 -1
  58. package/dist/data-table.d.ts +1 -1
  59. package/dist/data-table.js.map +1 -1
  60. package/dist/eslint/index.js +1 -1
  61. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  62. package/dist/feedback/feedback-row-detail.js +17 -26
  63. package/dist/feedback/feedback-row-detail.js.map +1 -1
  64. package/dist/feedback/feedback-swipe.d.ts +18 -0
  65. package/dist/feedback/feedback-swipe.js +10 -1
  66. package/dist/feedback/feedback-swipe.js.map +1 -1
  67. package/dist/i18n/defaults.js +9 -1
  68. package/dist/i18n/defaults.js.map +1 -1
  69. package/dist/i18n/german.js +81 -5
  70. package/dist/i18n/german.js.map +1 -1
  71. package/dist/i18n/kit-labels.d.ts +25 -1
  72. package/dist/i18n/kit-labels.js +8 -2
  73. package/dist/i18n/kit-labels.js.map +1 -1
  74. package/dist/i18n/locales/en.js +97 -7
  75. package/dist/i18n/locales/en.js.map +3 -3
  76. package/dist/i18n/locales/es.js +81 -5
  77. package/dist/i18n/locales/es.js.map +1 -1
  78. package/dist/i18n/locales/fr.js +81 -5
  79. package/dist/i18n/locales/fr.js.map +1 -1
  80. package/dist/i18n/locales/hu.js +81 -5
  81. package/dist/i18n/locales/hu.js.map +1 -1
  82. package/dist/i18n/locales/it.js +81 -5
  83. package/dist/i18n/locales/it.js.map +1 -1
  84. package/dist/i18n/locales/zh.js +81 -5
  85. package/dist/i18n/locales/zh.js.map +1 -1
  86. package/dist/i18n/review.js +16 -2
  87. package/dist/i18n/review.js.map +1 -1
  88. package/dist/index.d.ts +17 -2
  89. package/dist/index.js +40 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/date-format.d.ts +81 -0
  92. package/dist/lib/date-format.js +56 -0
  93. package/dist/lib/date-format.js.map +1 -0
  94. package/dist/lib/format.d.ts +5 -1
  95. package/dist/lib/format.js +11 -5
  96. package/dist/lib/format.js.map +1 -1
  97. package/dist/notifications/device-zone-report.d.ts +27 -0
  98. package/dist/notifications/device-zone-report.js +24 -0
  99. package/dist/notifications/device-zone-report.js.map +1 -0
  100. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  101. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  102. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  103. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  104. package/dist/notifications/feedback-notification-types.js +2 -1
  105. package/dist/notifications/feedback-notification-types.js.map +1 -1
  106. package/dist/notifications/notification-bell.d.ts +33 -0
  107. package/dist/notifications/notification-bell.js +74 -0
  108. package/dist/notifications/notification-bell.js.map +1 -0
  109. package/dist/notifications/notification-inbox.d.ts +77 -0
  110. package/dist/notifications/notification-inbox.js +206 -0
  111. package/dist/notifications/notification-inbox.js.map +1 -0
  112. package/dist/notifications/notification-types-setting.d.ts +6 -4
  113. package/dist/notifications/notification-types-setting.js +103 -45
  114. package/dist/notifications/notification-types-setting.js.map +1 -1
  115. package/dist/notifications/notification-types.d.ts +19 -7
  116. package/dist/notifications/notifications-labels.d.ts +41 -7
  117. package/dist/notifications/notifications-labels.js +18 -1
  118. package/dist/notifications/notifications-labels.js.map +1 -1
  119. package/dist/notifications/push-devices-setting.d.ts +20 -3
  120. package/dist/notifications/push-devices-setting.js +93 -33
  121. package/dist/notifications/push-devices-setting.js.map +1 -1
  122. package/dist/notifications/push-worker.d.ts +108 -0
  123. package/dist/notifications/push-worker.js +62 -0
  124. package/dist/notifications/push-worker.js.map +1 -0
  125. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  126. package/dist/notifications/quiet-hours-setting.js +9 -41
  127. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  128. package/dist/notifications/time-zone-select.d.ts +5 -2
  129. package/dist/notifications/time-zone-select.js.map +1 -1
  130. package/dist/notifications/use-push-device.d.ts +4 -17
  131. package/dist/notifications/use-push-device.js +7 -15
  132. package/dist/notifications/use-push-device.js.map +1 -1
  133. package/dist/offline/local-first.d.ts +33 -0
  134. package/dist/offline/local-first.js +39 -0
  135. package/dist/offline/local-first.js.map +1 -0
  136. package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
  137. package/dist/offline/pwa-update-prompt-labels.js +9 -0
  138. package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
  139. package/dist/offline/pwa-update-prompt.d.ts +32 -0
  140. package/dist/offline/pwa-update-prompt.js +36 -0
  141. package/dist/offline/pwa-update-prompt.js.map +1 -0
  142. package/dist/offline/query-persistence.d.ts +125 -0
  143. package/dist/offline/query-persistence.js +183 -0
  144. package/dist/offline/query-persistence.js.map +1 -0
  145. package/dist/offline/service-worker-updates.d.ts +46 -0
  146. package/dist/offline/service-worker-updates.js +39 -0
  147. package/dist/offline/service-worker-updates.js.map +1 -0
  148. package/dist/offline/shared-computer-labels.d.ts +2 -0
  149. package/dist/offline/shared-computer-labels.js +9 -0
  150. package/dist/offline/shared-computer-labels.js.map +1 -0
  151. package/dist/offline/shared-computer.d.ts +44 -0
  152. package/dist/offline/shared-computer.js +51 -0
  153. package/dist/offline/shared-computer.js.map +1 -0
  154. package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
  155. package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
  156. package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
  157. package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
  158. package/dist/offline/sign-out-pending-dialog.js +66 -0
  159. package/dist/offline/sign-out-pending-dialog.js.map +1 -0
  160. package/dist/offline.d.ts +11 -0
  161. package/dist/offline.js +33 -0
  162. package/dist/offline.js.map +1 -0
  163. package/dist/push-worker.d.ts +2 -0
  164. package/dist/push-worker.js +14 -0
  165. package/dist/push-worker.js.map +1 -0
  166. package/dist/shell/account-menu-text-size.d.ts +33 -0
  167. package/dist/shell/account-menu-text-size.js +51 -0
  168. package/dist/shell/account-menu-text-size.js.map +1 -0
  169. package/dist/shell/admin-menu.d.ts +24 -0
  170. package/dist/shell/admin-menu.js +12 -3
  171. package/dist/shell/admin-menu.js.map +1 -1
  172. package/dist/shell/top-bar-actions.d.ts +6 -2
  173. package/dist/shell/top-bar-actions.js +1 -0
  174. package/dist/shell/top-bar-actions.js.map +1 -1
  175. package/dist/shell.d.ts +4 -0
  176. package/dist/shell.js +4 -0
  177. package/dist/shell.js.map +1 -1
  178. package/dist/sync/db.d.ts +76 -0
  179. package/dist/sync/db.js +24 -0
  180. package/dist/sync/db.js.map +1 -0
  181. package/dist/sync/engine.d.ts +124 -0
  182. package/dist/sync/engine.js +474 -0
  183. package/dist/sync/engine.js.map +1 -0
  184. package/dist/sync/hooks.d.ts +30 -0
  185. package/dist/sync/hooks.js +67 -0
  186. package/dist/sync/hooks.js.map +1 -0
  187. package/dist/sync/testing.d.ts +28 -0
  188. package/dist/sync/testing.js +41 -0
  189. package/dist/sync/testing.js.map +1 -0
  190. package/dist/sync/wire.d.ts +50 -0
  191. package/dist/sync/wire.js +2 -0
  192. package/dist/sync/wire.js.map +1 -0
  193. package/dist/sync.d.ts +7 -0
  194. package/dist/sync.js +22 -0
  195. package/dist/sync.js.map +1 -0
  196. package/dist/tour/tour-catalogue.d.ts +7 -1
  197. package/dist/tour/tour-catalogue.js.map +1 -1
  198. package/dist/tour/tour-labels.js +1 -0
  199. package/dist/tour/tour-labels.js.map +1 -1
  200. package/dist/tour/tour.d.ts +30 -0
  201. package/dist/tour/tour.js +54 -8
  202. package/dist/tour/tour.js.map +1 -1
  203. package/dist/tour/use-tour-progress.d.ts +99 -1
  204. package/dist/tour/use-tour-progress.js +228 -12
  205. package/dist/tour/use-tour-progress.js.map +1 -1
  206. package/dist/tour.d.ts +2 -0
  207. package/dist/tour.js +2 -0
  208. package/dist/tour.js.map +1 -1
  209. package/package.json +43 -1
  210. package/src/admin/admin-action-log-labels.ts +9 -0
  211. package/src/admin/admin-action-log.tsx +41 -5
  212. package/src/admin/admin-area-labels.ts +20 -0
  213. package/src/admin/admin-overview.tsx +37 -2
  214. package/src/admin/mail-test-card-labels.ts +19 -0
  215. package/src/admin/mail-test-card.tsx +190 -0
  216. package/src/components/appearance-labels.ts +16 -0
  217. package/src/components/data-table-column-settings.tsx +2 -2
  218. package/src/components/data-table-head.tsx +2 -2
  219. package/src/components/data-table-mobile.tsx +26 -3
  220. package/src/components/data-table-row-actions.tsx +118 -2
  221. package/src/components/data-table-shared.tsx +39 -3
  222. package/src/components/data-table.tsx +52 -21
  223. package/src/components/date-format-setting.tsx +64 -0
  224. package/src/components/swipeable-row.tsx +8 -0
  225. package/src/components/tabs.tsx +4 -3
  226. package/src/components/translation-review-editor.tsx +2 -0
  227. package/src/components/translation-review-labels.ts +7 -0
  228. package/src/components/translation-review.tsx +102 -14
  229. package/src/components/write-lock-labels.ts +1 -0
  230. package/src/components/write-lock.tsx +36 -3
  231. package/src/data-table.ts +1 -0
  232. package/src/feedback/feedback-row-detail.tsx +18 -23
  233. package/src/feedback/feedback-swipe.tsx +28 -1
  234. package/src/i18n/defaults.ts +8 -0
  235. package/src/i18n/german.ts +90 -4
  236. package/src/i18n/kit-labels.tsx +31 -1
  237. package/src/i18n/locales/en.ts +13 -0
  238. package/src/i18n/locales/es.ts +90 -4
  239. package/src/i18n/locales/fr.ts +90 -4
  240. package/src/i18n/locales/hu.ts +86 -4
  241. package/src/i18n/locales/it.ts +90 -4
  242. package/src/i18n/locales/zh.ts +84 -2
  243. package/src/i18n/review.ts +15 -1
  244. package/src/index.ts +45 -0
  245. package/src/lib/date-format.ts +151 -0
  246. package/src/lib/format.ts +23 -6
  247. package/src/notifications/device-zone-report.ts +54 -0
  248. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  249. package/src/notifications/feedback-notification-types.ts +12 -5
  250. package/src/notifications/notification-bell.tsx +116 -0
  251. package/src/notifications/notification-inbox.tsx +304 -0
  252. package/src/notifications/notification-types-setting.tsx +151 -55
  253. package/src/notifications/notification-types.ts +20 -8
  254. package/src/notifications/notifications-labels.ts +69 -11
  255. package/src/notifications/push-devices-setting.tsx +123 -34
  256. package/src/notifications/push-worker.ts +160 -0
  257. package/src/notifications/quiet-hours-setting.tsx +41 -58
  258. package/src/notifications/time-zone-select.tsx +5 -2
  259. package/src/notifications/use-push-device.ts +20 -33
  260. package/src/offline/local-first.ts +76 -0
  261. package/src/offline/pwa-update-prompt-labels.ts +9 -0
  262. package/src/offline/pwa-update-prompt.tsx +72 -0
  263. package/src/offline/query-persistence.ts +356 -0
  264. package/src/offline/service-worker-updates.ts +81 -0
  265. package/src/offline/shared-computer-labels.ts +8 -0
  266. package/src/offline/shared-computer.tsx +87 -0
  267. package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
  268. package/src/offline/sign-out-pending-dialog.tsx +135 -0
  269. package/src/offline.ts +32 -0
  270. package/src/push-worker.ts +25 -0
  271. package/src/shell/account-menu-text-size.tsx +77 -0
  272. package/src/shell/admin-menu.tsx +40 -3
  273. package/src/shell/top-bar-actions.tsx +6 -1
  274. package/src/shell.ts +7 -0
  275. package/src/sync/db.ts +103 -0
  276. package/src/sync/engine.ts +699 -0
  277. package/src/sync/hooks.ts +93 -0
  278. package/src/sync/testing.ts +73 -0
  279. package/src/sync/wire.ts +61 -0
  280. package/src/sync.ts +29 -0
  281. package/src/tour/tour-catalogue.tsx +7 -1
  282. package/src/tour/tour-labels.ts +1 -0
  283. package/src/tour/tour.tsx +105 -9
  284. package/src/tour/use-tour-progress.ts +378 -12
  285. package/src/tour.ts +3 -0
@@ -9,9 +9,9 @@ import {
9
9
  export { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS };
10
10
  export type { FeedbackNotificationTypesLabels };
11
11
 
12
- /** The two feedback notification ids (§9.11), as server-kit's `FEEDBACK_ANSWERED` and
13
- * `FEEDBACK_COMMENT` name them. */
14
- export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment";
12
+ /** The feedback notification ids (§9.11, §11), as server-kit's `FEEDBACK_ANSWERED`,
13
+ * `FEEDBACK_COMMENT` and `FEEDBACK_RECEIVED` (0.46: an admin's alert) name them. */
14
+ export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment" | "feedback_received";
15
15
 
16
16
  /**
17
17
  * The setting words of the two feedback notification types (round 0.38,
@@ -27,9 +27,15 @@ export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment
27
27
  * - **"Comments on reports you follow"** (`feedback_comment`): a visible comment, to the
28
28
  * author and the earlier commenters.
29
29
  *
30
- * Both entries are always returned: the card draws a type only when the server answered
30
+ * - **"New feedback and reworks"** (`feedback_received`, 0.46, feedback §11): to the
31
+ * admins, when someone files a report or sends one back for rework. (A crash is mailed
32
+ * only inside the bundle where an app runs the queue, so the text names no crash.)
33
+ * `labels.adminGroup` ("Admin") is a heading an app may put over it.
34
+ *
35
+ * Every entry is always returned: the card draws a type only when the server answered
31
36
  * it (notifications §2.1), so kastlan, which does not declare `feedback_comment` while its
32
- * public parts are off, shows the first alone. Words: `<UiKitProvider labels={{
37
+ * public parts are off, shows the first alone, and a person who is no admin is never
38
+ * answered `feedback_received`. Words: `<UiKitProvider labels={{
33
39
  * feedbackNotificationTypes }}>` over English, `labels` over both.
34
40
  */
35
41
  export function useFeedbackNotificationTypes(
@@ -40,6 +46,7 @@ export function useFeedbackNotificationTypes(
40
46
  () => [
41
47
  { id: "feedback_answered", label: words.answered, description: words.answeredDescription },
42
48
  { id: "feedback_comment", label: words.comment, description: words.commentDescription },
49
+ { id: "feedback_received", label: words.received, description: words.receivedDescription },
43
50
  ],
44
51
  [words],
45
52
  );
@@ -0,0 +1,116 @@
1
+ import { Bell } from "lucide-react";
2
+ import type { LucideIcon } from "lucide-react";
3
+
4
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
+ import { useKitFormat } from "../lib/format";
6
+ import { DEFAULT_LIST_LABELS } from "../components/list-labels";
7
+ import { StatusDot } from "../components/status-dot";
8
+ import { TopBarActionMenu } from "../shell/topbar-action-menu";
9
+ import type { TopBarMenuEntry } from "../shell/topbar-action-menu";
10
+ import { useNotificationsLabels } from "./notification-parts";
11
+ import type { NotificationsLabels } from "./notifications-labels";
12
+ import type { NotificationInboxItem } from "./notification-inbox";
13
+
14
+ /** How many rows the bell's panel shows: the newest 20 (§10.1). */
15
+ const BELL_ROWS = 20;
16
+
17
+ export interface NotificationBellProps<Id extends string | number = string | number> {
18
+ /** The inbox, newest first (the bell shows the first 20); `undefined` while it loads. */
19
+ items: readonly NotificationInboxItem<Id>[] | undefined;
20
+ /** Every unread row of the account (`unread_count`): the badge. */
21
+ unreadCount: number;
22
+ /** Open a row: the app marks it read and routes to `item.url` (kastlan switches the
23
+ * acting company first). The panel closes. */
24
+ onOpen: (item: NotificationInboxItem<Id>) => void;
25
+ /** The app's inbox page or card, for "All notifications". */
26
+ href: string;
27
+ /** The row's glyph for a type, as {@link NotificationInbox}'s; `Bell` by default. */
28
+ icon?: (type: string) => LucideIcon | undefined;
29
+ /** A row's time. Default: the provider's locale and `timeZone`, date and time. */
30
+ formatDate?: (iso: string) => string;
31
+ labels?: LabelOverride<NotificationsLabels>;
32
+ }
33
+
34
+ /** The badge's text: "99+" past 99. */
35
+ const badgeText = (count: number) => (count > 99 ? "99+" : String(Math.floor(count)));
36
+
37
+ /**
38
+ * The top bar's bell (0.46, docs/notifications-harmonization.md §10.1), for
39
+ * `TopBarActions`' `notifications` slot, right before the account menu: an icon button
40
+ * with the unread count as a badge ("99+" past 99), opening the newest 20 rows in the
41
+ * kit's top-bar panel (`TopBarActionMenu`) with "All notifications" under them.
42
+ *
43
+ * **Optional per app.** "One badge per signal": an app shows the bell or an unread dot on
44
+ * the avatar, never both — keksdose keeps its avatar dot and no bell; kastlan's inbox
45
+ * lives in the bell. Shown to every signed-in account in an app that keeps an inbox, the
46
+ * demo included. Props only, as the inbox list: poll the count every
47
+ * {@link INBOX_BADGE_INTERVAL}.
48
+ */
49
+ export function NotificationBell<Id extends string | number = string | number>({
50
+ items,
51
+ unreadCount,
52
+ onOpen,
53
+ href,
54
+ icon,
55
+ formatDate: formatDateProp,
56
+ labels: labelsProp,
57
+ }: NotificationBellProps<Id>) {
58
+ const labels = useNotificationsLabels(labelsProp);
59
+ const list = useKitLabels("list", DEFAULT_LIST_LABELS);
60
+ const { formatDate } = useKitFormat();
61
+ const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "dateTime"));
62
+ const unread = unreadCount > 0;
63
+ const shown = (items ?? []).slice(0, BELL_ROWS);
64
+
65
+ const entries: TopBarMenuEntry[] = shown.map((item) => {
66
+ const Icon = icon?.(item.notification_type) ?? Bell;
67
+ const isUnread = item.read_at === null;
68
+ return {
69
+ key: `item-${String(item.id)}`,
70
+ icon: <Icon aria-hidden className="size-4" />,
71
+ label: (
72
+ <span className="block min-w-0" data-bell-item={String(item.id)}>
73
+ {item.company && <span className="block truncate text-caption text-muted">{item.company}</span>}
74
+ <span className={isUnread ? "block font-semibold" : "block"}>{item.title}</span>
75
+ <span className="block line-clamp-2 text-xs text-secondary">{item.body}</span>
76
+ <span className="block text-caption text-muted">{timeOf(item.created_at)}</span>
77
+ </span>
78
+ ),
79
+ trailing: isUnread ? <StatusDot tone="brand" aria-label={list.unread} /> : undefined,
80
+ onSelect: () => onOpen(item),
81
+ };
82
+ });
83
+ if (entries.length > 0) entries.push({ kind: "divider", key: "divider" });
84
+ entries.push({ kind: "link", key: "all", to: href, label: labels.allNotifications });
85
+
86
+ return (
87
+ <TopBarActionMenu
88
+ aria-label={labels.bellLabel}
89
+ triggerAttributes={{ "data-notification-bell": "" }}
90
+ trigger={() => (
91
+ <span className="relative inline-flex" data-unread={unread ? unreadCount : undefined}>
92
+ <Bell aria-hidden className="size-5" />
93
+ {unread && (
94
+ <>
95
+ <span
96
+ aria-hidden
97
+ data-badge=""
98
+ className="pointer-events-none absolute -end-2 -top-1.5 h-4 min-w-4 rounded-full bg-danger px-1 text-center text-micro font-semibold leading-4 text-danger-contrast"
99
+ >
100
+ {badgeText(unreadCount)}
101
+ </span>
102
+ <span className="sr-only">{labels.bellUnread(unreadCount)}</span>
103
+ </>
104
+ )}
105
+ </span>
106
+ )}
107
+ header={
108
+ shown.length === 0
109
+ ? { title: labels.inboxTitle, subtitle: items === undefined ? labels.inboxLoading : labels.inboxEmpty }
110
+ : undefined
111
+ }
112
+ entries={entries}
113
+ panelClassName="w-80 max-w-[calc(100vw-2rem)]"
114
+ />
115
+ );
116
+ }
@@ -0,0 +1,304 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from "react";
3
+ import { Bell, CheckCheck, Trash2 } from "lucide-react";
4
+ import type { LucideIcon } from "lucide-react";
5
+
6
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
7
+ import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
8
+ import { cn } from "../lib/cn";
9
+ import { useKitFormat } from "../lib/format";
10
+ import { Button } from "../components/button";
11
+ import { Card } from "../components/card";
12
+ import { EmptyState } from "../components/empty-state";
13
+ import { IconButton } from "../components/icon-button";
14
+ import { List, ListItem } from "../components/list";
15
+ import { Skeleton } from "../components/skeleton";
16
+ import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
17
+ import { SettingsCardTitle } from "../settings/settings-heading";
18
+ import { settle, useMounted } from "../account/account-parts";
19
+ import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
20
+ import type { NotificationsLabels } from "./notifications-labels";
21
+
22
+ /**
23
+ * One inbox row as `GET /notifications/inbox` answers it (server-kit `InboxItem`, §3.7):
24
+ * the title and body already in the recipient's language.
25
+ */
26
+ export interface NotificationInboxItem<Id extends string | number = string | number> {
27
+ id: Id;
28
+ /** The type id, for the row's icon. */
29
+ notification_type: string;
30
+ title: string;
31
+ body: string;
32
+ /** A path in the app, an absolute URL, or `null` (nothing to open). */
33
+ url: string | null;
34
+ /** ISO date-time. */
35
+ created_at: string;
36
+ /** ISO date-time, or `null` while unread. */
37
+ read_at: string | null;
38
+ /** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on
39
+ * the row — kastlan's inbox holds every company's rows. */
40
+ company?: string | null;
41
+ }
42
+
43
+ export interface NotificationInboxProps<Id extends string | number = string | number>
44
+ extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
45
+ /** The rows, newest first; `undefined` while the first page loads. */
46
+ items: readonly NotificationInboxItem<Id>[] | undefined;
47
+ /** Every unread row of the account (`unread_count`), not only this page's. */
48
+ unreadCount: number;
49
+ loading?: boolean;
50
+ /** `POST /notifications/inbox/read {ids}` — these rows, or `null` for all. */
51
+ onMarkRead: (ids: Id[] | null) => Promise<unknown> | void;
52
+ /** `DELETE /notifications/inbox`. Asked first, in the header. */
53
+ onClear: () => Promise<unknown> | void;
54
+ /**
55
+ * Open a row: the kit marks it read first, then calls this, and the app routes to
56
+ * `item.url` (kastlan switches the acting company first). Every row is a button then.
57
+ * Left out, a row with a `url` is a real link — an in-app path through the provider's
58
+ * router link, an absolute URL in a new tab — that marks itself read on a click or a
59
+ * middle click (keksdose #451), and a row without one marks itself read.
60
+ */
61
+ onOpen?: (item: NotificationInboxItem<Id>) => void;
62
+ /** The row's glyph for a type — the app's map (keksdose's `TYPE_ICONS`). `undefined`,
63
+ * or a type it does not know (a retired one), is the `Bell`. */
64
+ icon?: (type: string) => LucideIcon | undefined;
65
+ /** More rows than shown: a "Show more" row calls `onLoadMore` (the API pages by
66
+ * `offset`). */
67
+ hasMore?: boolean;
68
+ onLoadMore?: () => void;
69
+ /** The next page is loading. */
70
+ loadingMore?: boolean;
71
+ /** A row's time. Default: the provider's locale and `timeZone`, date and time. */
72
+ formatDate?: (iso: string) => string;
73
+ /** The heading. Default `labels.inboxTitle` ("Inbox"). */
74
+ title?: ReactNode;
75
+ /** The app's own words for a failed request, or `undefined` for the kit's `failed`. */
76
+ describeError?: (error: unknown) => ReactNode | undefined;
77
+ labels?: LabelOverride<NotificationsLabels>;
78
+ }
79
+
80
+ /**
81
+ * The in-app inbox (0.46, docs/notifications-harmonization.md §10.1), keksdose's list
82
+ * lifted: newest first, an icon per type (`Bell` for one the app does not map), the
83
+ * title and two lines of body, the time, and — kastlan — the company the row is about.
84
+ * Unread rows are bold with a dot. Props only: the app keeps the data (poll the badge
85
+ * every {@link INBOX_BADGE_INTERVAL}, invalidate on its worker's push message).
86
+ *
87
+ * - **Opening a row marks it read** (see `onOpen`).
88
+ * - **"Mark all as read"** while anything is unread; **"Clear all"** asks first, in the
89
+ * header — no `ConfirmProvider` needed — with focus on Cancel.
90
+ * - **"Show more"** with `hasMore`; the list scrolls inside a box of at most
91
+ * `min(24rem, 70vh)`, so it never pushes the settings below it off the page.
92
+ *
93
+ * Under a write lock the header's actions are `commit` controls exempt from billing.
94
+ */
95
+ export function NotificationInbox<Id extends string | number = string | number>({
96
+ items,
97
+ unreadCount,
98
+ loading,
99
+ onMarkRead,
100
+ onClear,
101
+ onOpen,
102
+ icon,
103
+ hasMore = false,
104
+ onLoadMore,
105
+ loadingMore = false,
106
+ formatDate: formatDateProp,
107
+ title,
108
+ describeError,
109
+ labels: labelsProp,
110
+ className,
111
+ ...rest
112
+ }: NotificationInboxProps<Id>) {
113
+ const labels = useNotificationsLabels(labelsProp);
114
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
115
+ const { formatDate } = useKitFormat();
116
+ const [asking, setAsking] = useState(false);
117
+ const [busy, setBusy] = useState<"read" | "clear" | null>(null);
118
+ const [failure, setFailure] = useState<ReactNode>(null);
119
+ const mounted = useMounted();
120
+ const headingId = useId();
121
+ const cancelRef = useRef<HTMLButtonElement>(null);
122
+ const clearRef = useRef<HTMLButtonElement>(null);
123
+ const [returning, setReturning] = useState(false);
124
+ useEffect(() => {
125
+ if (asking) cancelRef.current?.focus();
126
+ else if (returning) clearRef.current?.focus();
127
+ }, [asking, returning]);
128
+
129
+ const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "dateTime"));
130
+ const iconOf = (type: string): LucideIcon => icon?.(type) ?? Bell;
131
+
132
+ const run = (which: "read" | "clear", call: () => unknown, after?: () => void) => {
133
+ setBusy(which);
134
+ setFailure(null);
135
+ settle(call).then(
136
+ () => {
137
+ if (!mounted.current) return;
138
+ setBusy(null);
139
+ after?.();
140
+ },
141
+ (error: unknown) => {
142
+ if (!mounted.current) return;
143
+ setBusy(null);
144
+ setFailure(describeFailure(describeError?.(error), labels.failed));
145
+ },
146
+ );
147
+ };
148
+
149
+ /** Mark one row read; a failure is said under the list, the row stays as it is. */
150
+ const markOne = (item: NotificationInboxItem<Id>) => {
151
+ if (item.read_at !== null) return;
152
+ settle(() => onMarkRead([item.id])).catch((error: unknown) => {
153
+ if (mounted.current) setFailure(describeFailure(describeError?.(error), labels.failed));
154
+ });
155
+ };
156
+
157
+ const leave = () => {
158
+ setReturning(true);
159
+ setAsking(false);
160
+ };
161
+ const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {
162
+ if (event.key === "Escape") {
163
+ event.preventDefault();
164
+ event.stopPropagation();
165
+ leave();
166
+ }
167
+ };
168
+
169
+ const count = items?.length ?? 0;
170
+
171
+ const header = asking ? (
172
+ <div className="space-y-2 border-b border-subtle px-3 py-2" data-inbox-confirm="">
173
+ <p role="alert" className="text-sm">
174
+ {labels.clearPrompt}
175
+ </p>
176
+ <div className="flex flex-wrap gap-2">
177
+ <Button
178
+ type="button"
179
+ size="sm"
180
+ variant="danger"
181
+ commit={COMMIT_EXCEPT_BILLING}
182
+ pending={busy === "clear"}
183
+ onClick={() => run("clear", onClear, () => setAsking(false))}
184
+ onKeyDown={escapeLeaves}
185
+ >
186
+ {labels.clearAll}
187
+ </Button>
188
+ <Button ref={cancelRef} type="button" size="sm" variant="ghost" onClick={leave} onKeyDown={escapeLeaves}>
189
+ {common.cancel}
190
+ </Button>
191
+ </div>
192
+ </div>
193
+ ) : (
194
+ <div className="flex items-center justify-between gap-2 border-b border-subtle px-3 py-2">
195
+ <SettingsCardTitle id={headingId}>{title ?? labels.inboxTitle}</SettingsCardTitle>
196
+ <div className="flex items-center gap-0.5">
197
+ <IconButton
198
+ type="button"
199
+ size="xs"
200
+ tone="muted"
201
+ commit={COMMIT_EXCEPT_BILLING}
202
+ onClick={() => run("read", () => onMarkRead(null))}
203
+ disabled={unreadCount === 0 || busy !== null}
204
+ label={labels.markAllRead}
205
+ >
206
+ <CheckCheck />
207
+ </IconButton>
208
+ <IconButton
209
+ ref={clearRef}
210
+ type="button"
211
+ size="xs"
212
+ tone="danger"
213
+ commit={COMMIT_EXCEPT_BILLING}
214
+ onClick={() => {
215
+ setReturning(false);
216
+ setAsking(true);
217
+ }}
218
+ disabled={count === 0 || busy !== null}
219
+ label={labels.clearAll}
220
+ >
221
+ <Trash2 />
222
+ </IconButton>
223
+ </div>
224
+ </div>
225
+ );
226
+
227
+ let body: ReactNode;
228
+ if (loading || items === undefined) {
229
+ body = <Skeleton lines={3} label={labels.inboxLoading} className="p-3" />;
230
+ } else if (items.length === 0) {
231
+ body = <EmptyState variant="inline" title={labels.inboxEmpty} className="px-3 py-2" />;
232
+ } else {
233
+ body = (
234
+ // Named by the heading — except while "Clear all" asks, when the heading is away.
235
+ <List className="p-1" aria-labelledby={asking ? undefined : headingId} aria-label={asking ? labels.inboxTitle : undefined}>
236
+ {items.map((item) => {
237
+ const row = {
238
+ title: item.title,
239
+ subtitle: item.body,
240
+ subtitleLines: 2 as const,
241
+ overline: item.company ?? undefined,
242
+ meta: timeOf(item.created_at),
243
+ icon: iconOf(item.notification_type),
244
+ unread: item.read_at === null,
245
+ align: "start" as const,
246
+ };
247
+ if (onOpen) {
248
+ return (
249
+ <ListItem
250
+ key={item.id}
251
+ {...row}
252
+ data-inbox-item={String(item.id)}
253
+ onClick={() => {
254
+ markOne(item);
255
+ onOpen(item);
256
+ }}
257
+ />
258
+ );
259
+ }
260
+ if (!item.url) {
261
+ return <ListItem key={item.id} {...row} data-inbox-item={String(item.id)} onClick={() => markOne(item)} />;
262
+ }
263
+ // A real link, so a middle click opens a background tab; a middle click fires no
264
+ // `click`, hence `onAuxClick` (keksdose #451).
265
+ return (
266
+ <ListItem
267
+ key={item.id}
268
+ {...row}
269
+ data-inbox-item={String(item.id)}
270
+ href={item.url}
271
+ external={!item.url.startsWith("/")}
272
+ onClick={() => markOne(item)}
273
+ onAuxClick={(event) => {
274
+ if (event.button === 1) markOne(item);
275
+ }}
276
+ />
277
+ );
278
+ })}
279
+ {hasMore && onLoadMore && (
280
+ <li className="px-2 py-1">
281
+ <Button type="button" size="sm" variant="ghost" pending={loadingMore} onClick={onLoadMore} className="w-full">
282
+ {labels.showMore}
283
+ </Button>
284
+ </li>
285
+ )}
286
+ </List>
287
+ );
288
+ }
289
+
290
+ return (
291
+ <Card {...rest} className={cn("overflow-hidden p-0", className)}>
292
+ {header}
293
+ <div className="max-h-[min(24rem,70vh)] overflow-y-auto overscroll-contain">{body}</div>
294
+ {failure !== null && (
295
+ <div className="px-3 pb-3">
296
+ <FailureLine>{failure}</FailureLine>
297
+ </div>
298
+ )}
299
+ </Card>
300
+ );
301
+ }
302
+
303
+ /** How often an app polls the unread count for the badge (keksdose's 60 s, `limit:1`). */
304
+ export const INBOX_BADGE_INTERVAL = 60_000;