@12-apps/notifications 1.0.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 (47) hide show
  1. package/ADOPTING.md +316 -0
  2. package/README.md +153 -0
  3. package/package.json +94 -0
  4. package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
  5. package/prisma/notifications.prisma +141 -0
  6. package/scripts/sync-notifications-schema.mjs +60 -0
  7. package/src/errors.ts +21 -0
  8. package/src/generators.ts +44 -0
  9. package/src/hono/index.ts +121 -0
  10. package/src/index.ts +73 -0
  11. package/src/messages.ts +156 -0
  12. package/src/phone.ts +57 -0
  13. package/src/preferences-core.ts +89 -0
  14. package/src/react/api.ts +111 -0
  15. package/src/react/bell-button.tsx +78 -0
  16. package/src/react/bell-icon.tsx +33 -0
  17. package/src/react/create-web-notifications.tsx +127 -0
  18. package/src/react/hooks.ts +74 -0
  19. package/src/react/inbox-state.ts +216 -0
  20. package/src/react/index.ts +61 -0
  21. package/src/react/panel.tsx +181 -0
  22. package/src/react/preferences-screen.tsx +242 -0
  23. package/src/react/relative-time.ts +18 -0
  24. package/src/react/row.tsx +98 -0
  25. package/src/react/transport.ts +72 -0
  26. package/src/react/web-push-client.ts +113 -0
  27. package/src/react/web-push-setup.tsx +167 -0
  28. package/src/server/by-permission.ts +255 -0
  29. package/src/server/context.ts +269 -0
  30. package/src/server/create-api-notifications.ts +215 -0
  31. package/src/server/db.ts +252 -0
  32. package/src/server/dispatch.ts +298 -0
  33. package/src/server/inbox.ts +155 -0
  34. package/src/server/index.ts +115 -0
  35. package/src/server/preferences.ts +103 -0
  36. package/src/server/push-subscriptions.ts +121 -0
  37. package/src/server/router.ts +275 -0
  38. package/src/server/routes.ts +218 -0
  39. package/src/server/transports/drivers.ts +148 -0
  40. package/src/server/transports/email.ts +141 -0
  41. package/src/server/transports/registry.ts +106 -0
  42. package/src/server/transports/sms.ts +120 -0
  43. package/src/server/transports/web-push.ts +168 -0
  44. package/src/server/transports/whatsapp.ts +183 -0
  45. package/src/types.ts +158 -0
  46. package/src/web-push/index.ts +70 -0
  47. package/src/wire.ts +62 -0
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@12-apps/notifications` — the channel-agnostic notification system: an
3
+ * always-on in-app inbox, per-user × per-category channel preferences, and
4
+ * pluggable email / SMS / WhatsApp / web-push transports behind vendor
5
+ * drivers (12-15).
6
+ *
7
+ * This ROOT entry is the framework-free, storage-free core: the types, the
8
+ * generator registry, the preference policy and the phone rules. It imports
9
+ * nothing that a browser cannot run and touches no database, which is what
10
+ * lets the react half share the vocabulary with the server half instead of
11
+ * restating it.
12
+ *
13
+ * The two halves each have ONE factory:
14
+ *
15
+ * import { createApiNotifications } from '@12-apps/notifications/server';
16
+ * import { createWebNotifications } from '@12-apps/notifications/react';
17
+ *
18
+ * plus `@12-apps/notifications/hono` to mount the backend on Hono and
19
+ * `@12-apps/notifications/web-push` for the VAPID sender (which is the only
20
+ * piece that needs the `web-push` package, hence its own subpath).
21
+ */
22
+
23
+ export {
24
+ NOTIFICATION_CATEGORIES,
25
+ NOTIFICATION_CHANNELS,
26
+ taxonomyOf,
27
+ type DeliveryStatus,
28
+ type NotificationCategory,
29
+ type NotificationChannel,
30
+ type NotificationContent,
31
+ type NotificationEvent,
32
+ type NotificationGenerator,
33
+ type NotificationLogger,
34
+ type NotificationRecipient,
35
+ type NotificationTaxonomy,
36
+ type NotificationTransport,
37
+ type TransportRecipient,
38
+ } from './types';
39
+
40
+ export {
41
+ UnknownNotificationRecipientError,
42
+ UnknownNotificationTypeError,
43
+ } from './errors';
44
+
45
+ export {
46
+ createGeneratorRegistry,
47
+ type NotificationGeneratorRegistry,
48
+ } from './generators';
49
+
50
+ export {
51
+ DEFAULT_CHANNEL_ROW,
52
+ defaultChannelMatrix,
53
+ enabledChannelsOf,
54
+ mergeChoices,
55
+ mergeStoredRow,
56
+ type ChannelMatrix,
57
+ type ChannelRow,
58
+ } from './preferences-core';
59
+
60
+ export { normalizePhoneE164, type PhoneNormalizeOptions } from './phone';
61
+
62
+ export {
63
+ DEFAULT_NOTIFICATION_MESSAGES,
64
+ messagesOf,
65
+ type NotificationMessages,
66
+ } from './messages';
67
+
68
+ export {
69
+ inboxWire,
70
+ type InboxNotification,
71
+ type ListNotificationsResult,
72
+ type NotificationRow,
73
+ } from './wire';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Every sentence this package can say to a USER, in one table.
3
+ *
4
+ * pt-BR by default because that is the product copy the surface shipped with
5
+ * (future-pay's storefront and backoffice are Brazilian); a host in another
6
+ * market passes `messages` and overrides the subset it cares about. The copy
7
+ * lives HERE rather than in each screen so the api half and the react half can
8
+ * never disagree about a sentence — the 401 body the wire returns and the
9
+ * error the panel renders come from the same key.
10
+ */
11
+ export interface NotificationMessages {
12
+ // --- the wire ------------------------------------------------------------
13
+ unauthenticated: string;
14
+ invalidBody: string;
15
+ operationFailed: string;
16
+ /** `POST /notifications/mark-read` with neither `ids` nor `all`. */
17
+ markReadTargetRequired: string;
18
+
19
+ // --- the inbox panel -----------------------------------------------------
20
+ panelTitle: string;
21
+ markAllRead: string;
22
+ loading: string;
23
+ loadMore: string;
24
+ loadingMore: string;
25
+ loadFailedTitle: string;
26
+ loadFailedBody: string;
27
+ retry: string;
28
+ emptyTitle: string;
29
+ emptyBody: string;
30
+ openBell: string;
31
+ /** `(count) => 'Abrir notificações (3 não lidas)'`. */
32
+ openBellWithUnread: (count: number) => string;
33
+ unreadSuffix: string;
34
+ deleteOne: (title: string) => string;
35
+
36
+ // --- relative timestamps -------------------------------------------------
37
+ justNow: string;
38
+ minutesAgo: (minutes: number) => string;
39
+ hoursAgo: (hours: number) => string;
40
+ daysAgo: (days: number) => string;
41
+ /** Locale for the fallback absolute date on rows older than a week. */
42
+ dateLocale: string;
43
+
44
+ // --- the preferences screen ---------------------------------------------
45
+ preferencesTitle: string;
46
+ preferencesLead: string;
47
+ channelLabels: Record<string, string>;
48
+ channelUnavailableHints: Record<string, string>;
49
+ categoryLabels: Record<string, { title: string; description: string }>;
50
+ /** Fallback title for a category the host added but did not label. */
51
+ categoryFallbackTitle: (category: string) => string;
52
+ devicePushTitle: string;
53
+ devicePushIdle: string;
54
+ devicePushOn: string;
55
+ devicePushDenied: string;
56
+ devicePushFailed: string;
57
+ devicePushEnable: string;
58
+ devicePushEnabling: string;
59
+ }
60
+
61
+ /** future-pay's exact copy — the product default. */
62
+ export const DEFAULT_NOTIFICATION_MESSAGES: NotificationMessages = {
63
+ unauthenticated: 'Não autenticado.',
64
+ invalidBody: 'Dados inválidos.',
65
+ operationFailed: 'Não foi possível concluir a operação.',
66
+ markReadTargetRequired: 'Informe `ids` ou `all: true` (exatamente um).',
67
+
68
+ panelTitle: 'Notificações',
69
+ markAllRead: 'Marcar todas como lidas',
70
+ loading: 'Carregando notificações...',
71
+ loadMore: 'Carregar mais',
72
+ loadingMore: 'Carregando...',
73
+ loadFailedTitle: 'Não foi possível carregar',
74
+ loadFailedBody: 'Tente novamente em instantes.',
75
+ retry: 'Tentar novamente',
76
+ emptyTitle: 'Nenhuma notificação',
77
+ emptyBody: 'Você está em dia — novidades aparecem aqui.',
78
+ openBell: 'Abrir notificações',
79
+ openBellWithUnread: (count) => `Abrir notificações (${count} não lidas)`,
80
+ unreadSuffix: 'não lida',
81
+ deleteOne: (title) => `Excluir notificação: ${title}`,
82
+
83
+ justNow: 'agora',
84
+ minutesAgo: (minutes) => `há ${minutes} min`,
85
+ hoursAgo: (hours) => `há ${hours} h`,
86
+ daysAgo: (days) => (days === 1 ? 'há 1 dia' : `há ${days} dias`),
87
+ dateLocale: 'pt-BR',
88
+
89
+ preferencesTitle: 'Notificações',
90
+ preferencesLead:
91
+ 'Escolha como quer ser avisado, por tipo de assunto. O sino do app sempre recebe tudo.',
92
+ channelLabels: {
93
+ EMAIL: 'E-mail',
94
+ SMS: 'SMS',
95
+ WHATSAPP: 'WhatsApp',
96
+ WEB_PUSH: 'Navegador',
97
+ },
98
+ channelUnavailableHints: {
99
+ EMAIL: 'Envio de e-mail não está configurado neste ambiente.',
100
+ SMS: 'Cadastre um telefone no seu perfil para receber SMS.',
101
+ WHATSAPP: 'Cadastre um telefone no seu perfil para receber WhatsApp.',
102
+ WEB_PUSH: 'Alertas do navegador não estão configurados neste ambiente.',
103
+ },
104
+ categoryLabels: {
105
+ orders: {
106
+ title: 'Pedidos',
107
+ description: 'Confirmações e andamento dos seus pedidos.',
108
+ },
109
+ payments: {
110
+ title: 'Pagamentos',
111
+ description: 'Cobranças, comprovantes e falhas de pagamento.',
112
+ },
113
+ stock: {
114
+ title: 'Estoque',
115
+ description: 'Alertas de estoque das lojas que você administra.',
116
+ },
117
+ system: {
118
+ title: 'Sistema',
119
+ description: 'Avisos da sua conta e da plataforma.',
120
+ },
121
+ },
122
+ categoryFallbackTitle: (category) => category,
123
+ devicePushTitle: 'Alertas neste navegador',
124
+ devicePushIdle: 'Permita notificações para receber alertas mesmo com o site fechado.',
125
+ devicePushOn: 'Este navegador está recebendo alertas.',
126
+ devicePushDenied:
127
+ 'Permissão negada — habilite notificações nas configurações do navegador.',
128
+ devicePushFailed: 'Não foi possível ativar. Tente novamente.',
129
+ devicePushEnable: 'Ativar',
130
+ devicePushEnabling: 'Ativando...',
131
+ };
132
+
133
+ /** The messages in force, defaulted to the pt-BR product copy. */
134
+ export function messagesOf(config: {
135
+ messages?: Partial<NotificationMessages>;
136
+ }): NotificationMessages {
137
+ const overrides = config.messages ?? {};
138
+ return {
139
+ ...DEFAULT_NOTIFICATION_MESSAGES,
140
+ ...overrides,
141
+ // Nested records merge per KEY. A host relabelling one channel must not
142
+ // erase the labels for the other three, which a shallow spread would do.
143
+ channelLabels: {
144
+ ...DEFAULT_NOTIFICATION_MESSAGES.channelLabels,
145
+ ...overrides.channelLabels,
146
+ },
147
+ channelUnavailableHints: {
148
+ ...DEFAULT_NOTIFICATION_MESSAGES.channelUnavailableHints,
149
+ ...overrides.channelUnavailableHints,
150
+ },
151
+ categoryLabels: {
152
+ ...DEFAULT_NOTIFICATION_MESSAGES.categoryLabels,
153
+ ...overrides.categoryLabels,
154
+ },
155
+ };
156
+ }
package/src/phone.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared phone-destination rules for the SMS + WhatsApp transports.
3
+ *
4
+ * Providers need E.164 (`+5531999998888`); a host stores the phone as the user
5
+ * entered it. Best-effort normalization: an explicit `+` prefix is trusted; a
6
+ * bare 10/11-digit number is assumed to belong to `defaultCountryCode` and
7
+ * prefixed; anything else is unusable and makes the channel unavailable for
8
+ * that recipient.
9
+ *
10
+ * `defaultCountryCode` is REQUIRED, and that is the whole point of it being a
11
+ * parameter. It used to default to `55` (Brazil, the first host's market),
12
+ * which a published package must not do: a US adopter that never set it turned
13
+ * `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a
14
+ * stranger the customer's order details. There is no country this package could
15
+ * assume that is not wrong for every other adopter, so it assumes none and the
16
+ * omission is a compile error rather than a wrong number. future-pay passes
17
+ * `'55'` explicitly.
18
+ *
19
+ * NOTE: "verified phone" is approximated by "has a normalizable phone on
20
+ * file" — a host with a real verification flow should tighten its contact
21
+ * directory to only return verified numbers, which is the single seam both
22
+ * transports funnel through.
23
+ */
24
+
25
+ /** Options for {@link normalizePhoneE164}. */
26
+ export interface PhoneNormalizeOptions {
27
+ /**
28
+ * Country calling code for a bare local number, digits only (`'55'`, `'1'`).
29
+ * Required: see the module docstring for why there is no default.
30
+ */
31
+ defaultCountryCode: string;
32
+ }
33
+
34
+ /** A local subscriber number: area code (2) + 8 or 9 digits. */
35
+ const isLocal = (digits: string): boolean => digits.length === 10 || digits.length === 11;
36
+
37
+ /** An already-international number. E.164 allows 8..15 digits. */
38
+ const international = (digits: string): string | null =>
39
+ digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null;
40
+
41
+ /** Normalize a stored phone to E.164, or null when it can't be inferred. */
42
+ export function normalizePhoneE164(
43
+ raw: string | null | undefined,
44
+ options: PhoneNormalizeOptions,
45
+ ): string | null {
46
+ if (!raw) return null;
47
+ const country = options.defaultCountryCode;
48
+ const trimmed = raw.trim();
49
+ const digits = trimmed.replace(/\D/g, '');
50
+ if (trimmed.startsWith('+')) return international(digits);
51
+ if (isLocal(digits)) return `+${country}${digits}`;
52
+ // A bare number that already carries the country code.
53
+ if (digits.startsWith(country) && isLocal(digits.slice(country.length))) {
54
+ return `+${digits}`;
55
+ }
56
+ return null;
57
+ }
@@ -0,0 +1,89 @@
1
+ import {
2
+ NOTIFICATION_CHANNELS,
3
+ type NotificationCategory,
4
+ type NotificationChannel,
5
+ } from './types';
6
+
7
+ /**
8
+ * The preference POLICY, with no storage in it (12-15): which channels a
9
+ * category defaults to, how a stored JSON row is coerced onto the closed
10
+ * channel set, and how a partial save merges. `./server`'s store is the only
11
+ * thing that touches a database, so every rule here is unit-testable without
12
+ * one — and the react half can render the same defaults before the first read
13
+ * lands.
14
+ *
15
+ * Storage stores only EXPLICIT choices (one row per (user, category)); a
16
+ * missing row — or a missing channel key inside a row — falls back to
17
+ * {@link DEFAULT_CHANNEL_ROW}. Defaults: the free, low-friction channels
18
+ * (e-mail + web push) on; the paid per-message channels (SMS + WhatsApp) off
19
+ * until the user opts in. A host that disagrees passes `channelDefaults`.
20
+ */
21
+
22
+ /** One category's channel toggles. */
23
+ export type ChannelRow = Record<NotificationChannel, boolean>;
24
+
25
+ /** A user's full category × channel matrix. */
26
+ export type ChannelMatrix = Record<NotificationCategory, ChannelRow>;
27
+
28
+ /** The policy applied when a user never touched a category's toggles. */
29
+ export const DEFAULT_CHANNEL_ROW: ChannelRow = {
30
+ EMAIL: true,
31
+ SMS: false,
32
+ WHATSAPP: false,
33
+ WEB_PUSH: true,
34
+ };
35
+
36
+ /** The default matrix for one taxonomy (what the settings UI starts from). */
37
+ export function defaultChannelMatrix(
38
+ categories: readonly NotificationCategory[],
39
+ channelDefaults: Partial<ChannelRow> = {},
40
+ ): ChannelMatrix {
41
+ const row = { ...DEFAULT_CHANNEL_ROW, ...channelDefaults };
42
+ return Object.fromEntries(
43
+ categories.map((category) => [category, { ...row }]),
44
+ ) as ChannelMatrix;
45
+ }
46
+
47
+ /**
48
+ * Coerce a stored JSON `channels` map onto the closed channel set, filling the
49
+ * gaps from `base`. A stored row that predates a channel keeps that channel's
50
+ * default rather than reading as "off", which is what lets a new transport ship
51
+ * without a data migration.
52
+ *
53
+ * The consequence, and the rule it implies: a channel ADDED later turns itself
54
+ * ON for a user who had explicitly switched every channel in that category off,
55
+ * because their stored row has no key for it. That is harmless for the four
56
+ * shipped channels — the two that cost money default off — so **a new channel
57
+ * must be added with a `false` default** unless the user's existing consent
58
+ * already covers it. The alternative (reading a missing key as "off") would need
59
+ * a data migration for every existing row on every channel that ever ships.
60
+ */
61
+ export function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow {
62
+ const row = { ...base };
63
+ if (stored && typeof stored === 'object') {
64
+ const record = stored as Record<string, unknown>;
65
+ for (const channel of NOTIFICATION_CHANNELS) {
66
+ const value = record[channel];
67
+ if (typeof value === 'boolean') row[channel] = value;
68
+ }
69
+ }
70
+ return row;
71
+ }
72
+
73
+ /** The channels enabled by one effective row — the router's gate. */
74
+ export function enabledChannelsOf(row: ChannelRow): NotificationChannel[] {
75
+ return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);
76
+ }
77
+
78
+ /**
79
+ * What a PUT writes for one category: the caller's toggles merged over the
80
+ * user's CURRENT effective row. A single-toggle save (how the settings UI
81
+ * writes) must never reset the category's other channels back to their
82
+ * defaults, which is exactly what a whole-row write would do.
83
+ */
84
+ export function mergeChoices(
85
+ current: ChannelRow,
86
+ choices: Partial<ChannelRow>,
87
+ ): ChannelRow {
88
+ return { ...current, ...choices };
89
+ }
@@ -0,0 +1,111 @@
1
+ import type { ChannelRow } from '../preferences-core';
2
+ import type { NotificationChannel } from '../types';
3
+ import type { ListNotificationsResult } from '../wire';
4
+
5
+ import type { NotificationsResult, NotificationsTransport } from './transport';
6
+
7
+ /**
8
+ * The wire client, bound to one mount (12-15).
9
+ *
10
+ * Every path this package's screens can call, in one place — which is what
11
+ * makes the api half's route table and the web half's URLs one contract instead
12
+ * of two lists that drift.
13
+ */
14
+
15
+ /** `GET <mount>/notification-preferences` and the PUT's answer. */
16
+ export interface PreferencesPayload {
17
+ preferences: Record<string, ChannelRow>;
18
+ availability: Record<NotificationChannel, boolean>;
19
+ /** The host's taxonomy, so the screen renders it without being told twice. */
20
+ categories: string[];
21
+ }
22
+
23
+ /** `GET <mount>/push-subscriptions`. */
24
+ export interface PushRegistrationPayload {
25
+ /** null = web push is not configured on this deployment. */
26
+ vapidPublicKey: string | null;
27
+ count: number;
28
+ /**
29
+ * Whether the endpoint asked about is still registered to the caller. Present
30
+ * only when one was passed — see {@link NotificationsApiClient.getPushRegistration}.
31
+ */
32
+ registered?: boolean;
33
+ }
34
+
35
+ export interface NotificationsApiClient {
36
+ listNotifications(input: {
37
+ cursor?: string | null;
38
+ limit?: number;
39
+ filter?: 'all' | 'unread';
40
+ }): Promise<ListNotificationsResult>;
41
+ unreadCount(): Promise<number>;
42
+ markRead(ids: readonly string[]): Promise<NotificationsResult<{ updated: number }>>;
43
+ markAllRead(): Promise<NotificationsResult<{ updated: number }>>;
44
+ remove(ids: readonly string[]): Promise<NotificationsResult<{ deleted: number }>>;
45
+ getPreferences(): Promise<PreferencesPayload>;
46
+ savePreference(
47
+ category: string,
48
+ channel: NotificationChannel,
49
+ enabled: boolean,
50
+ ): Promise<NotificationsResult<PreferencesPayload>>;
51
+ /**
52
+ * The deployment's VAPID key and the caller's device count — and, when an
53
+ * `endpoint` is passed, whether the SERVER still has that exact subscription
54
+ * under the caller's id. The browser holding a subscription object is not
55
+ * evidence of that: a re-own or a 404/410 prune drops the row and leaves the
56
+ * browser's object in place.
57
+ */
58
+ getPushRegistration(input?: { endpoint?: string }): Promise<PushRegistrationPayload>;
59
+ savePushSubscription(input: {
60
+ endpoint: string;
61
+ keys: { p256dh: string; auth: string };
62
+ }): Promise<NotificationsResult<{ count: number }>>;
63
+ removePushSubscription(endpoint: string): Promise<NotificationsResult<{ count: number }>>;
64
+ }
65
+
66
+ export function createNotificationsApiClient(
67
+ apiBase: string,
68
+ transport: NotificationsTransport,
69
+ ): NotificationsApiClient {
70
+ const base = apiBase.replace(/\/$/, '');
71
+ const url = (path: string): string => `${base}${path}`;
72
+
73
+ return {
74
+ listNotifications({ cursor, limit, filter }) {
75
+ const params = new URLSearchParams();
76
+ if (limit !== undefined) params.set('limit', String(limit));
77
+ if (cursor) params.set('cursor', cursor);
78
+ if (filter) params.set('filter', filter);
79
+ const query = params.toString();
80
+ return transport.get<ListNotificationsResult>(
81
+ url(`/notifications${query ? `?${query}` : ''}`),
82
+ );
83
+ },
84
+ async unreadCount() {
85
+ const { count } = await transport.get<{ count: number }>(
86
+ url('/notifications/unread-count'),
87
+ );
88
+ return count;
89
+ },
90
+ markRead: (ids) =>
91
+ transport.send(url('/notifications/mark-read'), 'POST', { ids: [...ids] }),
92
+ markAllRead: () => transport.send(url('/notifications/mark-read'), 'POST', { all: true }),
93
+ remove: (ids) => transport.send(url('/notifications/delete'), 'POST', { ids: [...ids] }),
94
+ getPreferences: () => transport.get<PreferencesPayload>(url('/notification-preferences')),
95
+ savePreference: (category, channel, enabled) =>
96
+ transport.send(url('/notification-preferences'), 'PUT', {
97
+ [category]: { [channel]: enabled },
98
+ }),
99
+ getPushRegistration: ({ endpoint } = {}) =>
100
+ transport.get<PushRegistrationPayload>(
101
+ url(
102
+ endpoint
103
+ ? `/push-subscriptions?endpoint=${encodeURIComponent(endpoint)}`
104
+ : '/push-subscriptions',
105
+ ),
106
+ ),
107
+ savePushSubscription: (input) => transport.send(url('/push-subscriptions'), 'POST', input),
108
+ removePushSubscription: (endpoint) =>
109
+ transport.send(url('/push-subscriptions'), 'DELETE', { endpoint }),
110
+ };
111
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Bare bell trigger with the live unread badge — for hosts that do not already
3
+ * have a styled icon-button slot. A host with its own trigger chrome uses
4
+ * `useUnreadCount` + `Panel` directly.
5
+ */
6
+ import type { JSX } from 'react';
7
+
8
+ import { Badge } from '@12-apps/ui/data-display/Badge';
9
+ import { Box } from '@12-apps/ui/mui/Box';
10
+
11
+ import type { NotificationMessages } from '../messages';
12
+
13
+ import { BellIcon } from './bell-icon';
14
+ import { useUnreadCount, type NotificationsSubscribe } from './hooks';
15
+ import type { InboxStore } from './inbox-state';
16
+
17
+ const triggerSx = {
18
+ display: 'inline-flex',
19
+ alignItems: 'center',
20
+ justifyContent: 'center',
21
+ p: 0.5,
22
+ border: 'none',
23
+ background: 'none',
24
+ cursor: 'pointer',
25
+ color: 'text.primary',
26
+ lineHeight: 0,
27
+ '& *': { cursor: 'pointer' },
28
+ '&:hover': { color: 'primary.main' },
29
+ '&:focus-visible': {
30
+ outline: '2px solid',
31
+ outlineColor: 'primary.main',
32
+ outlineOffset: '2px',
33
+ borderRadius: '50%',
34
+ },
35
+ } as const;
36
+
37
+ export interface BellButtonProps {
38
+ onClick: () => void;
39
+ /** Signed-out hosts still mount the bell; `false` silences it. */
40
+ enabled?: boolean;
41
+ }
42
+
43
+ export function BellButton({
44
+ onClick,
45
+ enabled = true,
46
+ store,
47
+ messages,
48
+ subscribe,
49
+ }: BellButtonProps & {
50
+ store: InboxStore;
51
+ messages: NotificationMessages;
52
+ subscribe?: NotificationsSubscribe;
53
+ }): JSX.Element {
54
+ const count = useUnreadCount(store, {
55
+ enabled,
56
+ ...(subscribe ? { subscribe } : {}),
57
+ });
58
+ return (
59
+ <Box
60
+ component="button"
61
+ type="button"
62
+ onClick={onClick}
63
+ aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}
64
+ data-testid="notifications-bell"
65
+ sx={triggerSx}
66
+ >
67
+ <Badge
68
+ content={count > 0 ? count : undefined}
69
+ color="primary"
70
+ variant="count"
71
+ max={99}
72
+ data-testid="notifications-badge"
73
+ >
74
+ <BellIcon size={28} />
75
+ </Badge>
76
+ </Box>
77
+ );
78
+ }
@@ -0,0 +1,33 @@
1
+ /** Inline SVG bell (no icon-library dependency in this package). */
2
+ import type { JSX } from 'react';
3
+
4
+ import { Box } from '@12-apps/ui/mui/Box';
5
+
6
+ export function BellIcon({
7
+ size = 28,
8
+ dim = false,
9
+ }: {
10
+ size?: number;
11
+ dim?: boolean;
12
+ }): JSX.Element {
13
+ return (
14
+ <Box
15
+ component="svg"
16
+ viewBox="0 0 24 24"
17
+ aria-hidden
18
+ sx={{
19
+ width: size,
20
+ height: size,
21
+ fill: 'none',
22
+ stroke: 'currentColor',
23
+ opacity: dim ? 0.4 : 1,
24
+ }}
25
+ strokeWidth={1.8}
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ >
29
+ <path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" />
30
+ <path d="M10 20a2 2 0 0 0 4 0" />
31
+ </Box>
32
+ );
33
+ }