@phoenix-iq/notifications 1.0.0 → 1.2.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @phoenix-iq/notifications (web SDK)
2
2
 
3
- Browser push for apps on `notifications.phnx-iq.com`, our replacement for OneSignal's web SDK. It subscribes the browser
4
- (standard Web Push with the app's VAPID key), binds it to your signed-in user with an identity token your backend signs,
5
- and hands notification clicks to your app. TypeScript, no dependencies.
3
+ Browser push for apps on `notifications.phnx-iq.com`. It subscribes the browser (standard Web Push with the app's VAPID key),
4
+ binds it to your signed-in user with an identity token your backend signs, hands notification clicks to your app, and draws
5
+ the subscribe prompt HQ chose for the app: a bell, the app's logo or a pop-up. TypeScript, no dependencies.
6
6
 
7
7
  How the whole service works, the server API and the identity tokens: [docs/notifications.md](../../docs/notifications.md).
8
8
 
@@ -22,9 +22,9 @@ small plugin (HQ's dashboard `vite.config.ts` does).
22
22
  ```ts
23
23
  import { Notifications } from '@phoenix-iq/notifications';
24
24
 
25
- await Notifications.init({ appId: '<app id from HQ: Push apps>' });
25
+ await Notifications.init({ appId: '<app id from HQ: Notification management>' });
26
26
 
27
- // from a click ("Get notifications"): browsers only show the prompt on a user gesture
27
+ // from a click on your own button ("Get notifications"): browsers only ask on a user gesture
28
28
  const permission = await Notifications.requestPermission(); // 'granted' | 'denied' | 'default' | 'unsupported'
29
29
 
30
30
  // after your own sign-in, with the identity token your backend returned
@@ -34,9 +34,44 @@ await Notifications.login(identityToken);
34
34
  await Notifications.logout();
35
35
  ```
36
36
 
37
- `init` subscribes quietly when the person already allowed notifications. `login` before the permission is granted is kept
38
- and applied once it is. Nothing throws on an unsupported browser; check `Notifications.isSupported()` and
39
- `Notifications.permission()` to decide what to show.
37
+ `init` subscribes quietly when the person already allowed notifications (and did not unsubscribe here). `login` before the
38
+ permission is granted is kept and applied once it is. Nothing throws on an unsupported browser; check
39
+ `Notifications.isSupported()` and `Notifications.permission()` to decide what to show.
40
+
41
+ ## The subscribe prompt
42
+
43
+ HQ chooses one per app (the app → Web push → How visitors subscribe) and `init` draws it; a change shows on the next page load.
44
+
45
+ - **Bell** or **logo**: a round button in the bottom right or left corner, in the colour HQ set, showing a bell, or the app's
46
+ icon with a small bell on it (a logo that fails to load gives way to the bell). The first click asks the browser; then the
47
+ button shows that this browser is subscribed and offers "Unsubscribe", or, when notifications were blocked, says how to
48
+ allow them again. With *Hide after subscribing*, only browsers that are not subscribed see it.
49
+ - **Pop-up**: shown, when its time comes, to a browser never asked: a card at the top or bottom with a message, "Subscribe"
50
+ (the browser's own question) and "Later". HQ sets the delay after the page opens, the page view it starts from and how many
51
+ days "Later" keeps it away; the SDK counts these in the browser (IndexedDB). Texts left empty are the SDK's own in the
52
+ page's language. On a phone a top pop-up comes from the bottom, clear of your header; the strip around the card lets
53
+ clicks through.
54
+ - **None**: nothing is drawn. Ask from your own button with `requestPermission()`, and use `unsubscribe()` and the state
55
+ below.
56
+
57
+ They speak the page's language (`<html lang="ar">` → Arabic, right to left; else English, or the `language` option), take
58
+ the page's font, pick a light or dark look from the page's background, live in a shadow root (your CSS and theirs stay apart)
59
+ and are hidden when printing. `bell: false` and `banner: false` in `init` keep the button or the pop-up off a page even when
60
+ HQ chose it.
61
+
62
+ ## Subscription state
63
+
64
+ ```ts
65
+ const { permission, subscribed } = await Notifications.getState();
66
+ Notifications.onSubscriptionChange(({ permission, subscribed }) => render(permission, subscribed));
67
+ ```
68
+
69
+ `unsubscribe()` stops notifications here and is remembered: the browser keeps its permission, but later page loads do not
70
+ subscribe again until `requestPermission()` (which then subscribes without asking). Up to 1.0.0 the next `init` subscribed
71
+ again.
72
+
73
+ When the app has a welcome notification (HQ: Web push → Welcome notification), the service pushes it right after a browser
74
+ subscribes; nothing to do here.
40
75
 
41
76
  ## 3. Handle clicks
42
77
 
@@ -51,20 +86,28 @@ With no tab open, the browser opens the link directly.
51
86
 
52
87
  | Option | Default | |
53
88
  |---|---|---|
54
- | `appId` | (required) | The app's id: HQ → Push apps → the app |
89
+ | `appId` | (required) | The app's id: HQ → Notification management → the app |
55
90
  | `serverUrl` | `https://notifications.phnx-iq.com` | `''` uses your own origin (development behind a proxy) |
56
91
  | `serviceWorkerPath` | `/phoenix-notifications-sw.js` | |
57
92
  | `serviceWorkerScope` | `/` | |
58
- | `language` | the browser's | Picks the Arabic or English text of a notification |
93
+ | `language` | the page's (`<html lang>`), then the browser's | Picks the Arabic or English text of a notification, and the prompt's language |
59
94
  | `appVersion` | none | Stored with the device; in-app messages can target versions below one |
95
+ | `bell` | as HQ chose | `false` never draws the floating button (bell or logo) |
96
+ | `banner` | as HQ chose | `false` never shows the pop-up |
60
97
 
61
98
  ## Notes
62
99
 
63
100
  - iPhone and iPad: web push works only for a site added to the home screen (iOS 16.4+), as with any web push provider.
64
101
  - The device secret the server gives the browser is kept in IndexedDB; it only lets this browser manage its own subscription.
65
- - Your site must be in the app's sites (the app → Settings → Web push), or registration is refused.
102
+ - Your site must be in the app's sites (the app → Web push), or registration is refused.
103
+ - `example/bell.html` draws the button (bell or logo) and the pop-up in every state without a server (serve `sdk/web`, open
104
+ `/example/bell.html`).
105
+ - Up to 1.1.0 the SDK read a switch on the bell and one on the banner; from 1.2.0 it follows the one prompt HQ chose and can
106
+ draw the logo. An older SDK still works with a newer server: it draws a bell where HQ chose the logo.
66
107
 
67
108
  ## Publishing
68
109
 
69
- `npm run build` compiles `src` to `dist` (ES modules and type declarations). To release, raise `version` in
70
- `package.json` and run `npm publish` here (it builds first); the package is public on npmjs.com under `@phoenix-iq`.
110
+ `npm run build` compiles `src` to a fresh `dist` (ES modules and type declarations, no source maps); the package ships
111
+ `dist`, the service worker and this README, never `src`. `npm run lint` checks `src` and the service worker. To release,
112
+ raise `version` in `package.json` and run `npm publish` here (it builds first); the package is public on npmjs.com under
113
+ `@phoenix-iq`.
@@ -0,0 +1,35 @@
1
+ import type { SubscriptionState } from './index.js';
2
+ /**
3
+ * The pop-up of the banner prompt: a card that slides in at the top or the bottom of the page with a short message,
4
+ * "Subscribe" and "Later". "Subscribe" opens the browser's own question; "Later" keeps it away for some days. The SDK
5
+ * decides when it shows (a browser never asked, from a page view, after a delay); this only draws it and answers its buttons.
6
+ *
7
+ * Like the floating button it lives in a shadow root, takes the page's font and language and a light or dark look to suit the page.
8
+ * Texts HQ wrote are shown as they are; the empty ones are the SDK's own, in the page's language.
9
+ */
10
+ export type BannerPosition = 'top' | 'bottom';
11
+ /** The banner as HQ set it up (the web config's `banner`). */
12
+ export interface BannerSettings {
13
+ position: BannerPosition;
14
+ message?: string | null;
15
+ acceptText?: string | null;
16
+ cancelText?: string | null;
17
+ /** `#rrggbb` of the "Subscribe" button; null: the SDK's blue. */
18
+ color?: string | null;
19
+ delaySeconds: number;
20
+ pageViews: number;
21
+ repromptDays: number;
22
+ }
23
+ /** What the banner needs from the SDK. */
24
+ export interface BannerApi {
25
+ /** Asks the browser (synchronously, inside the click) and subscribes; resolves with the state afterwards. */
26
+ subscribe(): Promise<SubscriptionState>;
27
+ /** "Later": remembered, so the banner stays away for the days HQ set. */
28
+ dismiss(): Promise<void>;
29
+ onChange(handler: (state: SubscriptionState) => void): () => void;
30
+ }
31
+ export interface BannerHandle {
32
+ destroy(): void;
33
+ }
34
+ /** Slides the banner in, with the app's icon beside the message (else a bell in its colour); `destroy()` takes it away at once. */
35
+ export declare function mountBanner(api: BannerApi, settings: BannerSettings, language: string, iconUrl?: string | null): BannerHandle;
package/dist/banner.js ADDED
@@ -0,0 +1,178 @@
1
+ import { applyColors, applyTheme, colorOr, ICONS, svg, uiLanguage } from './theme.js';
2
+ const THANKS_MS = 2500;
3
+ const LEAVE_MS = 400;
4
+ const TEXTS = {
5
+ en: {
6
+ message: 'Get our news and updates as notifications in this browser. You can unsubscribe at any time.',
7
+ accept: 'Subscribe',
8
+ cancel: 'Later',
9
+ thanks: 'Thanks for subscribing!',
10
+ failed: "This browser couldn't be subscribed. Try again later.",
11
+ label: 'Notifications',
12
+ },
13
+ ar: {
14
+ message: 'اشترك ليصلك جديدنا وآخر أخبارنا إشعاراتٍ على هذا المتصفح، ويمكنك إلغاء الاشتراك متى شئت.',
15
+ accept: 'اشتراك',
16
+ cancel: 'لاحقًا',
17
+ thanks: 'شكرًا لاشتراكك!',
18
+ failed: 'تعذّر الاشتراك من هذا المتصفح. حاول لاحقًا.',
19
+ label: 'الإشعارات',
20
+ },
21
+ };
22
+ const STYLE = `
23
+ /* The host only spans the width and lets clicks through; the centring happens inside, where the page's CSS (a reset that
24
+ zeroes every element's margin, say) cannot reach. */
25
+ :host {
26
+ all: initial; position: fixed; z-index: 2147483001; left: 0; right: 0; pointer-events: none; font-family: inherit;
27
+ }
28
+ .wrap { max-width: 484px; margin: 0 auto; padding: 0 12px; }
29
+ :host([data-position="top"]) { top: calc(16px + env(safe-area-inset-top, 0px)); }
30
+ :host([data-position="bottom"]) { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
31
+ @media print { :host { display: none !important; } }
32
+ * { box-sizing: border-box; }
33
+ [hidden] { display: none !important; }
34
+ button { font: inherit; margin: 0; -webkit-tap-highlight-color: transparent; }
35
+ .card {
36
+ pointer-events: auto; display: flex; gap: 14px; align-items: flex-start; padding: 16px; border-radius: 16px; text-align: start;
37
+ background: var(--panel); color: var(--text); border: 1px solid var(--border);
38
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32), 0 2px 6px rgba(0, 0, 0, 0.18); font-size: 14px; line-height: 1.55;
39
+ opacity: 0; transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
40
+ }
41
+ :host([data-position="top"]) .card { transform: translateY(-24px); }
42
+ :host([data-position="bottom"]) .card { transform: translateY(24px); }
43
+ @media (max-width: 600px) {
44
+ /* on a phone the top of the page is the site's header and menu: the banner comes from the bottom */
45
+ :host([data-position="top"]) { top: auto; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
46
+ :host([data-position="top"]) .card { transform: translateY(24px); }
47
+ }
48
+ :host([data-shown]) .card { opacity: 1; transform: none; }
49
+ .icon {
50
+ flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; overflow: hidden;
51
+ background: var(--bell); color: var(--bell-text);
52
+ }
53
+ .icon img { width: 100%; height: 100%; object-fit: cover; }
54
+ .body { flex: 1; min-width: 0; }
55
+ .message { margin: 0; overflow-wrap: anywhere; }
56
+ .error { margin: 6px 0 0; color: var(--error); font-size: 13px; }
57
+ .actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 12px; }
58
+ .actions button { min-height: 38px; padding: 7px 16px; border-radius: 9px; border: 1px solid transparent; font-weight: 600; cursor: pointer; }
59
+ .actions .later { background: transparent; color: inherit; border-color: var(--border); }
60
+ .actions .later:hover { background: var(--hover); }
61
+ .actions .accept { background: var(--bell); color: var(--bell-text); }
62
+ .actions .accept:disabled { opacity: 0.6; cursor: progress; }
63
+ .actions button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .card { transition: opacity 0.2s ease; transform: none !important; }
66
+ }
67
+ `;
68
+ /** Slides the banner in, with the app's icon beside the message (else a bell in its colour); `destroy()` takes it away at once. */
69
+ export function mountBanner(api, settings, language, iconUrl) {
70
+ const lang = uiLanguage(language);
71
+ const t = TEXTS[lang];
72
+ const host = document.createElement('phoenix-notifications-banner');
73
+ host.dataset.position = settings.position === 'bottom' ? 'bottom' : 'top';
74
+ applyColors(host, colorOr(settings.color));
75
+ const shadow = host.attachShadow({ mode: 'open' });
76
+ // only constants go into this markup; HQ's texts and the icon's address are set as text and a property below
77
+ shadow.innerHTML = `<style>${STYLE}</style>
78
+ <div class="wrap"><div class="card" role="dialog" aria-label="${t.label}" aria-describedby="phx-banner-message" dir="${lang === 'ar' ? 'rtl' : 'ltr'}" lang="${lang}">
79
+ <div class="icon"></div>
80
+ <div class="body">
81
+ <p class="message" id="phx-banner-message" dir="auto"></p>
82
+ <p class="error" role="alert" hidden></p>
83
+ <div class="actions"><button type="button" class="later"></button><button type="button" class="accept"></button></div>
84
+ </div>
85
+ </div></div>`;
86
+ const $ = (selector) => shadow.querySelector(selector);
87
+ const icon = $('.icon');
88
+ const message = $('.message');
89
+ const errorLine = $('.error');
90
+ const actions = $('.actions');
91
+ const later = $('.later');
92
+ const accept = $('.accept');
93
+ message.textContent = settings.message?.trim() || t.message;
94
+ later.textContent = settings.cancelText?.trim() || t.cancel;
95
+ accept.textContent = settings.acceptText?.trim() || t.accept;
96
+ errorLine.textContent = t.failed;
97
+ if (iconUrl && /^https?:\/\//i.test(iconUrl)) {
98
+ const image = document.createElement('img');
99
+ image.alt = '';
100
+ image.src = iconUrl;
101
+ // a broken icon falls back to the bell
102
+ image.addEventListener('error', () => (icon.innerHTML = svg(ICONS.bell, 24)), { once: true });
103
+ icon.append(image);
104
+ }
105
+ else {
106
+ icon.innerHTML = svg(ICONS.bell, 24);
107
+ }
108
+ let busy = false;
109
+ let gone = false;
110
+ let leaveTimer = 0;
111
+ function leave() {
112
+ if (gone)
113
+ return;
114
+ gone = true;
115
+ stop();
116
+ delete host.dataset.shown;
117
+ leaveTimer = window.setTimeout(() => host.remove(), LEAVE_MS);
118
+ }
119
+ async function subscribe() {
120
+ if (busy)
121
+ return;
122
+ busy = true;
123
+ accept.disabled = true;
124
+ errorLine.hidden = true;
125
+ try {
126
+ // the browser's prompt opens inside this call, while the click still counts as the person's gesture
127
+ const state = await api.subscribe();
128
+ if (state.subscribed) {
129
+ message.textContent = t.thanks;
130
+ actions.hidden = true;
131
+ window.setTimeout(leave, THANKS_MS);
132
+ }
133
+ else if (state.permission === 'granted') {
134
+ errorLine.hidden = false;
135
+ }
136
+ else {
137
+ // blocked, or the browser's question was closed without an answer: as good as "Later"
138
+ if (state.permission === 'default')
139
+ await api.dismiss();
140
+ leave();
141
+ }
142
+ }
143
+ catch (error) {
144
+ console.warn('[notifications] subscribing failed', error);
145
+ errorLine.hidden = false;
146
+ }
147
+ finally {
148
+ busy = false;
149
+ accept.disabled = false;
150
+ }
151
+ }
152
+ accept.addEventListener('click', () => void subscribe());
153
+ later.addEventListener('click', () => {
154
+ void api.dismiss().catch(() => undefined);
155
+ leave();
156
+ });
157
+ shadow.addEventListener('keydown', ((event) => {
158
+ if (event.key === 'Escape' && !busy)
159
+ later.click();
160
+ }));
161
+ // subscribed elsewhere (the bell, the site's own button) or blocked: nothing left to ask
162
+ const stop = api.onChange((state) => {
163
+ if (!busy && (state.subscribed || state.permission === 'denied'))
164
+ leave();
165
+ });
166
+ document.body.appendChild(host);
167
+ applyTheme(host);
168
+ // two frames, so the card starts from its hidden place and slides
169
+ requestAnimationFrame(() => requestAnimationFrame(() => (host.dataset.shown = '')));
170
+ return {
171
+ destroy() {
172
+ gone = true;
173
+ stop();
174
+ window.clearTimeout(leaveTimer);
175
+ host.remove();
176
+ },
177
+ };
178
+ }
package/dist/bell.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { SubscriptionState } from './index.js';
2
+ /**
3
+ * The floating subscribe button of the bell and logo prompts: a round button in a bottom corner of the site that subscribes
4
+ * this browser on a click, shows that it is subscribed and lets the person unsubscribe, or explains how to allow notifications
5
+ * again once they blocked them. It shows a bell, or the app's logo with a small bell on it. HQ chooses the prompt per app;
6
+ * the SDK draws it from the web config.
7
+ *
8
+ * It lives in a shadow root, so the site's CSS cannot reach it and its CSS cannot leak out. It takes the page's font and
9
+ * language (Arabic right to left, else English), the colour HQ gave it, and a light or dark panel to suit the page.
10
+ */
11
+ export type BellPosition = 'bottom_right' | 'bottom_left';
12
+ /** What the button shows: a bell, or the app's logo (its icon). */
13
+ export type BellFace = 'bell' | 'logo';
14
+ /** The button as HQ set it up (the web config's `bell`). */
15
+ export interface BellSettings {
16
+ position: BellPosition;
17
+ /** `#rrggbb`; null: the SDK's blue. */
18
+ color?: string | null;
19
+ /** Only browsers that are not subscribed see it (after its thanks, a browser that subscribes no longer does). */
20
+ hideWhenSubscribed?: boolean;
21
+ /** `bell` unless the logo prompt is chosen. */
22
+ face?: BellFace;
23
+ /** The logo's address (the app's icon); without one, or when it fails to load, the button shows the bell. */
24
+ logoUrl?: string | null;
25
+ }
26
+ /** What the bell needs from the SDK. */
27
+ export interface BellApi {
28
+ state(): Promise<SubscriptionState>;
29
+ /** Asks the browser (synchronously, inside the click) and subscribes; resolves with the state afterwards. */
30
+ subscribe(): Promise<SubscriptionState>;
31
+ unsubscribe(): Promise<void>;
32
+ onChange(handler: (state: SubscriptionState) => void): () => void;
33
+ }
34
+ export interface BellHandle {
35
+ destroy(): void;
36
+ }
37
+ /** Draws the button into the page; `destroy()` takes it away again. */
38
+ export declare function mountBell(api: BellApi, settings: BellSettings, language: string): BellHandle;