@phoenix-iq/notifications 0.0.0-stage → 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/dist/bell.js ADDED
@@ -0,0 +1,331 @@
1
+ import { applyColors, applyTheme, colorOr, ICONS, svg, uiLanguage } from './theme.js';
2
+ const MESSAGE_MS = 3500;
3
+ const ATTENTION_MS = 4000;
4
+ const TEXTS = {
5
+ en: {
6
+ unsubscribed: 'Subscribe to notifications',
7
+ subscribed: "You're subscribed to notifications",
8
+ blocked: "You've blocked notifications",
9
+ thanks: 'Thanks for subscribing!',
10
+ goodbye: "You won't get notifications any more",
11
+ title: 'Notifications',
12
+ subscribedText: "You're getting this site's notifications in this browser.",
13
+ unsubscribedText: "Get this site's news as notifications in this browser.",
14
+ subscribe: 'Subscribe',
15
+ unsubscribe: 'Unsubscribe',
16
+ blockedTitle: 'Notifications are blocked',
17
+ blockedText: 'To get them, allow notifications for this site: open the site settings next to the address bar and set Notifications to Allow.',
18
+ failed: "This browser couldn't be subscribed. Try again later.",
19
+ close: 'Close',
20
+ },
21
+ ar: {
22
+ unsubscribed: 'اشترك في الإشعارات',
23
+ subscribed: 'أنت مشترك في الإشعارات',
24
+ blocked: 'الإشعارات محظورة في هذا المتصفح',
25
+ thanks: 'شكرًا لاشتراكك!',
26
+ goodbye: 'لن تصلك الإشعارات بعد الآن',
27
+ title: 'الإشعارات',
28
+ subscribedText: 'تصلك إشعارات هذا الموقع على هذا المتصفح.',
29
+ unsubscribedText: 'اشترك لتصلك أخبار هذا الموقع وجديده على هذا المتصفح.',
30
+ subscribe: 'اشتراك',
31
+ unsubscribe: 'إلغاء الاشتراك',
32
+ blockedTitle: 'الإشعارات محظورة',
33
+ blockedText: 'لتصلك الإشعارات، اسمح بها لهذا الموقع: افتح إعدادات الموقع بجانب شريط العنوان واختر «السماح» للإشعارات.',
34
+ failed: 'تعذّر الاشتراك من هذا المتصفح. حاول لاحقًا.',
35
+ close: 'إغلاق',
36
+ },
37
+ };
38
+ const STYLE = `
39
+ :host { all: initial; position: fixed; z-index: 2147483000; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); font-family: inherit; }
40
+ :host([data-side="right"]) { right: calc(20px + env(safe-area-inset-right, 0px)); }
41
+ :host([data-side="left"]) { left: calc(20px + env(safe-area-inset-left, 0px)); }
42
+ @media (max-width: 480px) {
43
+ :host { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
44
+ :host([data-side="right"]) { right: calc(16px + env(safe-area-inset-right, 0px)); }
45
+ :host([data-side="left"]) { left: calc(16px + env(safe-area-inset-left, 0px)); }
46
+ }
47
+ @media print { :host { display: none !important; } }
48
+ * { box-sizing: border-box; }
49
+ [hidden] { display: none !important; }
50
+ .root { position: relative; font-family: inherit; }
51
+ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
52
+ .bell {
53
+ position: relative; display: grid; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%;
54
+ background: var(--bell); color: var(--bell-text); cursor: pointer;
55
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28), 0 1px 3px rgba(0, 0, 0, 0.22);
56
+ transition: transform 0.15s ease, opacity 0.15s ease;
57
+ }
58
+ .bell:hover { transform: scale(1.06); }
59
+ .bell:active { transform: scale(0.95); }
60
+ .bell:focus-visible { outline: 3px solid var(--bell); outline-offset: 3px; }
61
+ :host([data-view="blocked"]) .bell { opacity: 0.82; }
62
+ .bell::after { content: ""; position: absolute; inset: 0; border-radius: 50%; pointer-events: none; }
63
+ :host([data-attention][data-view="unsubscribed"]) .bell::after { animation: ring 1.6s ease-out 2; }
64
+ @keyframes ring { from { box-shadow: 0 0 0 0 var(--ring); } to { box-shadow: 0 0 0 14px rgba(0, 0, 0, 0); } }
65
+ .badge {
66
+ position: absolute; top: -3px; right: -3px; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%;
67
+ background: #16a34a; color: #ffffff; border: 2px solid #ffffff;
68
+ }
69
+ .bubble {
70
+ position: absolute; bottom: 7px; width: max-content; max-width: min(260px, calc(100vw - 104px)); padding: 7px 11px; border-radius: 8px;
71
+ background: var(--tip); color: var(--tip-text); font-size: 13px; line-height: 1.45; text-align: start;
72
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); opacity: 0; transform: translateY(4px); pointer-events: none;
73
+ transition: opacity 0.15s ease, transform 0.15s ease;
74
+ }
75
+ :host([data-side="right"]) .bubble { right: 60px; }
76
+ :host([data-side="left"]) .bubble { left: 60px; }
77
+ .bubble[data-shown] { opacity: 1; transform: none; }
78
+ .panel {
79
+ position: absolute; bottom: 60px; width: min(300px, calc(100vw - 32px)); padding: 14px 16px 16px; border-radius: 14px;
80
+ background: var(--panel); color: var(--text); border: 1px solid var(--border); text-align: start;
81
+ box-shadow: 0 14px 40px rgba(0, 0, 0, 0.32); font-size: 14px; line-height: 1.55;
82
+ }
83
+ :host([data-side="right"]) .panel { right: 0; }
84
+ :host([data-side="left"]) .panel { left: 0; }
85
+ .head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
86
+ .title { flex: 1; margin: 0; font-size: 15px; font-weight: 700; }
87
+ .text { margin: 0 0 14px; color: var(--muted); }
88
+ .error { margin: -6px 0 12px; color: var(--error); font-size: 13px; }
89
+ .close {
90
+ display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; margin-inline-end: -6px;
91
+ background: transparent; color: inherit; opacity: 0.7; cursor: pointer;
92
+ }
93
+ .close:hover { opacity: 1; background: var(--hover); }
94
+ .action {
95
+ width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid transparent; border-radius: 9px; font-weight: 600; cursor: pointer;
96
+ background: var(--bell); color: var(--bell-text);
97
+ }
98
+ .action[data-kind="secondary"] { background: transparent; color: inherit; border-color: var(--border); }
99
+ .action[data-kind="secondary"]:hover { background: var(--hover); }
100
+ .action:disabled { opacity: 0.6; cursor: progress; }
101
+ .close:focus-visible, .action:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .bell, .bubble { transition: none; }
104
+ .bell:hover, .bell:active { transform: none; }
105
+ :host([data-attention][data-view="unsubscribed"]) .bell::after { animation: none; }
106
+ }
107
+ `;
108
+ /** Draws the bell into the page; `destroy()` takes it away again. */
109
+ export function mountBell(api, settings, language) {
110
+ const lang = uiLanguage(language);
111
+ const t = TEXTS[lang];
112
+ const host = document.createElement('phoenix-notifications-bell');
113
+ host.dataset.side = settings.position === 'bottom_left' ? 'left' : 'right';
114
+ host.dataset.view = 'unsubscribed';
115
+ applyColors(host, colorOr(settings.color));
116
+ const shadow = host.attachShadow({ mode: 'open' });
117
+ // only constants go into this markup (the texts and icons above, lang 'ar' or 'en'); the colour is set as a property
118
+ shadow.innerHTML = `<style>${STYLE}</style>
119
+ <div class="root" dir="${lang === 'ar' ? 'rtl' : 'ltr'}" lang="${lang}">
120
+ <div class="panel" role="dialog" aria-labelledby="phx-bell-title" hidden>
121
+ <div class="head"><p class="title" id="phx-bell-title"></p><button type="button" class="close" aria-label="${t.close}">${svg(ICONS.close, 18, 2)}</button></div>
122
+ <p class="text"></p>
123
+ <p class="error" role="alert" hidden></p>
124
+ <button type="button" class="action"></button>
125
+ </div>
126
+ <div class="bubble" role="status" aria-live="polite"></div>
127
+ <button type="button" class="bell" aria-haspopup="dialog" aria-expanded="false"><span class="icon"></span><span class="badge" hidden>${svg(ICONS.check, 11, 3)}</span></button>
128
+ </div>`;
129
+ const $ = (selector) => shadow.querySelector(selector);
130
+ const bell = $('.bell');
131
+ const icon = $('.icon');
132
+ const badge = $('.badge');
133
+ const bubble = $('.bubble');
134
+ const panel = $('.panel');
135
+ const title = $('.title');
136
+ const text = $('.text');
137
+ const errorLine = $('.error');
138
+ const action = $('.action');
139
+ const close = $('.close');
140
+ let view = 'unsubscribed';
141
+ let open = false;
142
+ let busy = false;
143
+ let failed = false;
144
+ let message = null;
145
+ let bubbleTimer = 0;
146
+ let iconFor = null;
147
+ /** Hidden until the first read of the state, so it never shows a wrong state (or shows at all, when it hides) first. */
148
+ let settled = false;
149
+ const viewOf = (state) => (state.permission === 'denied' ? 'blocked' : state.subscribed ? 'subscribed' : 'unsubscribed');
150
+ function render() {
151
+ host.dataset.view = view;
152
+ const hidden = !settled || (!!settings.hideWhenSubscribed && view === 'subscribed' && !open && !busy && !message);
153
+ host.style.display = hidden ? 'none' : '';
154
+ bell.setAttribute('aria-label', t[view]);
155
+ bell.setAttribute('aria-expanded', String(open));
156
+ if (iconFor !== view) {
157
+ icon.innerHTML = svg(view === 'blocked' ? ICONS.bellOff : ICONS.bell, 24);
158
+ iconFor = view;
159
+ }
160
+ badge.hidden = view !== 'subscribed';
161
+ panel.hidden = !open;
162
+ if (!open)
163
+ return;
164
+ title.textContent = view === 'blocked' ? t.blockedTitle : t.title;
165
+ text.textContent = view === 'blocked' ? t.blockedText : view === 'subscribed' ? t.subscribedText : t.unsubscribedText;
166
+ errorLine.hidden = !failed;
167
+ errorLine.textContent = t.failed;
168
+ action.hidden = view === 'blocked';
169
+ action.textContent = view === 'subscribed' ? t.unsubscribe : t.subscribe;
170
+ action.dataset.kind = view === 'subscribed' ? 'secondary' : 'primary';
171
+ action.disabled = busy;
172
+ }
173
+ function showBubble(content, ms) {
174
+ window.clearTimeout(bubbleTimer);
175
+ applyTheme(host);
176
+ bubble.textContent = content;
177
+ bubble.dataset.shown = '';
178
+ if (ms)
179
+ bubbleTimer = window.setTimeout(hideBubble, ms);
180
+ }
181
+ function hideBubble() {
182
+ window.clearTimeout(bubbleTimer);
183
+ const told = message !== null;
184
+ message = null;
185
+ delete bubble.dataset.shown;
186
+ // the thanks are over: a bell that hides once subscribed goes now
187
+ if (told)
188
+ render();
189
+ }
190
+ /** Something happened (subscribed, unsubscribed): said in the bubble for a few seconds. */
191
+ function tell(content) {
192
+ message = content;
193
+ showBubble(content, MESSAGE_MS);
194
+ }
195
+ function setOpen(next, focus) {
196
+ open = next;
197
+ failed = next && failed;
198
+ if (open) {
199
+ hideBubble();
200
+ applyTheme(host);
201
+ }
202
+ render();
203
+ if (focus === 'panel')
204
+ (action.hidden ? close : action).focus();
205
+ else if (focus === 'bell')
206
+ bell.focus();
207
+ }
208
+ async function settle() {
209
+ try {
210
+ view = viewOf(await api.state());
211
+ }
212
+ catch {
213
+ // keep the last view
214
+ }
215
+ }
216
+ async function subscribe() {
217
+ if (busy)
218
+ return;
219
+ busy = true;
220
+ failed = false;
221
+ render();
222
+ try {
223
+ // the browser's prompt opens inside this call, while the click still counts as the person's gesture
224
+ const state = await api.subscribe();
225
+ if (state.subscribed) {
226
+ setOpen(false, null);
227
+ tell(t.thanks);
228
+ }
229
+ else if (state.permission === 'granted') {
230
+ failed = true;
231
+ }
232
+ }
233
+ catch (error) {
234
+ console.warn('[notifications] subscribing failed', error);
235
+ failed = true;
236
+ }
237
+ finally {
238
+ busy = false;
239
+ await settle();
240
+ if (failed && !open)
241
+ setOpen(true, 'panel');
242
+ else
243
+ render();
244
+ }
245
+ }
246
+ async function unsubscribe() {
247
+ if (busy)
248
+ return;
249
+ busy = true;
250
+ render();
251
+ try {
252
+ await api.unsubscribe();
253
+ }
254
+ catch (error) {
255
+ // the opt-out is kept even when the server could not be told; the browser no longer subscribes
256
+ console.warn('[notifications] unsubscribing failed', error);
257
+ }
258
+ finally {
259
+ busy = false;
260
+ await settle();
261
+ setOpen(false, 'bell');
262
+ tell(t.goodbye);
263
+ }
264
+ }
265
+ bell.addEventListener('click', () => {
266
+ if (busy)
267
+ return;
268
+ // first time here: the browser's own prompt is the clearest question, so ask straight away
269
+ if (!open && view === 'unsubscribed' && Notification.permission === 'default') {
270
+ hideBubble();
271
+ void subscribe();
272
+ return;
273
+ }
274
+ setOpen(!open, open ? null : 'panel');
275
+ });
276
+ action.addEventListener('click', () => void (view === 'subscribed' ? unsubscribe() : subscribe()));
277
+ close.addEventListener('click', () => setOpen(false, 'bell'));
278
+ // the bell's label as a tooltip, for a mouse or the keyboard (a tap goes straight to the click)
279
+ bell.addEventListener('pointerenter', (event) => {
280
+ if (event.pointerType === 'mouse' && !open && !message)
281
+ showBubble(t[view]);
282
+ });
283
+ bell.addEventListener('pointerleave', () => {
284
+ if (!message)
285
+ hideBubble();
286
+ });
287
+ bell.addEventListener('focus', () => {
288
+ if (bell.matches(':focus-visible') && !open && !message)
289
+ showBubble(t[view]);
290
+ });
291
+ bell.addEventListener('blur', () => {
292
+ if (!message)
293
+ hideBubble();
294
+ });
295
+ const onKey = (event) => {
296
+ if (event.key === 'Escape' && open) {
297
+ event.stopPropagation();
298
+ setOpen(false, 'bell');
299
+ }
300
+ };
301
+ shadow.addEventListener('keydown', onKey);
302
+ const onOutside = (event) => {
303
+ if (open && !event.composedPath().includes(host))
304
+ setOpen(false, null);
305
+ };
306
+ document.addEventListener('pointerdown', onOutside);
307
+ const stop = api.onChange((state) => {
308
+ view = viewOf(state);
309
+ if (!busy)
310
+ render();
311
+ });
312
+ render();
313
+ document.body.appendChild(host);
314
+ void settle().then(() => {
315
+ settled = true;
316
+ render();
317
+ // a gentle pulse, twice, the first time the bell offers itself on a page
318
+ if (view === 'unsubscribed' && Notification.permission === 'default') {
319
+ host.dataset.attention = '';
320
+ window.setTimeout(() => delete host.dataset.attention, ATTENTION_MS);
321
+ }
322
+ });
323
+ return {
324
+ destroy() {
325
+ stop();
326
+ window.clearTimeout(bubbleTimer);
327
+ document.removeEventListener('pointerdown', onOutside);
328
+ host.remove();
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,83 @@
1
+ import { type StoredDevice } from './store.js';
2
+ /**
3
+ * Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
4
+ * the signed-in user with an identity token from the app's own backend, and hands notification clicks to the app. When the
5
+ * app has its subscription bell or banner switched on in HQ, it also draws them, so a site needs no button of its own.
6
+ *
7
+ * Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
8
+ * The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
9
+ */
10
+ export declare const DEFAULT_SERVER_URL = "https://notifications.phnx-iq.com";
11
+ export declare const DEFAULT_SERVICE_WORKER_PATH = "/phoenix-notifications-sw.js";
12
+ export interface InitOptions {
13
+ /** The app id from HQ (Notification management → the app). */
14
+ appId: string;
15
+ /** Where the notifications API is. '' means this site's own origin (development through a proxy). */
16
+ serverUrl?: string;
17
+ serviceWorkerPath?: string;
18
+ serviceWorkerScope?: string;
19
+ /** Language the device reports (picks Arabic or English text, and the bell's language); defaults to the page's, then the browser's. */
20
+ language?: string;
21
+ appVersion?: string;
22
+ /**
23
+ * The subscription bell, drawn when the app has it switched on in HQ (the app → Web push → Subscription bell). `false`
24
+ * never draws it, for a site with its own "Get notifications" button.
25
+ */
26
+ bell?: boolean;
27
+ /** The subscription banner, shown when the app has it switched on in HQ (the app → Web push → Subscription banner). `false` never shows it. */
28
+ banner?: boolean;
29
+ }
30
+ export type PushPermission = 'granted' | 'denied' | 'default' | 'unsupported';
31
+ /** Whether this browser gets the app's notifications, for a site's own button. */
32
+ export interface SubscriptionState {
33
+ permission: PushPermission;
34
+ /** Subscribed and registered with the server. False after `unsubscribe()`, even though the permission stays granted. */
35
+ subscribed: boolean;
36
+ }
37
+ export interface NotificationClick {
38
+ url?: string;
39
+ data: Record<string, string>;
40
+ messageId?: string;
41
+ }
42
+ export declare class NotificationsError extends Error {
43
+ readonly status: number;
44
+ constructor(status: number, message: string);
45
+ }
46
+ export declare const Notifications: {
47
+ /** True when this browser can receive web push here (a secure context with service workers and the Push API). */
48
+ isSupported(): boolean;
49
+ /**
50
+ * Registers the service worker, and draws the subscription bell and the banner when the app has them on. When the person
51
+ * already allowed notifications (and did not unsubscribe here), subscribes quietly.
52
+ */
53
+ init(init: InitOptions): Promise<void>;
54
+ permission(): PushPermission;
55
+ /** Whether this browser gets the app's notifications now. */
56
+ getState(): Promise<SubscriptionState>;
57
+ /**
58
+ * Asks the person (call it from a click: browsers only ask on a user gesture) and subscribes when they allow. It also
59
+ * subscribes again after `unsubscribe()`, without asking, since the browser keeps the permission.
60
+ */
61
+ requestPermission(): Promise<PushPermission>;
62
+ /**
63
+ * Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
64
+ * notifications there is no device yet: the token is kept and used once they do.
65
+ */
66
+ login(identityToken: string): Promise<void>;
67
+ /** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
68
+ logout(): Promise<void>;
69
+ /**
70
+ * Stops notifications in this browser altogether, and remembers it: later page loads do not subscribe again although the
71
+ * permission stays granted, until `requestPermission()` (the bell's "Subscribe").
72
+ */
73
+ unsubscribe(): Promise<void>;
74
+ /** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
75
+ onClick(handler: (click: NotificationClick) => void): () => void;
76
+ /** Called when this browser subscribes, unsubscribes or the permission changes. Returns an unsubscribe function. */
77
+ onSubscriptionChange(handler: (state: SubscriptionState) => void): () => void;
78
+ /** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
79
+ refresh(): Promise<void>;
80
+ };
81
+ export type { BannerPosition } from './banner.js';
82
+ export type { BellPosition } from './bell.js';
83
+ export type { StoredDevice };