@dotrino/install 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/install",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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,36 @@
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
+ /**
23
+ * `intent://` que reabre la app en Chrome (no el Custom Tab/webview embebido)
24
+ * con `?pwa-install=1`, con fallback a https. Solo útil en Android. null si falla.
25
+ */
26
+ export function chromeInstallUrl(param?: string): string | null
27
+
28
+ /**
29
+ * Estado de cara a la UI: 'installed' | 'native' (prompt nativo) | 'ios'
30
+ * (instrucciones Safari) | 'relaunch' (Android embebido → abrir en Chrome) | 'none'.
31
+ */
32
+ export function installContext(): InstallContext
33
+
9
34
  /** ¿Tiene sentido ofrecer instalar? (prompt nativo disponible, o iOS, y no instalada). */
10
35
  export function canInstall(): boolean
11
36
 
@@ -26,8 +51,11 @@ export const HOME_DEFAULT: string
26
51
 
27
52
  /**
28
53
  * Custom element del botón de instalar (`<dotrino-install>`).
29
- * Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono).
30
- * Eventos: `cc-install` (cancelable), `cc-install-result` (detail.outcome).
54
+ * Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono),
55
+ * `app-name` y `app-icon` (título e icono del overlay grande de instalación).
56
+ * En Android dentro de un Custom Tab (sin prompt nativo) el botón relanza la app
57
+ * en Chrome; al llegar con `?pwa-install=1` muestra un overlay grande centrado.
58
+ * Eventos: `cc-install` (cancelable), `cc-install-result` (detail.outcome, incluye 'relaunch').
31
59
  */
