@closerclick/closer-click-notifications 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @closerclick/closer-click-notifications
2
+
3
+ Notificaciones compartidas del ecosistema **Closer Click**. Unifica lo que cada
4
+ app reimplementaba por separado (messenger, eco, chess, pronóstico, gymbro):
5
+
6
+ 1. **`createNotifications(config)`** — controlador *data-agnostic* y sin framework:
7
+ permiso del navegador + **preferencias por categoría con scope por app** +
8
+ disparo (`notify`) respetando permiso y prefs. Web Push opcional.
9
+ 2. **`<closer-click-notifications>`** — Web Component (panel de ajustes): activar
10
+ permiso, togglear cada categoría, sonido y push. Shadow DOM, bilingüe es/en,
11
+ temable por CSS vars (`--ccn-*`). **Sin JS de terceros ni cookies.**
12
+ 3. **`createVaultPushProvider(...)`** — Web Push (app cerrada) ligado al transporte
13
+ del ecosistema (`closer-click-proxy-client`) y firmado por el vault de identidad.
14
+
15
+ El ecosistema es mixto Vue/vanilla → la UI va como **custom element** (mismo patrón
16
+ que `closer-click-support` / `closer-click-profile` / `closer-click-nav`).
17
+
18
+ ## Instalar
19
+
20
+ ```bash
21
+ npm i @closerclick/closer-click-notifications
22
+ ```
23
+
24
+ ## Uso (controlador + disparo)
25
+
26
+ ```js
27
+ import { createNotifications } from '@closerclick/closer-click-notifications'
28
+
29
+ const notifications = createNotifications({
30
+ storageKey: 'eco', // scope: namespacea las prefs por app
31
+ categories: [
32
+ { key: 'replies', label: { es: 'Respuestas', en: 'Replies' }, hint: { es: 'Cuando responden a tu eco.', en: 'When someone replies.' } },
33
+ { key: 'reposts', label: { es: 'Reposts', en: 'Reposts' }, default: true },
34
+ ],
35
+ sound: true, // incluye toggle de sonido (default true)
36
+ })
37
+
38
+ // Disparar (no hace nada si falta permiso o la categoría está apagada):
39
+ await notifications.notify('replies', {
40
+ title: 'Nueva respuesta',
41
+ body: '@ada respondió tu eco',
42
+ icon: '/icon-192.png',
43
+ tag: 'eco-reply',
44
+ onClick: () => location.assign('/#replies'),
45
+ })
46
+ ```
47
+
48
+ `notify` usa el Service Worker (`registration.showNotification`) cuando hay uno
49
+ activo (mejor en móvil/PWA) y si no `new Notification`. El **sonido** lo controla
50
+ la preferencia `sound` (vía el flag `silent` del SO).
51
+
52
+ ## Panel de ajustes (Web Component)
53
+
54
+ ```js
55
+ import '@closerclick/closer-click-notifications' // registra el custom element
56
+ ```
57
+
58
+ ```html
59
+ <closer-click-notifications modal lang="es"></closer-click-notifications>
60
+ ```
61
+
62
+ ```js
63
+ // tras montar, asigná el controlador (propiedad JS, no atributo):
64
+ document.querySelector('closer-click-notifications').controller = notifications
65
+ ```
66
+
67
+ En Vue 3, configurá `isCustomElement: (tag) => tag.startsWith('closer-click-')` en
68
+ `compilerOptions` del plugin de Vue, y enlazá el controlador con un `ref`:
69
+
70
+ ```html
71
+ <closer-click-notifications :ref="el => el && (el.controller = notifications)" modal />
72
+ ```
73
+
74
+ ### Atributos
75
+
76
+ | Atributo | Descripción |
77
+ |-----------|-------------|
78
+ | `modal` | envuelve en backdrop (click fuera = cerrar, emite `cc-notif-close`) |
79
+ | `heading` | título del panel (override) |
80
+ | `lang` | `es` \| `en` \| `auto` (default `auto`) |
81
+
82
+ ### Propiedad JS
83
+
84
+ - `.controller` — el objeto devuelto por `createNotifications(...)`.
85
+
86
+ ### Eventos (bubbles, composed)
87
+
88
+ - `cc-notif-change` — `detail { key, value }` (toggle de categoría/sonido).
89
+ - `cc-notif-permission` — `detail { permission }` (tras pedir permiso).
90
+ - `cc-notif-push` — `detail { enabled }` (toggle de push).
91
+ - `cc-notif-close` — cerrar (X o backdrop en modo `modal`).
92
+
93
+ ## Web Push (app cerrada)
94
+
95
+ ```js
96
+ import { createNotifications, createVaultPushProvider } from '@closerclick/closer-click-notifications'
97
+ import { getWebSocketProxyClient } from '@closerclick/closer-click-proxy-client'
98
+ import { Identity } from '@closerclick/closer-click-identity'
99
+
100
+ const notifications = createNotifications({
101
+ storageKey: 'messenger',
102
+ categories: [ /* … */ ],
103
+ push: createVaultPushProvider({
104
+ proxyClient: () => getWebSocketProxyClient(),
105
+ identity: () => Identity.connect(), // instancia o getter (async ok)
106
+ storageKey: 'messenger',
107
+ }),
108
+ })
109
+
110
+ // Re-registra la subscription tras cada identify (los endpoints rotan):
111
+ await notifications.push.ensureSubscribed()
112
+ ```
113
+
114
+ El "timbre" **no transporta contenido**: el Service Worker despierta y la app
115
+ reconecta e `identify()` drena la cola cifrada del proxy. La subscription se liga
116
+ a la **misma pubkey del vault** usada en `identify`, con un sobre firmado por el vault.
117
+
118
+ ## Tema (CSS custom properties)
119
+
120
+ ```css
121
+ closer-click-notifications {
122
+ --ccn-bg: #12161d;
123
+ --ccn-accent: #2dd4bf;
124
+ --ccn-radius: 14px;
125
+ }
126
+ ```
127
+
128
+ `--ccn-bg`, `--ccn-bg-2..4`, `--ccn-border`, `--ccn-text`, `--ccn-muted`,
129
+ `--ccn-accent`, `--ccn-accent-text`, `--ccn-danger`, `--ccn-gold`, `--ccn-radius`,
130
+ `--ccn-font`.
131
+
132
+ ## Licencia
133
+
134
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@closerclick/closer-click-notifications",
3
+ "version": "0.1.0",
4
+ "description": "Notificaciones compartidas del ecosistema Closer Click: controlador data-agnostic (permiso del navegador + preferencias por categoría con scope por app + disparo) y el Web Component <closer-click-notifications> (panel de ajustes). Incluye helper de Web Push ligado al vault + proxy. Autohosteado, Shadow DOM, sin JS de terceros ni cookies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "types": "src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node test/smoke.mjs"
22
+ },
23
+ "keywords": [
24
+ "closer-click",
25
+ "web-component",
26
+ "custom-element",
27
+ "notifications",
28
+ "web-push",
29
+ "pwa",
30
+ "cross-app"
31
+ ],
32
+ "author": "seyacat",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/closerclick/closer-click-notifications.git"
37
+ }
38
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ /** Texto bilingüe o plano. */
2
+ export type LocalizedText = string | { es?: string; en?: string }
3
+
4
+ export interface NotificationCategory {
5
+ /** Clave estable de la categoría (se persiste en prefs). */
6
+ key: string
7
+ /** Etiqueta visible (bilingüe o plana). */
8
+ label: LocalizedText
9
+ /** Ayuda opcional bajo la etiqueta. */
10
+ hint?: LocalizedText
11
+ /** Valor por defecto (default: true = notifica). */
12
+ default?: boolean
13
+ }
14
+
15
+ export interface NotifyOptions extends NotificationOptions {
16
+ title?: string
17
+ /** Handler de click (solo vía `new Notification`, no SW). */
18
+ onClick?: (e: Event) => void
19
+ }
20
+
21
+ /** Provider de Web Push que la app inyecta (ver createVaultPushProvider). */
22
+ export interface PushProvider {
23
+ supported?: () => boolean
24
+ isEnabled?: () => boolean
25
+ busy?: (() => boolean) | boolean
26
+ error?: (() => string) | string
27
+ enable?: () => Promise<boolean>
28
+ disable?: () => Promise<boolean>
29
+ ensureSubscribed?: () => Promise<void>
30
+ }
31
+
32
+ export interface NotificationsConfig {
33
+ /** Namespace por app (scope de las preferencias). */
34
+ storageKey: string
35
+ categories: NotificationCategory[]
36
+ /** Incluir preferencia/toggle de sonido (default: true). */
37
+ sound?: boolean
38
+ /** Web Push opcional. */
39
+ push?: PushProvider
40
+ }
41
+
42
+ export interface NotificationsController {
43
+ readonly storageKey: string
44
+ readonly categories: NotificationCategory[]
45
+ readonly hasSound: boolean
46
+ readonly prefs: Record<string, boolean>
47
+ readonly supported: boolean
48
+ readonly soundEnabled: boolean
49
+ permission(): NotificationPermission | 'unsupported'
50
+ requestPermission(): Promise<NotificationPermission | 'unsupported'>
51
+ shouldNotify(key?: string): boolean
52
+ notify(key: string, opts?: NotifyOptions): Promise<Notification | null>
53
+ get(key: string): boolean
54
+ set(key: string, val: boolean): void
55
+ subscribe(fn: () => void): () => void
56
+ push: null | {
57
+ readonly supported: boolean
58
+ readonly enabled: boolean
59
+ readonly busy: boolean
60
+ readonly error: string
61
+ enable(): Promise<boolean>
62
+ disable(): Promise<boolean>
63
+ ensureSubscribed(): Promise<void>
64
+ }
65
+ }
66
+
67
+ /** Crea el controlador de notificaciones (data-agnostic, sin framework). */
68
+ export function createNotifications(config: NotificationsConfig): NotificationsController
69
+
70
+ export interface VaultPushProviderConfig {
71
+ /** Cliente proxy del ecosistema (o getter) con enablePush/disablePush. */
72
+ proxyClient: any | (() => any)
73
+ /** Instancia Identity del vault (o getter, puede ser async). */
74
+ identity: any | (() => any | Promise<any>)
75
+ /** Namespace del flag local (por app). */
76
+ storageKey?: string
77
+ }
78
+
79
+ /** Provider de Web Push ligado al vault + proxy del ecosistema. */
80
+ export function createVaultPushProvider(cfg: VaultPushProviderConfig): Required<Pick<PushProvider, 'supported' | 'isEnabled' | 'busy' | 'error' | 'enable' | 'disable' | 'ensureSubscribed'>>
81
+
82
+ export class CloserClickNotifications extends HTMLElement {
83
+ controller: NotificationsController | null
84
+ }
85
+
86
+ export default CloserClickNotifications
87
+
88
+ declare global {
89
+ interface HTMLElementTagNameMap {
90
+ 'closer-click-notifications': CloserClickNotifications
91
+ }
92
+ }
package/src/index.js ADDED
@@ -0,0 +1,531 @@
1
+ /**
2
+ * @closerclick/closer-click-notifications
3
+ *
4
+ * Notificaciones compartidas del ecosistema Closer Click. Unifica lo que cada app
5
+ * reimplementaba por separado (messenger, eco, chess, pronóstico, gymbro):
6
+ *
7
+ * 1) createNotifications(config) — controlador data-agnostic, framework-free:
8
+ * permiso del navegador + preferencias por categoría (con SCOPE POR APP vía
9
+ * storageKey) + disparo (notify) respetando permiso/prefs. Opcionalmente
10
+ * cablea Web Push (app cerrada) a través de un provider.
11
+ *
12
+ * 2) <closer-click-notifications> — Web Component (panel de ajustes): activa el
13
+ * permiso, togglea cada categoría, el sonido y el push. Shadow DOM,
14
+ * bilingüe es/en, temable por CSS vars (--ccn-*). Sin JS de terceros ni cookies.
15
+ *
16
+ * 3) createVaultPushProvider({ proxyClient, identity, storageKey }) — helper que
17
+ * liga el Web Push al transporte del ecosistema (proxy-client) firmado por
18
+ * el vault de identidad. Misma lógica que tenían messenger y pronóstico.
19
+ *
20
+ * El ecosistema es mixto Vue/vanilla → la UI reutilizable va como custom element
21
+ * (mismo patrón que closer-click-support / closer-click-profile / closer-click-nav).
22
+ */
23
+
24
+ /* ============================================================================
25
+ * 1) Controlador ── createNotifications(config)
26
+ * ==========================================================================*/
27
+
28
+ const LS_PREFIX = 'cc-notif:'
29
+
30
+ const isBrowser = typeof window !== 'undefined'
31
+ function _supported () {
32
+ return isBrowser && typeof Notification !== 'undefined' &&
33
+ typeof navigator !== 'undefined' && 'serviceWorker' in navigator
34
+ }
35
+
36
+ /**
37
+ * @param {object} config
38
+ * @param {string} config.storageKey namespace por app (scope). Ej: 'eco', 'messenger'.
39
+ * @param {Array} config.categories [{ key, label, hint?, default? }] (label/hint:
40
+ * string o { es, en }).
41
+ * @param {boolean} [config.sound=true] incluir preferencia/toggle de sonido.
42
+ * @param {object} [config.push] provider de Web Push (ver createVaultPushProvider).
43
+ */
44
+ export function createNotifications (config = {}) {
45
+ const storageKey = config.storageKey || 'default'
46
+ const categories = Array.isArray(config.categories) ? config.categories : []
47
+ const hasSound = config.sound !== false
48
+ const push = config.push || null
49
+
50
+ const LS_KEY = LS_PREFIX + storageKey
51
+
52
+ // ----- preferencias (localStorage, por dispositivo) -----
53
+ const defaults = {}
54
+ for (const c of categories) defaults[c.key] = c.default !== false
55
+ if (hasSound) defaults.sound = true
56
+
57
+ function _load () {
58
+ try { return { ...defaults, ...JSON.parse(localStorage.getItem(LS_KEY) || '{}') } }
59
+ catch { return { ...defaults } }
60
+ }
61
+ const prefs = _load()
62
+ function _save () { try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)) } catch (_) {} }
63
+
64
+ // ----- suscriptores (re-render del Web Component, etc.) -----
65
+ const subs = new Set()
66
+ function _emit () { for (const fn of subs) { try { fn() } catch (_) {} } }
67
+
68
+ // ----- permiso -----
69
+ function permission () {
70
+ if (typeof Notification === 'undefined') return 'unsupported'
71
+ return Notification.permission
72
+ }
73
+ async function requestPermission () {
74
+ if (typeof Notification === 'undefined') return 'unsupported'
75
+ let p = Notification.permission
76
+ if (p === 'default') { try { p = await Notification.requestPermission() } catch (_) {} }
77
+ _emit()
78
+ return p
79
+ }
80
+
81
+ // ----- disparo -----
82
+ function shouldNotify (key) {
83
+ if (key == null) return true
84
+ return prefs[key] !== false
85
+ }
86
+
87
+ /**
88
+ * Lanza una notificación si: hay soporte, permiso 'granted' y la categoría está
89
+ * activa. Usa el Service Worker (registration.showNotification) cuando está
90
+ * disponible (mejor en móvil/PWA), si no `new Notification`. El sonido lo
91
+ * controla la preferencia `sound` (silent del SO).
92
+ * @returns {Promise<Notification|null>}
93
+ */
94
+ async function notify (key, opts = {}) {
95
+ if (!_supported() || permission() !== 'granted') return null
96
+ if (!shouldNotify(key)) return null
97
+ const { title = '', onClick, silent, ...rest } = opts
98
+ const beSilent = silent != null ? !!silent : !(hasSound && prefs.sound !== false)
99
+ const noteOpts = { silent: beSilent, ...rest }
100
+ try {
101
+ if (navigator.serviceWorker && navigator.serviceWorker.controller) {
102
+ const reg = await navigator.serviceWorker.ready
103
+ await reg.showNotification(title, noteOpts)
104
+ return null
105
+ }
106
+ } catch (_) { /* cae a new Notification */ }
107
+ try {
108
+ const n = new Notification(title, noteOpts)
109
+ if (typeof onClick === 'function') {
110
+ n.onclick = (e) => { try { window.focus() } catch (_) {} ; onClick(e) }
111
+ }
112
+ return n
113
+ } catch (_) { return null }
114
+ }
115
+
116
+ // ----- API de prefs -----
117
+ function get (key) { return prefs[key] }
118
+ function set (key, val) {
119
+ if (!(key in defaults)) return
120
+ prefs[key] = !!val
121
+ _save()
122
+ _emit()
123
+ }
124
+
125
+ const ctrl = {
126
+ storageKey,
127
+ categories,
128
+ hasSound,
129
+ prefs,
130
+ get supported () { return _supported() },
131
+ permission,
132
+ requestPermission,
133
+ shouldNotify,
134
+ notify,
135
+ get,
136
+ set,
137
+ get soundEnabled () { return hasSound && prefs.sound !== false },
138
+ subscribe (fn) { subs.add(fn); return () => subs.delete(fn) },
139
+ _emit,
140
+ push: null,
141
+ }
142
+
143
+ // ----- Web Push opcional -----
144
+ if (push) {
145
+ ctrl.push = {
146
+ get supported () { return typeof push.supported === 'function' ? !!push.supported() : _supported() },
147
+ get enabled () { return typeof push.isEnabled === 'function' ? !!push.isEnabled() : false },
148
+ get busy () { return typeof push.busy === 'function' ? !!push.busy() : !!push.busy },
149
+ get error () { return typeof push.error === 'function' ? (push.error() || '') : (push.error || '') },
150
+ async enable () { const r = push.enable ? await push.enable() : false; _emit(); return r },
151
+ async disable () { const r = push.disable ? await push.disable() : false; _emit(); return r },
152
+ async ensureSubscribed () { if (push.ensureSubscribed) await push.ensureSubscribed(); _emit() },
153
+ }
154
+ }
155
+
156
+ return ctrl
157
+ }
158
+
159
+ /* ============================================================================
160
+ * 2) Helper de Web Push ligado al vault + proxy (transporte del ecosistema)
161
+ * ==========================================================================*/
162
+
163
+ /**
164
+ * Provider de Web Push para createNotifications({ push }). Replica la lógica que
165
+ * tenían messenger/pronóstico: opt-in del usuario a recibir un "timbre" con la
166
+ * app cerrada; la subscription se liga a la MISMA pubkey del vault usada en
167
+ * identify, con un sobre firmado por el vault. El SW de la PWA muestra el aviso.
168
+ *
169
+ * @param {object} cfg
170
+ * @param {object|function} cfg.proxyClient cliente proxy (o getter) con enablePush/disablePush.
171
+ * @param {object|function} cfg.identity instancia Identity (o getter async) del vault.
172
+ * @param {string} cfg.storageKey namespace del flag local (por app).
173
+ */
174
+ export function createVaultPushProvider ({ proxyClient, identity, storageKey = 'default' } = {}) {
175
+ const LS_KEY = 'cc-push:' + storageKey
176
+ const getProxy = typeof proxyClient === 'function' ? proxyClient : () => proxyClient
177
+ const getId = typeof identity === 'function' ? identity : () => identity
178
+
179
+ let _enabled = isBrowser && localStorage.getItem(LS_KEY) === '1'
180
+ let _busy = false
181
+ let _error = ''
182
+
183
+ function supported () {
184
+ return isBrowser && typeof Notification !== 'undefined' &&
185
+ 'serviceWorker' in navigator && 'PushManager' in window
186
+ }
187
+
188
+ async function _vault () {
189
+ const id = await getId()
190
+ const publicKey = id && id.me && id.me.publickey
191
+ if (!id || !publicKey) throw new Error('Vault de identidad no disponible')
192
+ return { publicKey, sign: (d) => id.signData(d) }
193
+ }
194
+
195
+ async function enable () {
196
+ _error = ''
197
+ if (!supported()) { _error = 'Tu navegador no soporta notificaciones push'; return false }
198
+ _busy = true
199
+ try {
200
+ const perm = await Notification.requestPermission()
201
+ if (perm !== 'granted') { _error = 'Permiso de notificaciones denegado'; return false }
202
+ const { publicKey, sign } = await _vault()
203
+ await getProxy().enablePush({ publicKey, sign })
204
+ _enabled = true
205
+ try { localStorage.setItem(LS_KEY, '1') } catch (_) {}
206
+ return true
207
+ } catch (e) { _error = (e && e.message) || String(e); return false }
208
+ finally { _busy = false }
209
+ }
210
+
211
+ async function disable () {
212
+ _error = ''
213
+ _busy = true
214
+ try {
215
+ let publicKey, sign
216
+ try { ({ publicKey, sign } = await _vault()) } catch (_) { /* igual cancelamos local */ }
217
+ await getProxy().disablePush({ publicKey, sign })
218
+ _enabled = false
219
+ try { localStorage.removeItem(LS_KEY) } catch (_) {}
220
+ return true
221
+ } catch (e) { _error = (e && e.message) || String(e); return false }
222
+ finally { _busy = false }
223
+ }
224
+
225
+ // Re-registra la subscription tras cada identify (los endpoints rotan).
226
+ // Silencioso: si el usuario no optó o el permiso no está, no hace nada.
227
+ async function ensureSubscribed () {
228
+ if (!_enabled || !supported() || Notification.permission !== 'granted') return
229
+ try {
230
+ const { publicKey, sign } = await _vault()
231
+ await getProxy().enablePush({ publicKey, sign })
232
+ } catch (e) { console.warn('[cc-notif] ensureSubscribed falló:', (e && e.message) || e) }
233
+ }
234
+
235
+ return {
236
+ supported,
237
+ isEnabled: () => _enabled,
238
+ busy: () => _busy,
239
+ error: () => _error,
240
+ enable,
241
+ disable,
242
+ ensureSubscribed,
243
+ }
244
+ }
245
+
246
+ /* ============================================================================
247
+ * 3) Web Component ── <closer-click-notifications> (panel de ajustes)
248
+ * ==========================================================================*/
249
+
250
+ const I18N = {
251
+ es: {
252
+ heading: 'Notificaciones',
253
+ intro: 'Elige qué quieres que te avise esta app.',
254
+ enable: 'Activar notificaciones',
255
+ enableHint: 'Permite que el navegador te muestre avisos.',
256
+ denied: 'Permiso bloqueado en el navegador. Actívalo en los ajustes del sitio.',
257
+ unsupported: 'Tu navegador no soporta notificaciones.',
258
+ sound: 'Sonido',
259
+ soundHint: 'Suena al notificar.',
260
+ push: 'Aviso con la app cerrada',
261
+ pushHint: 'Recibe un timbre aunque no tengas la pestaña abierta.',
262
+ pushDenied: '(Permiso bloqueado en el navegador.)',
263
+ },
264
+ en: {
265
+ heading: 'Notifications',
266
+ intro: 'Choose what this app may alert you about.',
267
+ enable: 'Enable notifications',
268
+ enableHint: 'Let the browser show you alerts.',
269
+ denied: 'Permission blocked in the browser. Enable it in the site settings.',
270
+ unsupported: 'Your browser does not support notifications.',
271
+ sound: 'Sound',
272
+ soundHint: 'Play a sound when notifying.',
273
+ push: 'Alerts when the app is closed',
274
+ pushHint: 'Get pinged even without the tab open.',
275
+ pushDenied: '(Permission blocked in the browser.)',
276
+ },
277
+ }
278
+
279
+ const STYLE = `
280
+ :host {
281
+ --_bg: var(--ccn-bg, #12161d);
282
+ --_bg-2: var(--ccn-bg-2, #171c24);
283
+ --_bg-3: var(--ccn-bg-3, #1f2630);
284
+ --_bg-4: var(--ccn-bg-4, #2a3550);
285
+ --_border: var(--ccn-border, rgba(255,255,255,0.16));
286
+ --_text: var(--ccn-text, #e9eef3);
287
+ --_muted: var(--ccn-muted, #94a1b0);
288
+ --_accent: var(--ccn-accent, #2dd4bf);
289
+ --_accent-text: var(--ccn-accent-text, #042038);
290
+ --_danger: var(--ccn-danger, #ef6b6b);
291
+ --_radius: var(--ccn-radius, 14px);
292
+ --_font: var(--ccn-font, system-ui, sans-serif);
293
+ display: block; color: var(--_text); font-family: var(--_font);
294
+ }
295
+ :host([modal]) .wrap {
296
+ position: fixed; inset: 0; z-index: 2147483000;
297
+ background: rgba(0,0,0,0.55);
298
+ display: flex; align-items: center; justify-content: center; padding: 16px;
299
+ }
300
+ .card {
301
+ background: var(--_bg); border: 1px solid var(--_border);
302
+ border-radius: var(--_radius); width: 100%; max-width: 460px;
303
+ display: flex; flex-direction: column; overflow: hidden;
304
+ }
305
+ :host(:not([modal])) .card { border: 0; border-radius: 0; background: transparent; }
306
+ .head {
307
+ display: flex; align-items: center; justify-content: space-between;
308
+ padding: 16px 20px; border-bottom: 1px solid var(--_border);
309
+ }
310
+ :host(:not([modal])) .head { padding: 0 0 12px; }
311
+ .title { font-weight: 700; font-size: 17px; margin: 0; }
312
+ .x { background: transparent; border: 0; font-size: 22px; cursor: pointer; color: var(--_muted); width: 32px; height: 32px; border-radius: 8px; line-height: 1; }
313
+ .x:hover { background: var(--_bg-3); color: var(--_text); }
314
+ .body { padding: 16px 20px; display: flex; flex-direction: column; }
315
+ :host(:not([modal])) .body { padding: 0; }
316
+ .intro { margin: 0 0 12px; font-size: 13px; color: var(--_muted); }
317
+
318
+ .cta {
319
+ display: flex; flex-direction: column; gap: 4px;
320
+ background: var(--_bg-2); border: 1px solid var(--_border);
321
+ border-radius: 10px; padding: 14px; margin-bottom: 12px;
322
+ }
323
+ .cta .btn {
324
+ align-self: flex-start; margin-top: 8px; font: inherit; font-weight: 700;
325
+ background: var(--_accent); color: var(--_accent-text); border: 0;
326
+ border-radius: 10px; padding: 9px 16px; cursor: pointer;
327
+ }
328
+ .cta .btn:hover { filter: brightness(1.05); }
329
+ .cta-label { font-weight: 600; font-size: 14px; }
330
+ .cta-hint { font-size: 12px; color: var(--_muted); }
331
+ .note { font-size: 12.5px; color: var(--_muted); }
332
+ .note.warn { color: var(--ccn-gold, #ffd166); }
333
+
334
+ .opt {
335
+ display: flex; align-items: center; gap: 14px;
336
+ padding: 12px 0; border-top: 1px solid var(--_border);
337
+ }
338
+ .opt:first-of-type { border-top: 0; }
339
+ .opt.push { margin-top: 4px; border-top: 1px solid var(--_border); }
340
+ .opt-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
341
+ .opt-label { font-size: 14px; font-weight: 600; }
342
+ .opt-hint { font-size: 12px; color: var(--_muted); line-height: 1.4; }
343
+ .error { margin: 10px 0 0; font-size: 13px; color: var(--_danger); }
344
+
345
+ .switch {
346
+ flex-shrink: 0; width: 46px; height: 26px; border-radius: 999px; border: 0;
347
+ background: var(--_bg-4); position: relative; cursor: pointer;
348
+ transition: background 160ms ease-out; padding: 0;
349
+ }
350
+ .switch.on { background: var(--_accent); }
351
+ .switch[disabled] { opacity: .5; cursor: not-allowed; }
352
+ .knob {
353
+ position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
354
+ border-radius: 50%; background: #fff; transition: transform 160ms ease-out;
355
+ }
356
+ .switch.on .knob { transform: translateX(20px); }
357
+ `
358
+
359
+ function _txt (v, lang) {
360
+ if (v == null) return ''
361
+ if (typeof v === 'string') return v
362
+ return v[lang] || v.es || v.en || ''
363
+ }
364
+
365
+ // Fallback fuera del navegador (SSR / tests Node): permite importar el módulo
366
+ // sin DOM. El custom element solo se registra en navegador (ver más abajo).
367
+ const _HTMLElement = (typeof HTMLElement !== 'undefined') ? HTMLElement : class {}
368
+
369
+ class CloserClickNotifications extends _HTMLElement {
370
+ static get observedAttributes () { return ['modal', 'heading', 'lang'] }
371
+
372
+ constructor () {
373
+ super()
374
+ this.attachShadow({ mode: 'open' })
375
+ this._ctrl = null
376
+ this._unsub = null
377
+ this._onClick = this._onClick.bind(this)
378
+ }
379
+
380
+ set controller (c) {
381
+ if (this._unsub) { this._unsub(); this._unsub = null }
382
+ this._ctrl = c || null
383
+ if (this._ctrl && typeof this._ctrl.subscribe === 'function') {
384
+ this._unsub = this._ctrl.subscribe(() => this._render())
385
+ }
386
+ this._render()
387
+ }
388
+ get controller () { return this._ctrl }
389
+
390
+ connectedCallback () { this._render() }
391
+ disconnectedCallback () { if (this._unsub) { this._unsub(); this._unsub = null } }
392
+ attributeChangedCallback () { this._render() }
393
+
394
+ _lang () {
395
+ const a = (this.getAttribute('lang') || 'auto').toLowerCase()
396
+ if (a === 'es' || a === 'en') return a
397
+ const nav = (isBrowser && navigator.language || 'es').slice(0, 2)
398
+ return nav === 'en' ? 'en' : 'es'
399
+ }
400
+
401
+ _esc (s) {
402
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
403
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
404
+ ))
405
+ }
406
+
407
+ _emit (type, detail) {
408
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
409
+ }
410
+
411
+ async _onClick (e) {
412
+ const c = this._ctrl
413
+ if (!c) return
414
+ const t = e.target.closest('button[data-act]')
415
+ if (!t) {
416
+ // Click en el backdrop (solo modal): cerrar si fue directo sobre .wrap.
417
+ if (this.hasAttribute('modal') && e.target === e.currentTarget) this._emit('cc-notif-close', {})
418
+ return
419
+ }
420
+ const act = t.getAttribute('data-act')
421
+ if (act === 'close') { this._emit('cc-notif-close', {}) }
422
+ else if (act === 'enable') {
423
+ const p = await c.requestPermission()
424
+ this._emit('cc-notif-permission', { permission: p })
425
+ }
426
+ else if (act === 'toggle') {
427
+ const key = t.getAttribute('data-key')
428
+ const val = !c.get(key)
429
+ c.set(key, val)
430
+ this._emit('cc-notif-change', { key, value: val })
431
+ }
432
+ else if (act === 'push') {
433
+ if (!c.push) return
434
+ const turnOn = !c.push.enabled
435
+ if (turnOn) await c.push.enable(); else await c.push.disable()
436
+ this._emit('cc-notif-push', { enabled: c.push.enabled })
437
+ }
438
+ }
439
+
440
+ _switch (on, { key, act, disabled } = {}) {
441
+ const attrs = `data-act="${act}"${key ? ` data-key="${this._esc(key)}"` : ''}${disabled ? ' disabled' : ''}`
442
+ return `<button type="button" class="switch${on ? ' on' : ''}" role="switch" aria-checked="${on ? 'true' : 'false'}" ${attrs}><span class="knob"></span></button>`
443
+ }
444
+
445
+ _render () {
446
+ const sr = this.shadowRoot
447
+ const c = this._ctrl
448
+ const lang = this._lang()
449
+ const t = I18N[lang]
450
+ const isModal = this.hasAttribute('modal')
451
+ const heading = this.getAttribute('heading') || t.heading
452
+
453
+ if (!c) { sr.innerHTML = `<style>${STYLE}</style><div class="wrap"><div class="card"></div></div>`; return }
454
+
455
+ const perm = c.permission()
456
+ const supported = c.supported
457
+
458
+ let inner = ''
459
+ if (!supported || perm === 'unsupported') {
460
+ inner += `<p class="note warn">${this._esc(t.unsupported)}</p>`
461
+ } else if (perm === 'denied') {
462
+ inner += `<p class="note warn">${this._esc(t.denied)}</p>`
463
+ } else if (perm === 'default') {
464
+ inner += `<div class="cta">
465
+ <span class="cta-label">${this._esc(t.enable)}</span>
466
+ <span class="cta-hint">${this._esc(t.enableHint)}</span>
467
+ <button type="button" class="btn" data-act="enable">${this._esc(t.enable)}</button>
468
+ </div>`
469
+ }
470
+
471
+ // Categorías (se guardan aunque el permiso no esté concedido).
472
+ for (const cat of c.categories) {
473
+ inner += `<div class="opt">
474
+ <div class="opt-text">
475
+ <span class="opt-label">${this._esc(_txt(cat.label, lang))}</span>
476
+ ${cat.hint ? `<span class="opt-hint">${this._esc(_txt(cat.hint, lang))}</span>` : ''}
477
+ </div>
478
+ ${this._switch(c.get(cat.key) !== false, { key: cat.key, act: 'toggle' })}
479
+ </div>`
480
+ }
481
+
482
+ // Sonido
483
+ if (c.hasSound) {
484
+ inner += `<div class="opt">
485
+ <div class="opt-text">
486
+ <span class="opt-label">${this._esc(t.sound)}</span>
487
+ <span class="opt-hint">${this._esc(t.soundHint)}</span>
488
+ </div>
489
+ ${this._switch(c.soundEnabled, { key: 'sound', act: 'toggle' })}
490
+ </div>`
491
+ }
492
+
493
+ // Push (app cerrada)
494
+ if (c.push && c.push.supported) {
495
+ const pDenied = perm === 'denied'
496
+ inner += `<div class="opt push">
497
+ <div class="opt-text">
498
+ <span class="opt-label">${this._esc(t.push)}</span>
499
+ <span class="opt-hint">${this._esc(t.pushHint)}${pDenied ? ' ' + this._esc(t.pushDenied) : ''}</span>
500
+ </div>
501
+ ${this._switch(c.push.enabled, { act: 'push', disabled: c.push.busy || pDenied })}
502
+ </div>`
503
+ if (c.push.error) inner += `<p class="error">${this._esc(c.push.error)}</p>`
504
+ }
505
+
506
+ sr.innerHTML = `<style>${STYLE}</style>
507
+ <div class="wrap">
508
+ <div class="card" data-card>
509
+ <div class="head">
510
+ <h2 class="title">${this._esc(heading)}</h2>
511
+ ${isModal ? '<button type="button" class="x" data-act="close" aria-label="×">×</button>' : ''}
512
+ </div>
513
+ <div class="body">
514
+ <p class="intro">${this._esc(t.intro)}</p>
515
+ ${inner}
516
+ </div>
517
+ </div>
518
+ </div>`
519
+
520
+ // Un solo listener en .wrap: los botones se resuelven por data-act; el click
521
+ // directo en el backdrop (e.target === .wrap) cierra (solo en modal).
522
+ sr.querySelector('.wrap').addEventListener('click', this._onClick)
523
+ }
524
+ }
525
+
526
+ if (isBrowser && !customElements.get('closer-click-notifications')) {
527
+ customElements.define('closer-click-notifications', CloserClickNotifications)
528
+ }
529
+
530
+ export { CloserClickNotifications }
531
+ export default CloserClickNotifications