@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.
- package/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- 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';
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|
package/src/react/api.ts
ADDED
|
@@ -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
|
+
}
|