32
60
  export class DotrinoInstall extends HTMLElement {
33
61
  /** 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 escuchás dentro de onMounted lo perdés y el
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 querés tu propio botón) — ver también ./vue:
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,55 @@ 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
+
114
+ /** ¿La URL trae el marcador de "abrir para instalar" (tras relanzar en Chrome)? */
115
+ export function hasInstallFlag (param = INSTALL_PARAM) {
116
+ try { return new URLSearchParams(location.search).has(param) } catch (_) { return false }
117
+ }
118
+
119
+ /**
120
+ * Construye un `intent://` que reabre ESTA misma app en **Chrome** (no en el
121
+ * Custom Tab/webview embebido) con el marcador `?pwa-install=1`, con fallback a
122
+ * la URL https normal si Chrome no está. Solo tiene sentido en Android.
123
+ */
124
+ export function chromeInstallUrl (param = INSTALL_PARAM) {
125
+ try {
126
+ const u = new URL(location.href)
127
+ u.searchParams.set(param, '1')
128
+ const target = `${u.host}${u.pathname}${u.search}`
129
+ const fallback = encodeURIComponent(u.toString())
130
+ return `intent://${target}#Intent;scheme=https;package=com.android.chrome;S.browser_fallback_url=${fallback};end`
131
+ } catch (_) { return null }
132
+ }
133
+
134
+ /**
135
+ * Estado de instalación de cara a la UI:
136
+ * - 'installed' ya instalada / standalone → no ofrecer.
137
+ * - 'native' hay prompt nativo (Chrome instalable) → instalar de un toque.
138
+ * - 'ios' iOS/Safari → instrucciones "Añadir a pantalla de inicio".
139
+ * - 'relaunch' Android sin prompt nativo (probable Custom Tab embebido) →
140
+ * relanzar en Chrome con `chromeInstallUrl()`.
141
+ * - 'none' nada que ofrecer (todavía esperando, o navegador sin soporte).
142
+ */
143
+ export function installContext () {
144
+ if (isAppInstalled()) return 'installed'
145
+ if (_deferred) return 'native'
146
+ if (isIOS()) return 'ios'
147
+ if (_settled && isAndroid()) return 'relaunch'
148
+ return 'none'
149
+ }
150
+
94
151
  /**
95
152
  * ¿Tiene sentido ofrecer instalar? true si hay prompt nativo disponible, o si es
96
153
  * iOS (instalable a mano vía Compartir), siempre que NO esté ya instalada.
@@ -153,7 +210,12 @@ const I18N = {
153
210
  iosStep2: 'Elige «Añadir a pantalla de inicio»',
154
211
  otherIntro: 'Tu navegador no permite la instalación con un toque. Para instalarla:',
155
212
  otherStep: 'Abre el menú del navegador y elige «Instalar app» (o «Añadir a pantalla de inicio»).',
156
- close: 'Cerrar'
213
+ close: 'Cerrar',
214
+ bigTitle: 'Instala la app',
215
+ bigTitleNamed: (n) => `Instala ${n}`,
216
+ bigSub: 'Acceso directo en tu pantalla de inicio, sin tienda de apps.',
217
+ preparing: 'Preparando…',
218
+ notNow: 'Ahora no'
157
219
  },
158
220
  en: {
159
221
  install: 'Install App',
@@ -163,7 +225,12 @@ const I18N = {
163
225
  iosStep2: 'Choose “Add to Home Screen”',
164
226
  otherIntro: 'Your browser can’t install with one tap. To install it:',
165
227
  otherStep: 'Open the browser menu and choose “Install app” (or “Add to Home Screen”).',
166
- close: 'Close'
228
+ close: 'Close',
229
+ bigTitle: 'Install the app',
230
+ bigTitleNamed: (n) => `Install ${n}`,
231
+ bigSub: 'A shortcut on your home screen, no app store.',
232
+ preparing: 'Preparing…',
233
+ notNow: 'Not now'
167
234
  }
168
235
  }
169
236
 
@@ -263,25 +330,47 @@ const STYLE = `
263
330
  flex: none; width: 24px; height: 24px; border-radius: 50%;
264
331
  display: inline-flex; align-items: center; justify-content: center;
265
332
  font-size: 13px; font-weight: 700;
266
- background: var(--cc-install-accent, #84cc16); color: #14110f;
333
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
267
334
  }
268
335
  .steps svg { width: 20px; height: 20px; flex: none; }
269
336
  .card .ok {
270
337
  all: unset; box-sizing: border-box; cursor: pointer;
271
338
  display: block; width: 100%; text-align: center;
272
339
  padding: 11px; border-radius: 12px; font-weight: 700;
273
- background: var(--cc-install-accent, #84cc16); color: #14110f;
340
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
274
341
  }
275
342
  .card .ok:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
343
+
344
+ /* Overlay grande y centrado: aparece al llegar con ?pwa-install=1 (relanzado en Chrome). */
345
+ .big { text-align: center; }
346
+ .big .app-icon { width: 72px; height: 72px; border-radius: 18px; margin: 2px auto 14px; display: block; }
347
+ .big h2 { font-size: 20px; }
348
+ .big .sub { margin: 0 0 18px; opacity: .8; }
349
+ .big .cta {
350
+ all: unset; box-sizing: border-box; cursor: pointer;
351
+ display: flex; align-items: center; justify-content: center; gap: 8px;
352
+ width: 100%; padding: 14px; border-radius: 14px; font-weight: 800; font-size: 16px;
353
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
354
+ }
355
+ .big .cta[disabled] { opacity: .6; cursor: default; }
356
+ .big .cta svg { width: 20px; height: 20px; }
357
+ .big .manual { margin: 14px 2px 0; font-size: 14px; opacity: .85; }
358
+ .big .dismiss {
359
+ all: unset; box-sizing: border-box; cursor: pointer;
360
+ display: block; width: 100%; text-align: center;
361
+ margin-top: 10px; padding: 8px; font-weight: 600; opacity: .7;
362
+ }
276
363
  `
277
364
 
278
365
  class DotrinoInstall extends HTMLElement {
279
- static get observedAttributes () { return ['lang', 'label', 'icon'] }
366
+ static get observedAttributes () { return ['lang', 'label', 'icon', 'app-name', 'app-icon'] }
280
367
 
281
368
  constructor () {
282
369
  super()
283
370
  this.attachShadow({ mode: 'open' })
284
- this._modalOpen = false
371
+ this._modalOpen = false // modal de instrucciones (iOS/fallback)
372
+ this._bigOpen = false // overlay grande de instalación (?pwa-install=1)
373
+ this._bigManual = false // el overlay grande pasó a instrucciones manuales
285
374
  this._unsub = null
286
375
  this._onState = this._render.bind(this)
287
376
  this._onKey = this._onKey.bind(this)
@@ -289,12 +378,52 @@ class DotrinoInstall extends HTMLElement {
289
378
 
290
379
  connectedCallback () {
291
380
  this._unsub = onInstallStateChange(this._onState)
381
+ // Si llegamos con el marcador (relanzados en Chrome para instalar), abrimos
382
+ // el overlay grande centrado. Solo el primero monta el overlay (singleton).
383
+ if (hasInstallFlag() && !isAppInstalled() && !DotrinoInstall._bigClaimed) {
384
+ DotrinoInstall._bigClaimed = true
385
+ this._bigOpen = true
386
+ try { document.addEventListener('keydown', this._onKey) } catch (_) {}
387
+ // Si en unos segundos no hay prompt nativo, ofrecemos la vía manual.
388
+ setTimeout(() => {
389
+ if (this._bigOpen && !hasNativePrompt() && !isAppInstalled()) { this._bigManual = true; this._render() }
390
+ }, 4000)
391
+ }
292
392
  this._render()
293
393
  }
294
394
 
295
395
  disconnectedCallback () {
296
396
  if (this._unsub) { this._unsub(); this._unsub = null }
297
397
  try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
398
+ if (this._portal) { try { this._portal.remove() } catch (_) {} this._portal = null; this._portalShadow = null }
399
+ }
400
+
401
+ /* Los modales (instrucciones / overlay grande) usan position:fixed para
402
+ centrarse en la ventana. Si el <dotrino-install> vive dentro de un ancestro
403
+ con `backdrop-filter`/`transform` (topbars), ese ancestro se vuelve el bloque
404
+ contenedor del fixed y el modal se descoloca. Por eso los renderizamos en un
405
+ PORTAL colgado de <body>, con su propio shadow root y el tema copiado. */
406
+ _portalRoot () {
407
+ if (!this._portal) {
408
+ this._portal = document.createElement('div')
409
+ this._portal.setAttribute('data-dotrino-install-portal', '')
410
+ this._portalShadow = this._portal.attachShadow({ mode: 'open' })
411
+ try {
412
+ const cs = getComputedStyle(this)
413
+ for (const v of ['--cc-install-accent', '--cc-install-modal-bg', '--cc-install-modal-color']) {
414
+ const val = cs.getPropertyValue(v)
415
+ if (val && val.trim()) this._portal.style.setProperty(v, val.trim())
416
+ }
417
+ } catch (_) {}
418
+ document.body.appendChild(this._portal)
419
+ }
420
+ return this._portalShadow
421
+ }
422
+
423
+ _renderPortal (innerHTML) {
424
+ const root = this._portalRoot()
425
+ root.innerHTML = innerHTML ? `<style>${STYLE}</style>${innerHTML}` : ''
426
+ return root
298
427
  }
299
428
 
300
429
  attributeChangedCallback () {
@@ -302,39 +431,68 @@ class DotrinoInstall extends HTMLElement {
302
431
  }
303
432
 
304
433
  _render () {
305
- const show = canInstall()
306
- // Oculta el host por completo cuando no hay nada que ofrecer (no ocupa espacio).
307
- this.hidden = !show
308
- if (!show && !this._modalOpen) {
309
- this.shadowRoot.innerHTML = ''
310
- return
311
- }
434
+ const ctx = installContext() // installed|native|ios|relaunch|none
435
+ const showBtn = ctx === 'native' || ctx === 'ios' || ctx === 'relaunch'
436
+ // Oculta el host por completo cuando no hay botón ni modal abierto.
437
+ this.hidden = !showBtn && !this._modalOpen && !this._bigOpen
438
+ if (this.hidden) { this.shadowRoot.innerHTML = ''; return }
312
439
 
313
440
  const lang = resolveLang(this.getAttribute('lang'))
314
441
  const t = I18N[lang]
315
442
  const label = this.getAttribute('label') != null ? this.getAttribute('label') : t.install
316
443
  const showIcon = (this.getAttribute('icon') || '').toLowerCase() !== 'false'
317
444
 
445
+ // Botón pequeño en el shadow propio (inline en la topbar).
318
446
  let html = `<style>${STYLE}</style>`
319
- html += `<button class="trigger" type="button" part="button" aria-label="${label || t.install}">`
320
- if (showIcon) html += `<span class="ico" part="icon">${ICON_DOWNLOAD}</span>`
321
- html += `<span class="lbl" part="label">${label}</span></button>`
322
-
323
- if (this._modalOpen) html += this._modalHTML(lang)
324
-
447
+ if (showBtn) {
448
+ html += `<button class="trigger" type="button" part="button" aria-label="${label || t.install}">`
449
+ if (showIcon) html += `<span class="ico" part="icon">${ICON_DOWNLOAD}</span>`
450
+ html += `<span class="lbl" part="label">${label}</span></button>`
451
+ }
325
452
  this.shadowRoot.innerHTML = html
326
453
  const btn = this.shadowRoot.querySelector('button.trigger')
327
- if (btn) btn.addEventListener('click', () => this._activate())
328
-
329
- if (this._modalOpen) {
330
- const close = () => this._closeModal()
331
- const backdrop = this.shadowRoot.querySelector('.backdrop')
332
- const okBtn = this.shadowRoot.querySelector('.ok')
333
- if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
334
- if (okBtn) okBtn.addEventListener('click', close)
454
+ if (btn) btn.addEventListener('click', () => this._activate(ctx))
455
+
456
+ // Modales en el PORTAL (body), para que el fixed se centre en la ventana.
457
+ if (this._bigOpen) {
458
+ const root = this._renderPortal(this._bigHTML(lang))
459
+ const backdrop = root.querySelector('.backdrop')
460
+ if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeBig() })
461
+ root.querySelector('.dismiss')?.addEventListener('click', () => this._closeBig())
462
+ root.querySelector('.cta')?.addEventListener('click', () => this._bigInstall())
463
+ } else if (this._modalOpen) {
464
+ const root = this._renderPortal(this._modalHTML(lang))
465
+ const backdrop = root.querySelector('.backdrop')
466
+ const okBtn = root.querySelector('.ok')
467
+ if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeModal() })
468
+ if (okBtn) okBtn.addEventListener('click', () => this._closeModal())
469
+ } else if (this._portalShadow) {
470
+ this._renderPortal('') // limpia el portal cuando no hay modal
335
471
  }
336
472
  }
337
473
 
474
+ /** Overlay grande y centrado de instalación (al llegar con ?pwa-install=1). */
475
+ _bigHTML (lang) {
476
+ const t = I18N[lang]
477
+ const name = this.getAttribute('app-name')
478
+ const icon = this.getAttribute('app-icon')
479
+ const title = name ? t.bigTitleNamed(name) : t.bigTitle
480
+ const ready = hasNativePrompt()
481
+ let body
482
+ if (this._bigManual) {
483
+ const step = isIOS() ? `${t.iosStep1} → ${t.iosStep2}` : t.otherStep
484
+ body = `<p class="manual">${step}</p>`
485
+ } else {
486
+ body = `<button class="cta" type="button" ${ready ? '' : 'disabled'}>${ICON_DOWNLOAD}` +
487
+ `<span>${ready ? t.install : t.preparing}</span></button>`
488
+ }
489
+ return `<div class="backdrop" part="modal" role="dialog" aria-modal="true" aria-label="${title}">` +
490
+ `<div class="card big" part="modal-card">` +
491
+ (icon ? `<img class="app-icon" src="${icon}" alt="" width="72" height="72" />` : '') +
492
+ `<h2>${title}</h2><p class="sub">${t.bigSub}</p>${body}` +
493
+ `<button class="dismiss" type="button">${t.notNow}</button></div></div>`
494
+ }
495
+
338
496
  _modalHTML (lang) {
339
497
  const t = I18N[lang]
340
498
  let steps
@@ -350,10 +508,19 @@ class DotrinoInstall extends HTMLElement {
350
508
  `<button class="ok" type="button">${t.close}</button></div></div>`
351
509
  }
352
510
 
353
- async _activate () {
511
+ async _activate (ctx) {
354
512
  const ev = new CustomEvent('cc-install', { bubbles: true, composed: true, cancelable: true })
355
513
  if (!this.dispatchEvent(ev)) return // la app canceló para hacer lo suyo
356
514
 
515
+ // Contexto embebido (Custom Tab desde otra PWA): no hay prompt nativo, así que
516
+ // relanzamos la app en Chrome, donde la instalación sí funciona.
517
+ if (ctx === 'relaunch') {
518
+ const url = chromeInstallUrl()
519
+ this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome: 'relaunch' } }))
520
+ if (url) { try { location.href = url } catch (_) {} }
521
+ return
522
+ }
523
+
357
524
  const outcome = await promptInstall()
358
525
  if (outcome === 'instructions') {
359
526
  this._openModal()
@@ -364,6 +531,22 @@ class DotrinoInstall extends HTMLElement {
364
531
  this._render()
365
532
  }
366
533
 
534
+ /** Botón grande del overlay: dispara el prompt nativo; si no hay, instrucciones. */
535
+ async _bigInstall () {
536
+ if (!hasNativePrompt()) { this._bigManual = true; this._render(); return }
537
+ const outcome = await promptInstall()
538
+ this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome } }))
539
+ if (outcome === 'accepted' || outcome === 'installed') this._closeBig()
540
+ else if (outcome === 'instructions') { this._bigManual = true; this._render() }
541
+ else this._render()
542
+ }
543
+
544
+ _closeBig () {
545
+ this._bigOpen = false
546
+ try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
547
+ this._render()
548
+ }
549
+
367
550
  _openModal () {
368
551
  this._modalOpen = true
369
552
  try { document.addEventListener('keydown', this._onKey) } catch (_) {}
@@ -377,7 +560,9 @@ class DotrinoInstall extends HTMLElement {
377
560
  }
378
561
 
379
562
  _onKey (e) {
380
- if (e.key === 'Escape') this._closeModal()
563
+ if (e.key !== 'Escape') return
564
+ if (this._bigOpen) this._closeBig()
565
+ else this._closeModal()
381
566
  }
382
567
 
383
568
  /** Dispara la instalación desde JS de la app (igual que el click). */
@@ -388,4 +573,6 @@ if (typeof customElements !== 'undefined' && !customElements.get('dotrino-instal
388
573
  customElements.define('dotrino-install', DotrinoInstall)
389
574
  }
390
575
 
391
- export { DotrinoInstall, HOME_DEFAULT }
576
+ DotrinoInstall._bigClaimed = false
577
+
578
+ export { DotrinoInstall, HOME_DEFAULT, INSTALL_PARAM }