@dotrino/install 0.1.1 → 0.2.1
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/package.json +1 -1
- package/src/index.d.ts +34 -3
- package/src/index.js +234 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/install",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Botón de \"Instalar app\" (PWA) unificado del ecosistema Dotrino: Web Component <dotrino-install> que captura beforeinstallprompt temprano, maneja iOS con un modal de instrucciones (sin alert) y se oculta si ya está instalada. Vue o vanilla. Sin JS de terceros ni cookies, bilingüe es/en.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/index.d.ts
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
|
-
export type InstallOutcome = 'accepted' | 'dismissed' | 'instructions' | 'installed'
|
|
1
|
+
export type InstallOutcome = 'accepted' | 'dismissed' | 'instructions' | 'installed' | 'relaunch'
|
|
2
|
+
export type InstallContext = 'installed' | 'native' | 'ios' | 'relaunch' | 'none'
|
|
2
3
|
|
|
3
4
|
/** ¿La app ya corre como instalada (display-mode standalone) o se instaló ya? */
|
|
4
5
|
export function isAppInstalled(): boolean
|
|
5
6
|
|
|
7
|
+
/** Atajo legible de `isAppInstalled()`. */
|
|
8
|
+
export function isStandalone(): boolean
|
|
9
|
+
|
|
6
10
|
/** Detección de iOS/iPadOS (incluye el iPad que se reporta como Mac). */
|
|
7
11
|
export function isIOS(): boolean
|
|
8
12
|
|
|
13
|
+
/** Detección de Android. */
|
|
14
|
+
export function isAndroid(): boolean
|
|
15
|
+
|
|
16
|
+
/** Nombre del parámetro marcador ("pwa-install"). */
|
|
17
|
+
export const INSTALL_PARAM: string
|
|
18
|
+
|
|
19
|
+
/** ¿La URL trae el marcador `?pwa-install=1` (tras relanzar en Chrome)? */
|
|
20
|
+
export function hasInstallFlag(param?: string): boolean
|
|
21
|
+
|
|
22
|
+
/** ¿La app fue abierta desde el hub instalado (el home le puso `?hub=1`)? */
|
|
23
|
+
export function isEmbeddedHub(): boolean
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `intent://` que reabre la app en Chrome (no el Custom Tab/webview embebido)
|
|
27
|
+
* con `?pwa-install=1`, con fallback a https. Solo útil en Android. null si falla.
|
|
28
|
+
*/
|
|
29
|
+
export function chromeInstallUrl(param?: string): string | null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Estado de cara a la UI: 'installed' | 'native' (prompt nativo) | 'ios'
|
|
33
|
+
* (instrucciones Safari) | 'relaunch' (Android embebido → abrir en Chrome) | 'none'.
|
|
34
|
+
*/
|
|
35
|
+
export function installContext(): InstallContext
|
|
36
|
+
|
|
9
37
|
/** ¿Tiene sentido ofrecer instalar? (prompt nativo disponible, o iOS, y no instalada). */
|
|
10
38
|
export function canInstall(): boolean
|
|
11
39
|
|
|
@@ -26,8 +54,11 @@ export const HOME_DEFAULT: string
|
|
|
26
54
|
|
|
27
55
|
/**
|
|
28
56
|
* Custom element del botón de instalar (`<dotrino-install>`).
|
|
29
|
-
* Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono)
|
|
30
|
-
*
|
|
57
|
+
* Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono),
|
|
58
|
+
* `app-name` y `app-icon` (título e icono del overlay grande de instalación).
|
|
59
|
+
* En Android dentro de un Custom Tab (sin prompt nativo) el botón relanza la app
|
|
60
|
+
* en Chrome; al llegar con `?pwa-install=1` muestra un overlay grande centrado.
|
|
61
|
+
* Eventos: `cc-install` (cancelable), `cc-install-result` (detail.outcome, incluye 'relaunch').
|
|
31
62
|
*/
|
|
32
63
|
export class DotrinoInstall extends HTMLElement {
|
|
33
64
|
/** Dispara la instalación desde JS (igual que el click del usuario). */
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* recurrentes que aquí se arreglan de una vez:
|
|
8
8
|
*
|
|
9
9
|
* 1. El evento `beforeinstallprompt` se dispara MUY pronto (a veces antes de
|
|
10
|
-
* montar el componente). Si lo
|
|
10
|
+
* montar el componente). Si lo escuchas dentro de onMounted lo pierdes y el
|
|
11
11
|
* botón nunca aparece. Aquí lo capturamos a nivel de módulo, en import.
|
|
12
12
|
* 2. iOS/Safari NO dispara `beforeinstallprompt` y no hay API de instalación:
|
|
13
13
|
* la única vía es "Compartir → Añadir a pantalla de inicio". Mostramos esas
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* <dotrino-install></dotrino-install>
|
|
25
25
|
* <dotrino-install lang="en" label="Install"></dotrino-install>
|
|
26
26
|
*
|
|
27
|
-
* Uso programático (si
|
|
27
|
+
* Uso programático (si quieres tu propio botón) — ver también ./vue:
|
|
28
28
|
* import { canInstall, promptInstall, onInstallStateChange } from '@dotrino/install'
|
|
29
29
|
* if (canInstall()) showMyButton()
|
|
30
30
|
* await promptInstall() // dispara el prompt nativo o el modal iOS/fallback
|
|
@@ -41,6 +41,7 @@ const HOME_DEFAULT = 'https://dotrino.com'
|
|
|
41
41
|
|
|
42
42
|
let _deferred = null // el BeforeInstallPromptEvent diferido (o null)
|
|
43
43
|
let _installed = false // se marcó appinstalled en esta sesión
|
|
44
|
+
let _settled = false // pasó el margen de espera del prompt nativo
|
|
44
45
|
const _subs = new Set() // suscriptores a cambios de estado (re-render)
|
|
45
46
|
|
|
46
47
|
function _emit () {
|
|
@@ -49,6 +50,13 @@ function _emit () {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Tras un margen sin prompt nativo (Android), asumimos contexto embebido (Custom
|
|
54
|
+
// Tab abierto desde otra PWA): ahí Chrome NO dispara `beforeinstallprompt`, así
|
|
55
|
+
// que en vez de ocultar el botón ofreceremos relanzar en Chrome (ver más abajo).
|
|
56
|
+
if (typeof window !== 'undefined') {
|
|
57
|
+
try { setTimeout(() => { _settled = true; _emit() }, 1400) } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
function _onBIP (e) {
|
|
53
61
|
// Evita que el navegador muestre su mini-infobar; nosotros decidimos cuándo.
|
|
54
62
|
try { e.preventDefault() } catch (_) {}
|
|
@@ -91,6 +99,70 @@ export function isIOS () {
|
|
|
91
99
|
return false
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/** ¿Corre como app instalada (standalone)? Atajo legible. */
|
|
103
|
+
export function isStandalone () {
|
|
104
|
+
return isAppInstalled()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Detección de Android. */
|
|
108
|
+
export function isAndroid () {
|
|
109
|
+
try { return /Android/i.test(navigator.userAgent || '') } catch (_) { return false }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const INSTALL_PARAM = 'pwa-install'
|
|
113
|
+
const HUB_PARAM = 'hub'
|
|
114
|
+
|
|
115
|
+
/** ¿La URL trae el marcador de "abrir para instalar" (tras relanzar en Chrome)? */
|
|
116
|
+
export function hasInstallFlag (param = INSTALL_PARAM) {
|
|
117
|
+
try { return new URLSearchParams(location.search).has(param) } catch (_) { return false }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ¿La app fue abierta DESDE el hub instalado (el home le puso `?hub=1`)? En ese
|
|
122
|
+
* caso corre embebida en un Custom Tab que puede reportarse como standalone; este
|
|
123
|
+
* marcador es la señal fiable de "estoy embebido, ofrece instalar en Chrome".
|
|
124
|
+
*/
|
|
125
|
+
export function isEmbeddedHub () {
|
|
126
|
+
try { return new URLSearchParams(location.search).has(HUB_PARAM) } catch (_) { return false }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Construye un `intent://` que reabre ESTA misma app en **Chrome** (no en el
|
|
131
|
+
* Custom Tab/webview embebido) con el marcador `?pwa-install=1`, con fallback a
|
|
132
|
+
* la URL https normal si Chrome no está. Solo tiene sentido en Android.
|
|
133
|
+
*/
|
|
134
|
+
export function chromeInstallUrl (param = INSTALL_PARAM) {
|
|
135
|
+
try {
|
|
136
|
+
const u = new URL(location.href)
|
|
137
|
+
u.searchParams.delete(HUB_PARAM) // el marcador del hub no debe viajar a Chrome
|
|
138
|
+
u.searchParams.set(param, '1')
|
|
139
|
+
const target = `${u.host}${u.pathname}${u.search}`
|
|
140
|
+
const fallback = encodeURIComponent(u.toString())
|
|
141
|
+
return `intent://${target}#Intent;scheme=https;package=com.android.chrome;S.browser_fallback_url=${fallback};end`
|
|
142
|
+
} catch (_) { return null }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Estado de instalación de cara a la UI:
|
|
147
|
+
* - 'installed' ya instalada / standalone → no ofrecer.
|
|
148
|
+
* - 'native' hay prompt nativo (Chrome instalable) → instalar de un toque.
|
|
149
|
+
* - 'ios' iOS/Safari → instrucciones "Añadir a pantalla de inicio".
|
|
150
|
+
* - 'relaunch' Android sin prompt nativo (probable Custom Tab embebido) →
|
|
151
|
+
* relanzar en Chrome con `chromeInstallUrl()`.
|
|
152
|
+
* - 'none' nada que ofrecer (todavía esperando, o navegador sin soporte).
|
|
153
|
+
*/
|
|
154
|
+
export function installContext () {
|
|
155
|
+
// Prompt nativo (Chrome real) siempre gana.
|
|
156
|
+
if (_deferred) return 'native'
|
|
157
|
+
// Embebido desde el hub (Android): ofrecer relanzar en Chrome AUNQUE el Custom
|
|
158
|
+
// Tab se reporte como standalone (por eso esto va ANTES de isAppInstalled()).
|
|
159
|
+
if (isEmbeddedHub() && isAndroid()) return 'relaunch'
|
|
160
|
+
if (isAppInstalled()) return 'installed'
|
|
161
|
+
if (isIOS()) return 'ios'
|
|
162
|
+
if (_settled && isAndroid()) return 'relaunch'
|
|
163
|
+
return 'none'
|
|
164
|
+
}
|
|
165
|
+
|
|
94
166
|
/**
|
|
95
167
|
* ¿Tiene sentido ofrecer instalar? true si hay prompt nativo disponible, o si es
|
|
96
168
|
* iOS (instalable a mano vía Compartir), siempre que NO esté ya instalada.
|
|
@@ -153,7 +225,12 @@ const I18N = {
|
|
|
153
225
|
iosStep2: 'Elige «Añadir a pantalla de inicio»',
|
|
154
226
|
otherIntro: 'Tu navegador no permite la instalación con un toque. Para instalarla:',
|
|
155
227
|
otherStep: 'Abre el menú del navegador y elige «Instalar app» (o «Añadir a pantalla de inicio»).',
|
|
156
|
-
close: 'Cerrar'
|
|
228
|
+
close: 'Cerrar',
|
|
229
|
+
bigTitle: 'Instala la app',
|
|
230
|
+
bigTitleNamed: (n) => `Instala ${n}`,
|
|
231
|
+
bigSub: 'Acceso directo en tu pantalla de inicio, sin tienda de apps.',
|
|
232
|
+
preparing: 'Preparando…',
|
|
233
|
+
notNow: 'Ahora no'
|
|
157
234
|
},
|
|
158
235
|
en: {
|
|
159
236
|
install: 'Install App',
|
|
@@ -163,7 +240,12 @@ const I18N = {
|
|
|
163
240
|
iosStep2: 'Choose “Add to Home Screen”',
|
|
164
241
|
otherIntro: 'Your browser can’t install with one tap. To install it:',
|
|
165
242
|
otherStep: 'Open the browser menu and choose “Install app” (or “Add to Home Screen”).',
|
|
166
|
-
close: 'Close'
|
|
243
|
+
close: 'Close',
|
|
244
|
+
bigTitle: 'Install the app',
|
|
245
|
+
bigTitleNamed: (n) => `Install ${n}`,
|
|
246
|
+
bigSub: 'A shortcut on your home screen, no app store.',
|
|
247
|
+
preparing: 'Preparing…',
|
|
248
|
+
notNow: 'Not now'
|
|
167
249
|
}
|
|
168
250
|
}
|
|
169
251
|
|
|
@@ -263,25 +345,47 @@ const STYLE = `
|
|
|
263
345
|
flex: none; width: 24px; height: 24px; border-radius: 50%;
|
|
264
346
|
display: inline-flex; align-items: center; justify-content: center;
|
|
265
347
|
font-size: 13px; font-weight: 700;
|
|
266
|
-
background: var(--cc-install-accent, #84cc16); color: #14110f;
|
|
348
|
+
background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
|
|
267
349
|
}
|
|
268
350
|
.steps svg { width: 20px; height: 20px; flex: none; }
|
|
269
351
|
.card .ok {
|
|
270
352
|
all: unset; box-sizing: border-box; cursor: pointer;
|
|
271
353
|
display: block; width: 100%; text-align: center;
|
|
272
354
|
padding: 11px; border-radius: 12px; font-weight: 700;
|
|
273
|
-
background: var(--cc-install-accent, #84cc16); color: #14110f;
|
|
355
|
+
background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
|
|
274
356
|
}
|
|
275
357
|
.card .ok:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
358
|
+
|
|
359
|
+
/* Overlay grande y centrado: aparece al llegar con ?pwa-install=1 (relanzado en Chrome). */
|
|
360
|
+
.big { text-align: center; }
|
|
361
|
+
.big .app-icon { width: 72px; height: 72px; border-radius: 18px; margin: 2px auto 14px; display: block; }
|
|
362
|
+
.big h2 { font-size: 20px; }
|
|
363
|
+
.big .sub { margin: 0 0 18px; opacity: .8; }
|
|
364
|
+
.big .cta {
|
|
365
|
+
all: unset; box-sizing: border-box; cursor: pointer;
|
|
366
|
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
367
|
+
width: 100%; padding: 14px; border-radius: 14px; font-weight: 800; font-size: 16px;
|
|
368
|
+
background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
|
|
369
|
+
}
|
|
370
|
+
.big .cta[disabled] { opacity: .6; cursor: default; }
|
|
371
|
+
.big .cta svg { width: 20px; height: 20px; }
|
|
372
|
+
.big .manual { margin: 14px 2px 0; font-size: 14px; opacity: .85; }
|
|
373
|
+
.big .dismiss {
|
|
374
|
+
all: unset; box-sizing: border-box; cursor: pointer;
|
|
375
|
+
display: block; width: 100%; text-align: center;
|
|
376
|
+
margin-top: 10px; padding: 8px; font-weight: 600; opacity: .7;
|
|
377
|
+
}
|
|
276
378
|
`
|
|
277
379
|
|
|
278
380
|
class DotrinoInstall extends HTMLElement {
|
|
279
|
-
static get observedAttributes () { return ['lang', 'label', 'icon'] }
|
|
381
|
+
static get observedAttributes () { return ['lang', 'label', 'icon', 'app-name', 'app-icon'] }
|
|
280
382
|
|
|
281
383
|
constructor () {
|
|
282
384
|
super()
|
|
283
385
|
this.attachShadow({ mode: 'open' })
|
|
284
|
-
this._modalOpen = false
|
|
386
|
+
this._modalOpen = false // modal de instrucciones (iOS/fallback)
|
|
387
|
+
this._bigOpen = false // overlay grande de instalación (?pwa-install=1)
|
|
388
|
+
this._bigManual = false // el overlay grande pasó a instrucciones manuales
|
|
285
389
|
this._unsub = null
|
|
286
390
|
this._onState = this._render.bind(this)
|
|
287
391
|
this._onKey = this._onKey.bind(this)
|
|
@@ -289,12 +393,52 @@ class DotrinoInstall extends HTMLElement {
|
|
|
289
393
|
|
|
290
394
|
connectedCallback () {
|
|
291
395
|
this._unsub = onInstallStateChange(this._onState)
|
|
396
|
+
// Si llegamos con el marcador (relanzados en Chrome para instalar), abrimos
|
|
397
|
+
// el overlay grande centrado. Solo el primero monta el overlay (singleton).
|
|
398
|
+
if (hasInstallFlag() && !isAppInstalled() && !DotrinoInstall._bigClaimed) {
|
|
399
|
+
DotrinoInstall._bigClaimed = true
|
|
400
|
+
this._bigOpen = true
|
|
401
|
+
try { document.addEventListener('keydown', this._onKey) } catch (_) {}
|
|
402
|
+
// Si en unos segundos no hay prompt nativo, ofrecemos la vía manual.
|
|
403
|
+
setTimeout(() => {
|
|
404
|
+
if (this._bigOpen && !hasNativePrompt() && !isAppInstalled()) { this._bigManual = true; this._render() }
|
|
405
|
+
}, 4000)
|
|
406
|
+
}
|
|
292
407
|
this._render()
|
|
293
408
|
}
|
|
294
409
|
|
|
295
410
|
disconnectedCallback () {
|
|
296
411
|
if (this._unsub) { this._unsub(); this._unsub = null }
|
|
297
412
|
try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
|
|
413
|
+
if (this._portal) { try { this._portal.remove() } catch (_) {} this._portal = null; this._portalShadow = null }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* Los modales (instrucciones / overlay grande) usan position:fixed para
|
|
417
|
+
centrarse en la ventana. Si el <dotrino-install> vive dentro de un ancestro
|
|
418
|
+
con `backdrop-filter`/`transform` (topbars), ese ancestro se vuelve el bloque
|
|
419
|
+
contenedor del fixed y el modal se descoloca. Por eso los renderizamos en un
|
|
420
|
+
PORTAL colgado de <body>, con su propio shadow root y el tema copiado. */
|
|
421
|
+
_portalRoot () {
|
|
422
|
+
if (!this._portal) {
|
|
423
|
+
this._portal = document.createElement('div')
|
|
424
|
+
this._portal.setAttribute('data-dotrino-install-portal', '')
|
|
425
|
+
this._portalShadow = this._portal.attachShadow({ mode: 'open' })
|
|
426
|
+
try {
|
|
427
|
+
const cs = getComputedStyle(this)
|
|
428
|
+
for (const v of ['--cc-install-accent', '--cc-install-modal-bg', '--cc-install-modal-color']) {
|
|
429
|
+
const val = cs.getPropertyValue(v)
|
|
430
|
+
if (val && val.trim()) this._portal.style.setProperty(v, val.trim())
|
|
431
|
+
}
|
|
432
|
+
} catch (_) {}
|
|
433
|
+
document.body.appendChild(this._portal)
|
|
434
|
+
}
|
|
435
|
+
return this._portalShadow
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_renderPortal (innerHTML) {
|
|
439
|
+
const root = this._portalRoot()
|
|
440
|
+
root.innerHTML = innerHTML ? `<style>${STYLE}</style>${innerHTML}` : ''
|
|
441
|
+
return root
|
|
298
442
|
}
|
|
299
443
|
|
|
300
444
|
attributeChangedCallback () {
|
|
@@ -302,39 +446,68 @@ class DotrinoInstall extends HTMLElement {
|
|
|
302
446
|
}
|
|
303
447
|
|
|
304
448
|
_render () {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return
|
|
311
|
-
}
|
|
449
|
+
const ctx = installContext() // installed|native|ios|relaunch|none
|
|
450
|
+
const showBtn = ctx === 'native' || ctx === 'ios' || ctx === 'relaunch'
|
|
451
|
+
// Oculta el host por completo cuando no hay botón ni modal abierto.
|
|
452
|
+
this.hidden = !showBtn && !this._modalOpen && !this._bigOpen
|
|
453
|
+
if (this.hidden) { this.shadowRoot.innerHTML = ''; return }
|
|
312
454
|
|
|
313
455
|
const lang = resolveLang(this.getAttribute('lang'))
|
|
314
456
|
const t = I18N[lang]
|
|
315
457
|
const label = this.getAttribute('label') != null ? this.getAttribute('label') : t.install
|
|
316
458
|
const showIcon = (this.getAttribute('icon') || '').toLowerCase() !== 'false'
|
|
317
459
|
|
|
460
|
+
// Botón pequeño en el shadow propio (inline en la topbar).
|
|
318
461
|
let html = `<style>${STYLE}</style>`
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
462
|
+
if (showBtn) {
|
|
463
|
+
html += `<button class="trigger" type="button" part="button" aria-label="${label || t.install}">`
|
|
464
|
+
if (showIcon) html += `<span class="ico" part="icon">${ICON_DOWNLOAD}</span>`
|
|
465
|
+
html += `<span class="lbl" part="label">${label}</span></button>`
|
|
466
|
+
}
|
|
325
467
|
this.shadowRoot.innerHTML = html
|
|
326
468
|
const btn = this.shadowRoot.querySelector('button.trigger')
|
|
327
|
-
if (btn) btn.addEventListener('click', () => this._activate())
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
const
|
|
333
|
-
if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop)
|
|
334
|
-
|
|
469
|
+
if (btn) btn.addEventListener('click', () => this._activate(ctx))
|
|
470
|
+
|
|
471
|
+
// Modales en el PORTAL (body), para que el fixed se centre en la ventana.
|
|
472
|
+
if (this._bigOpen) {
|
|
473
|
+
const root = this._renderPortal(this._bigHTML(lang))
|
|
474
|
+
const backdrop = root.querySelector('.backdrop')
|
|
475
|
+
if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeBig() })
|
|
476
|
+
root.querySelector('.dismiss')?.addEventListener('click', () => this._closeBig())
|
|
477
|
+
root.querySelector('.cta')?.addEventListener('click', () => this._bigInstall())
|
|
478
|
+
} else if (this._modalOpen) {
|
|
479
|
+
const root = this._renderPortal(this._modalHTML(lang))
|
|
480
|
+
const backdrop = root.querySelector('.backdrop')
|
|
481
|
+
const okBtn = root.querySelector('.ok')
|
|
482
|
+
if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeModal() })
|
|
483
|
+
if (okBtn) okBtn.addEventListener('click', () => this._closeModal())
|
|
484
|
+
} else if (this._portalShadow) {
|
|
485
|
+
this._renderPortal('') // limpia el portal cuando no hay modal
|
|
335
486
|
}
|
|
336
487
|
}
|
|
337
488
|
|
|
489
|
+
/** Overlay grande y centrado de instalación (al llegar con ?pwa-install=1). */
|
|
490
|
+
_bigHTML (lang) {
|
|
491
|
+
const t = I18N[lang]
|
|
492
|
+
const name = this.getAttribute('app-name')
|
|
493
|
+
const icon = this.getAttribute('app-icon')
|
|
494
|
+
const title = name ? t.bigTitleNamed(name) : t.bigTitle
|
|
495
|
+
const ready = hasNativePrompt()
|
|
496
|
+
let body
|
|
497
|
+
if (this._bigManual) {
|
|
498
|
+
const step = isIOS() ? `${t.iosStep1} → ${t.iosStep2}` : t.otherStep
|
|
499
|
+
body = `<p class="manual">${step}</p>`
|
|
500
|
+
} else {
|
|
501
|
+
body = `<button class="cta" type="button" ${ready ? '' : 'disabled'}>${ICON_DOWNLOAD}` +
|
|
502
|
+
`<span>${ready ? t.install : t.preparing}</span></button>`
|
|
503
|
+
}
|
|
504
|
+
return `<div class="backdrop" part="modal" role="dialog" aria-modal="true" aria-label="${title}">` +
|
|
505
|
+
`<div class="card big" part="modal-card">` +
|
|
506
|
+
(icon ? `<img class="app-icon" src="${icon}" alt="" width="72" height="72" />` : '') +
|
|
507
|
+
`<h2>${title}</h2><p class="sub">${t.bigSub}</p>${body}` +
|
|
508
|
+
`<button class="dismiss" type="button">${t.notNow}</button></div></div>`
|
|
509
|
+
}
|
|
510
|
+
|
|
338
511
|
_modalHTML (lang) {
|
|
339
512
|
const t = I18N[lang]
|
|
340
513
|
let steps
|
|
@@ -350,10 +523,19 @@ class DotrinoInstall extends HTMLElement {
|
|
|
350
523
|
`<button class="ok" type="button">${t.close}</button></div></div>`
|
|
351
524
|
}
|
|
352
525
|
|
|
353
|
-
async _activate () {
|
|
526
|
+
async _activate (ctx) {
|
|
354
527
|
const ev = new CustomEvent('cc-install', { bubbles: true, composed: true, cancelable: true })
|
|
355
528
|
if (!this.dispatchEvent(ev)) return // la app canceló para hacer lo suyo
|
|
356
529
|
|
|
530
|
+
// Contexto embebido (Custom Tab desde otra PWA): no hay prompt nativo, así que
|
|
531
|
+
// relanzamos la app en Chrome, donde la instalación sí funciona.
|
|
532
|
+
if (ctx === 'relaunch') {
|
|
533
|
+
const url = chromeInstallUrl()
|
|
534
|
+
this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome: 'relaunch' } }))
|
|
535
|
+
if (url) { try { location.href = url } catch (_) {} }
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
|
|
357
539
|
const outcome = await promptInstall()
|
|
358
540
|
if (outcome === 'instructions') {
|
|
359
541
|
this._openModal()
|
|
@@ -364,6 +546,22 @@ class DotrinoInstall extends HTMLElement {
|
|
|
364
546
|
this._render()
|
|
365
547
|
}
|
|
366
548
|
|
|
549
|
+
/** Botón grande del overlay: dispara el prompt nativo; si no hay, instrucciones. */
|
|
550
|
+
async _bigInstall () {
|
|
551
|
+
if (!hasNativePrompt()) { this._bigManual = true; this._render(); return }
|
|
552
|
+
const outcome = await promptInstall()
|
|
553
|
+
this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome } }))
|
|
554
|
+
if (outcome === 'accepted' || outcome === 'installed') this._closeBig()
|
|
555
|
+
else if (outcome === 'instructions') { this._bigManual = true; this._render() }
|
|
556
|
+
else this._render()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
_closeBig () {
|
|
560
|
+
this._bigOpen = false
|
|
561
|
+
try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
|
|
562
|
+
this._render()
|
|
563
|
+
}
|
|
564
|
+
|
|
367
565
|
_openModal () {
|
|
368
566
|
this._modalOpen = true
|
|
369
567
|
try { document.addEventListener('keydown', this._onKey) } catch (_) {}
|
|
@@ -377,7 +575,9 @@ class DotrinoInstall extends HTMLElement {
|
|
|
377
575
|
}
|
|
378
576
|
|
|
379
577
|
_onKey (e) {
|
|
380
|
-
if (e.key
|
|
578
|
+
if (e.key !== 'Escape') return
|
|
579
|
+
if (this._bigOpen) this._closeBig()
|
|
580
|
+
else this._closeModal()
|
|
381
581
|
}
|
|
382
582
|
|
|
383
583
|
/** Dispara la instalación desde JS de la app (igual que el click). */
|
|
@@ -388,4 +588,6 @@ if (typeof customElements !== 'undefined' && !customElements.get('dotrino-instal
|
|
|
388
588
|
customElements.define('dotrino-install', DotrinoInstall)
|
|
389
589
|
}
|
|
390
590
|
|
|
391
|
-
|
|
591
|
+
DotrinoInstall._bigClaimed = false
|
|
592
|
+
|
|
593
|
+
export { DotrinoInstall, HOME_DEFAULT, INSTALL_PARAM }
|