@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/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # @phoenix-iq/notifications (web SDK)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ hands notification clicks to your app, and shows the subscription bell and banner when the app has them on. TypeScript, no
6
+ dependencies.
7
+
8
+ How the whole service works, the server API and the identity tokens: [docs/notifications.md](../../docs/notifications.md).
9
+
10
+ ```bash
11
+ npm install @phoenix-iq/notifications
12
+ ```
13
+
14
+ ## 1. Serve the service worker
15
+
16
+ Serve `phoenix-notifications-sw.js` (in this package) at your site's root, so it is reachable at
17
+ `https://your-site/phoenix-notifications-sw.js`. It must be on your own site: a service worker only controls its own origin.
18
+ Copy it from `node_modules/@phoenix-iq/notifications/phoenix-notifications-sw.js` in your build, or with Vite emit it from a
19
+ small plugin (HQ's dashboard `vite.config.ts` does).
20
+
21
+ ## 2. Initialise and log in
22
+
23
+ ```ts
24
+ import { Notifications } from '@phoenix-iq/notifications';
25
+
26
+ await Notifications.init({ appId: '<app id from HQ: Notification management>' });
27
+
28
+ // from a click ("Get notifications"): browsers only show the prompt on a user gesture
29
+ const permission = await Notifications.requestPermission(); // 'granted' | 'denied' | 'default' | 'unsupported'
30
+
31
+ // after your own sign-in, with the identity token your backend returned
32
+ await Notifications.login(identityToken);
33
+
34
+ // on sign-out: the browser keeps your broadcasts as a guest, but not that user's notifications
35
+ await Notifications.logout();
36
+ ```
37
+
38
+ `init` subscribes quietly when the person already allowed notifications (and did not unsubscribe here). `login` before the
39
+ permission is granted is kept and applied once it is. Nothing throws on an unsupported browser; check
40
+ `Notifications.isSupported()` and `Notifications.permission()` to decide what to show.
41
+
42
+ ## The subscription bell
43
+
44
+ Switch it on in HQ (the app → Web push → Subscription bell) and `init` draws it: a round bell in the bottom right or left
45
+ corner, in the colour you chose. The first click asks the browser; then the bell shows that this browser is subscribed and
46
+ offers "Unsubscribe", or, when notifications were blocked, says how to allow them again. It speaks the page's language
47
+ (`<html lang="ar">` → Arabic, right to left; else English, or the `language` option), takes the page's font, and picks a
48
+ light or dark panel from the page's background. It sits in a shadow root, so your CSS and its CSS stay apart, and it is
49
+ hidden when printing. A change in HQ shows on the next page load. With *Hide the bell once subscribed*, only browsers that
50
+ are not subscribed see it.
51
+
52
+ ## The subscription banner
53
+
54
+ OneSignal's slide prompt. Switch it on in HQ (the app → Web push → Subscription banner) and `init` shows it, when its time
55
+ comes, to a browser never asked: a card at the top or bottom with a message, "Subscribe" (the browser's own question) and
56
+ "Later". HQ sets the delay after the page opens, the page view it starts from and how many days "Later" keeps it away; the
57
+ SDK counts these in the browser (IndexedDB). Texts left empty are the SDK's own in the page's language. On a phone a top
58
+ banner comes from the bottom, clear of your header, and a bottom one sits above the bell. Like the bell it lives in a shadow
59
+ root; the strip around the card lets clicks through.
60
+
61
+ A site with its own "Get notifications" button passes `bell: false` and `banner: false`, and uses `requestPermission()`,
62
+ `unsubscribe()` and the state below.
63
+
64
+ ## Subscription state
65
+
66
+ ```ts
67
+ const { permission, subscribed } = await Notifications.getState();
68
+ Notifications.onSubscriptionChange(({ permission, subscribed }) => render(permission, subscribed));
69
+ ```
70
+
71
+ `unsubscribe()` stops notifications here and is remembered: the browser keeps its permission, but later page loads do not
72
+ subscribe again until `requestPermission()` (which then subscribes without asking). Up to 1.0.0 the next `init` subscribed
73
+ again.
74
+
75
+ When the app has a welcome notification (HQ: Web push → Welcome notification), the service pushes it right after a browser
76
+ subscribes; nothing to do here.
77
+
78
+ ## 3. Handle clicks
79
+
80
+ ```ts
81
+ Notifications.onClick(({ url, data }) => router.navigate(new URL(url ?? '/', location.origin).pathname));
82
+ ```
83
+
84
+ A click on a notification focuses an open tab of your site and calls your handler; without a handler the SDK opens the link.
85
+ With no tab open, the browser opens the link directly.
86
+
87
+ ## Options
88
+
89
+ | Option | Default | |
90
+ |---|---|---|
91
+ | `appId` | (required) | The app's id: HQ → Notification management → the app |
92
+ | `serverUrl` | `https://notifications.phnx-iq.com` | `''` uses your own origin (development behind a proxy) |
93
+ | `serviceWorkerPath` | `/phoenix-notifications-sw.js` | |
94
+ | `serviceWorkerScope` | `/` | |
95
+ | `language` | the page's (`<html lang>`), then the browser's | Picks the Arabic or English text of a notification, and the bell's language |
96
+ | `appVersion` | none | Stored with the device; in-app messages can target versions below one |
97
+ | `bell` | as HQ set it | `false` never draws the subscription bell (a site with its own button) |
98
+ | `banner` | as HQ set it | `false` never shows the subscription banner |
99
+
100
+ ## Notes
101
+
102
+ - iPhone and iPad: web push works only for a site added to the home screen (iOS 16.4+), as with any web push provider.
103
+ - The device secret the server gives the browser is kept in IndexedDB; it only lets this browser manage its own subscription.
104
+ - Your site must be in the app's sites (the app → Web push), or registration is refused.
105
+ - `example/bell.html` draws the bell and the banner in every state without a server (serve `sdk/web`, open `/example/bell.html`).
106
+
107
+ ## Publishing
108
+
109
+ `npm run build` compiles `src` to a fresh `dist` (ES modules and type declarations, no source maps); the package ships
110
+ `dist`, the service worker and this README, never `src`. `npm run lint` checks `src` and the service worker. To release,
111
+ raise `version` in `package.json` and run `npm publish` here (it builds first); the package is public on npmjs.com under
112
+ `@phoenix-iq`.
@@ -0,0 +1,41 @@
1
+ import type { SubscriptionState } from './index.js';
2
+ /**
3
+ * The subscription banner (OneSignal's slide prompt): a card that slides in at the top or the bottom of the page with a short
4
+ * message, "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 bell 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
+ export interface BannerPlacement {
35
+ /** The app's icon, shown beside the message (else a bell in the banner's colour). */
36
+ iconUrl?: string | null;
37
+ /** The bell is on the page: a bottom banner keeps clear of it on narrow screens. */
38
+ aboveBell?: boolean;
39
+ }
40
+ /** Slides the banner in; `destroy()` takes it away at once. */
41
+ export declare function mountBanner(api: BannerApi, settings: BannerSettings, language: string, placement?: BannerPlacement): BannerHandle;
package/dist/banner.js ADDED
@@ -0,0 +1,183 @@
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, as OneSignal's does */
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
+ /* and where it would cover the bell in its corner, it sits above it */
48
+ :host([data-above-bell]) { bottom: calc(84px + env(safe-area-inset-bottom, 0px)); }
49
+ }
50
+ :host([data-shown]) .card { opacity: 1; transform: none; }
51
+ .icon {
52
+ flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; overflow: hidden;
53
+ background: var(--bell); color: var(--bell-text);
54
+ }
55
+ .icon img { width: 100%; height: 100%; object-fit: cover; }
56
+ .body { flex: 1; min-width: 0; }
57
+ .message { margin: 0; overflow-wrap: anywhere; }
58
+ .error { margin: 6px 0 0; color: var(--error); font-size: 13px; }
59
+ .actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 12px; }
60
+ .actions button { min-height: 38px; padding: 7px 16px; border-radius: 9px; border: 1px solid transparent; font-weight: 600; cursor: pointer; }
61
+ .actions .later { background: transparent; color: inherit; border-color: var(--border); }
62
+ .actions .later:hover { background: var(--hover); }
63
+ .actions .accept { background: var(--bell); color: var(--bell-text); }
64
+ .actions .accept:disabled { opacity: 0.6; cursor: progress; }
65
+ .actions button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .card { transition: opacity 0.2s ease; transform: none !important; }
68
+ }
69
+ `;
70
+ /** Slides the banner in; `destroy()` takes it away at once. */
71
+ export function mountBanner(api, settings, language, placement = {}) {
72
+ const lang = uiLanguage(language);
73
+ const t = TEXTS[lang];
74
+ const { iconUrl, aboveBell } = placement;
75
+ const host = document.createElement('phoenix-notifications-banner');
76
+ host.dataset.position = settings.position === 'bottom' ? 'bottom' : 'top';
77
+ if (aboveBell)
78
+ host.dataset.aboveBell = '';
79
+ applyColors(host, colorOr(settings.color));
80
+ const shadow = host.attachShadow({ mode: 'open' });
81
+ // only constants go into this markup; HQ's texts and the icon's address are set as text and a property below
82
+ shadow.innerHTML = `<style>${STYLE}</style>
83
+ <div class="wrap"><div class="card" role="dialog" aria-label="${t.label}" aria-describedby="phx-banner-message" dir="${lang === 'ar' ? 'rtl' : 'ltr'}" lang="${lang}">
84
+ <div class="icon"></div>
85
+ <div class="body">
86
+ <p class="message" id="phx-banner-message" dir="auto"></p>
87
+ <p class="error" role="alert" hidden></p>
88
+ <div class="actions"><button type="button" class="later"></button><button type="button" class="accept"></button></div>
89
+ </div>
90
+ </div></div>`;
91
+ const $ = (selector) => shadow.querySelector(selector);
92
+ const icon = $('.icon');
93
+ const message = $('.message');
94
+ const errorLine = $('.error');
95
+ const actions = $('.actions');
96
+ const later = $('.later');
97
+ const accept = $('.accept');
98
+ message.textContent = settings.message?.trim() || t.message;
99
+ later.textContent = settings.cancelText?.trim() || t.cancel;
100
+ accept.textContent = settings.acceptText?.trim() || t.accept;
101
+ errorLine.textContent = t.failed;
102
+ if (iconUrl && /^https?:\/\//i.test(iconUrl)) {
103
+ const image = document.createElement('img');
104
+ image.alt = '';
105
+ image.src = iconUrl;
106
+ // a broken icon falls back to the bell
107
+ image.addEventListener('error', () => (icon.innerHTML = svg(ICONS.bell, 24)), { once: true });
108
+ icon.append(image);
109
+ }
110
+ else {
111
+ icon.innerHTML = svg(ICONS.bell, 24);
112
+ }
113
+ let busy = false;
114
+ let gone = false;
115
+ let leaveTimer = 0;
116
+ function leave() {
117
+ if (gone)
118
+ return;
119
+ gone = true;
120
+ stop();
121
+ delete host.dataset.shown;
122
+ leaveTimer = window.setTimeout(() => host.remove(), LEAVE_MS);
123
+ }
124
+ async function subscribe() {
125
+ if (busy)
126
+ return;
127
+ busy = true;
128
+ accept.disabled = true;
129
+ errorLine.hidden = true;
130
+ try {
131
+ // the browser's prompt opens inside this call, while the click still counts as the person's gesture
132
+ const state = await api.subscribe();
133
+ if (state.subscribed) {
134
+ message.textContent = t.thanks;
135
+ actions.hidden = true;
136
+ window.setTimeout(leave, THANKS_MS);
137
+ }
138
+ else if (state.permission === 'granted') {
139
+ errorLine.hidden = false;
140
+ }
141
+ else {
142
+ // blocked, or the browser's question was closed without an answer: as good as "Later"
143
+ if (state.permission === 'default')
144
+ await api.dismiss();
145
+ leave();
146
+ }
147
+ }
148
+ catch (error) {
149
+ console.warn('[notifications] subscribing failed', error);
150
+ errorLine.hidden = false;
151
+ }
152
+ finally {
153
+ busy = false;
154
+ accept.disabled = false;
155
+ }
156
+ }
157
+ accept.addEventListener('click', () => void subscribe());
158
+ later.addEventListener('click', () => {
159
+ void api.dismiss().catch(() => undefined);
160
+ leave();
161
+ });
162
+ shadow.addEventListener('keydown', ((event) => {
163
+ if (event.key === 'Escape' && !busy)
164
+ later.click();
165
+ }));
166
+ // subscribed elsewhere (the bell, the site's own button) or blocked: nothing left to ask
167
+ const stop = api.onChange((state) => {
168
+ if (!busy && (state.subscribed || state.permission === 'denied'))
169
+ leave();
170
+ });
171
+ document.body.appendChild(host);
172
+ applyTheme(host);
173
+ // two frames, so the card starts from its hidden place and slides
174
+ requestAnimationFrame(() => requestAnimationFrame(() => (host.dataset.shown = '')));
175
+ return {
176
+ destroy() {
177
+ gone = true;
178
+ stop();
179
+ window.clearTimeout(leaveTimer);
180
+ host.remove();
181
+ },
182
+ };
183
+ }
package/dist/bell.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { SubscriptionState } from './index.js';
2
+ /**
3
+ * The subscription bell (OneSignal's "subscription bell"): a round button in a bottom corner of the site that subscribes this
4
+ * 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. HQ switches it on per app; the SDK draws it from the web config.
6
+ *
7
+ * 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
8
+ * language (Arabic right to left, else English), the colour HQ gave it, and a light or dark panel to suit the page.
9
+ */
10
+ export type BellPosition = 'bottom_right' | 'bottom_left';
11
+ /** The bell as HQ set it up (the web config's `bell`). */
12
+ export interface BellSettings {
13
+ position: BellPosition;
14
+ /** `#rrggbb`; null: the SDK's blue. */
15
+ color?: string | null;
16
+ /** Only browsers that are not subscribed see it (after its thanks, a browser that subscribes no longer does). */
17
+ hideWhenSubscribed?: boolean;
18
+ }
19
+ /** What the bell needs from the SDK. */
20
+ export interface BellApi {
21
+ state(): Promise<SubscriptionState>;
22
+ /** Asks the browser (synchronously, inside the click) and subscribes; resolves with the state afterwards. */
23
+ subscribe(): Promise<SubscriptionState>;
24
+ unsubscribe(): Promise<void>;
25
+ onChange(handler: (state: SubscriptionState) => void): () => void;
26
+ }
27
+ export interface BellHandle {
28
+ destroy(): void;
29
+ }
30
+ /** Draws the bell into the page; `destroy()` takes it away again. */
31
+ export declare function mountBell(api: BellApi, settings: BellSettings, language: string): BellHandle;