@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 +21 -0
- package/README.md +134 -0
- package/package.json +38 -0
- package/src/index.d.ts +92 -0
- package/src/index.js +531 -0
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
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|