@dotrino/install 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/package.json +52 -0
- package/src/index.d.ts +41 -0
- package/src/index.js +391 -0
- package/src/vue.d.ts +21 -0
- package/src/vue.js +61 -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,112 @@
|
|
|
1
|
+
# @dotrino/install
|
|
2
|
+
|
|
3
|
+
Botón de **"Instalar app"** (PWA) unificado para todo el ecosistema
|
|
4
|
+
[Dotrino](https://dotrino.com).
|
|
5
|
+
|
|
6
|
+
Resuelve la fragmentación de tener el mismo flujo `beforeinstallprompt` copiado a
|
|
7
|
+
mano en cada app (Vue y vanilla), donde cada copia divergió y arrastra los mismos
|
|
8
|
+
bugs sutiles. Un solo Web Component, testeado, igual en todas las apps.
|
|
9
|
+
|
|
10
|
+
Sin JS de terceros, sin cookies, autohosteado (Shadow DOM). Bilingüe es/en.
|
|
11
|
+
|
|
12
|
+
## Por qué un paquete y no copiar el snippet
|
|
13
|
+
|
|
14
|
+
El botón es trivial; lo que **no** lo es —y por eso se centraliza— son tres
|
|
15
|
+
detalles que casi todas las copias hacían mal:
|
|
16
|
+
|
|
17
|
+
1. **`beforeinstallprompt` se dispara muy pronto**, a veces antes de montar el
|
|
18
|
+
componente. Si lo escuchás en `onMounted` lo perdés y el botón nunca aparece.
|
|
19
|
+
Aquí se captura a nivel de módulo, en el `import`.
|
|
20
|
+
2. **iOS/Safari no soporta `beforeinstallprompt`** ni API de instalación: la
|
|
21
|
+
única vía es *Compartir → Añadir a pantalla de inicio*. Sin esto, en iPhone la
|
|
22
|
+
app simplemente no se puede instalar. Lo resolvemos con un **modal de
|
|
23
|
+
instrucciones propio** (no `alert()`, prohibido en el ecosistema).
|
|
24
|
+
3. **No reaparecer** cuando la app ya corre instalada (`display-mode: standalone`)
|
|
25
|
+
ni tras `appinstalled`.
|
|
26
|
+
|
|
27
|
+
## Uso — Web Component (recomendado)
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// Vue: importa el paquete una vez (p. ej. en main.js) y usa el tag.
|
|
31
|
+
import '@dotrino/install'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<header class="topbar">
|
|
36
|
+
<dotrino-install></dotrino-install>
|
|
37
|
+
</header>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<!-- vanilla -->
|
|
42
|
+
<script type="module" src=".../@dotrino/install/src/index.js"></script>
|
|
43
|
+
<dotrino-install lang="es"></dotrino-install>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
El elemento se **oculta solo** (no ocupa espacio) cuando no hay forma de instalar
|
|
47
|
+
o la app ya está instalada. En Chromium muestra el botón cuando llega el prompt y
|
|
48
|
+
lo dispara al hacer click. En iOS muestra el botón siempre (hasta que se instale)
|
|
49
|
+
y al hacer click abre el modal con las instrucciones de *Compartir*.
|
|
50
|
+
|
|
51
|
+
### Atributos
|
|
52
|
+
|
|
53
|
+
| Atributo | Valores | Default |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `lang` | `es` \| `en` | `<html lang>` / navegador |
|
|
56
|
+
| `label` | texto del botón | `Instalar` / `Install` |
|
|
57
|
+
| `icon` | `false` para ocultar el icono | icono visible |
|
|
58
|
+
|
|
59
|
+
### Estilo (custom properties)
|
|
60
|
+
|
|
61
|
+
`--cc-install-color`, `--cc-install-bg`, `--cc-install-bg-hover`,
|
|
62
|
+
`--cc-install-radius`, `--cc-install-pad`, `--cc-install-gap`,
|
|
63
|
+
`--cc-install-font-size`, `--cc-install-icon`, `--cc-install-focus`,
|
|
64
|
+
`--cc-install-accent` (acento del modal), `--cc-install-modal-bg`,
|
|
65
|
+
`--cc-install-modal-color`.
|
|
66
|
+
|
|
67
|
+
Parts: `button`, `icon`, `label`, `modal`, `modal-card`.
|
|
68
|
+
|
|
69
|
+
### Eventos
|
|
70
|
+
|
|
71
|
+
- `cc-install` — cancelable, antes de actuar (`preventDefault()` para hacer lo tuyo).
|
|
72
|
+
- `cc-install-result` — `detail.outcome`: `accepted` \| `dismissed` \| `instructions` \| `installed`.
|
|
73
|
+
|
|
74
|
+
## Uso programático
|
|
75
|
+
|
|
76
|
+
Para apps que quieren su propio botón con la lógica compartida:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { canInstall, promptInstall, onInstallStateChange, isIOS } from '@dotrino/install'
|
|
80
|
+
|
|
81
|
+
const unsub = onInstallStateChange(() => { miBoton.hidden = !canInstall() })
|
|
82
|
+
miBoton.onclick = async () => {
|
|
83
|
+
const outcome = await promptInstall() // 'accepted' | 'dismissed' | 'instructions' | 'installed'
|
|
84
|
+
if (outcome === 'instructions') mostrarMisInstrucciones() // iOS / navegador sin soporte
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
API: `isAppInstalled()`, `isIOS()`, `canInstall()`, `hasNativePrompt()`,
|
|
89
|
+
`promptInstall()`, `onInstallStateChange(fn) → unsub`.
|
|
90
|
+
|
|
91
|
+
### Composable Vue 3
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { useInstall } from '@dotrino/install/vue'
|
|
95
|
+
const { canInstall, isInstalled, install } = useInstall()
|
|
96
|
+
```
|
|
97
|
+
```html
|
|
98
|
+
<button v-if="canInstall" @click="install">Instalar</button>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
> Para el botón ya hecho (con modal iOS incluido) usá el Web Component; el
|
|
102
|
+
> composable es solo para botones a medida.
|
|
103
|
+
|
|
104
|
+
## Test
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
npm test # Playwright contra Chromium: prompt nativo, appinstalled y rama iOS
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Licencia
|
|
111
|
+
|
|
112
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotrino/install",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"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
|
+
"./vue": {
|
|
15
|
+
"types": "./src/vue.d.ts",
|
|
16
|
+
"import": "./src/vue.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node test/smoke.mjs"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"vue": ">=3.3.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"vue": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dotrino",
|
|
37
|
+
"web-component",
|
|
38
|
+
"custom-element",
|
|
39
|
+
"install",
|
|
40
|
+
"pwa",
|
|
41
|
+
"beforeinstallprompt",
|
|
42
|
+
"add-to-home-screen",
|
|
43
|
+
"ios",
|
|
44
|
+
"cross-app"
|
|
45
|
+
],
|
|
46
|
+
"author": "seyacat",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/imdotrino/dotrino-install.git"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type InstallOutcome = 'accepted' | 'dismissed' | 'instructions' | 'installed'
|
|
2
|
+
|
|
3
|
+
/** ¿La app ya corre como instalada (display-mode standalone) o se instaló ya? */
|
|
4
|
+
export function isAppInstalled(): boolean
|
|
5
|
+
|
|
6
|
+
/** Detección de iOS/iPadOS (incluye el iPad que se reporta como Mac). */
|
|
7
|
+
export function isIOS(): boolean
|
|
8
|
+
|
|
9
|
+
/** ¿Tiene sentido ofrecer instalar? (prompt nativo disponible, o iOS, y no instalada). */
|
|
10
|
+
export function canInstall(): boolean
|
|
11
|
+
|
|
12
|
+
/** ¿Hay un prompt nativo (Chromium) listo para dispararse sin instrucciones? */
|
|
13
|
+
export function hasNativePrompt(): boolean
|
|
14
|
+
|
|
15
|
+
/** Suscribe un callback a los cambios de estado de instalación. Devuelve el desuscriptor. */
|
|
16
|
+
export function onInstallStateChange(fn: () => void): () => void
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dispara la instalación. Lanza el prompt nativo si existe; si no, devuelve
|
|
20
|
+
* 'instructions' para que el llamador muestre instrucciones (el Web Component
|
|
21
|
+
* lo hace solo con su modal).
|
|
22
|
+
*/
|
|
23
|
+
export function promptInstall(): Promise<InstallOutcome>
|
|
24
|
+
|
|
25
|
+
export const HOME_DEFAULT: string
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 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).
|
|
31
|
+
*/
|
|
32
|
+
export class DotrinoInstall extends HTMLElement {
|
|
33
|
+
/** Dispara la instalación desde JS (igual que el click del usuario). */
|
|
34
|
+
install(): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare global {
|
|
38
|
+
interface HTMLElementTagNameMap {
|
|
39
|
+
'dotrino-install': DotrinoInstall
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/install
|
|
3
|
+
*
|
|
4
|
+
* Botón de "Instalar app" (PWA) unificado y reutilizable por CUALQUIER app del
|
|
5
|
+
* ecosistema Dotrino (Vue o vanilla). Resuelve la fragmentación de tener el
|
|
6
|
+
* mismo flujo `beforeinstallprompt` copiado a mano en cada app, con tres bugs
|
|
7
|
+
* recurrentes que aquí se arreglan de una vez:
|
|
8
|
+
*
|
|
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
|
|
11
|
+
* botón nunca aparece. Aquí lo capturamos a nivel de módulo, en import.
|
|
12
|
+
* 2. iOS/Safari NO dispara `beforeinstallprompt` y no hay API de instalación:
|
|
13
|
+
* la única vía es "Compartir → Añadir a pantalla de inicio". Mostramos esas
|
|
14
|
+
* instrucciones en un modal propio (Shadow DOM), NUNCA con alert() — el
|
|
15
|
+
* ecosistema prohíbe alert/confirm/prompt del navegador.
|
|
16
|
+
* 3. No mostrar el botón si la app ya corre instalada (display-mode standalone)
|
|
17
|
+
* ni reaparecerlo tras `appinstalled`.
|
|
18
|
+
*
|
|
19
|
+
* Filosofía Dotrino: sin JS de terceros, sin cookies, autohosteado,
|
|
20
|
+
* bilingüe es/en (español neutro, tuteo).
|
|
21
|
+
*
|
|
22
|
+
* Uso (vanilla o Vue) — Web Component:
|
|
23
|
+
* import '@dotrino/install' // registra el custom element
|
|
24
|
+
* <dotrino-install></dotrino-install>
|
|
25
|
+
* <dotrino-install lang="en" label="Install"></dotrino-install>
|
|
26
|
+
*
|
|
27
|
+
* Uso programático (si querés tu propio botón) — ver también ./vue:
|
|
28
|
+
* import { canInstall, promptInstall, onInstallStateChange } from '@dotrino/install'
|
|
29
|
+
* if (canInstall()) showMyButton()
|
|
30
|
+
* await promptInstall() // dispara el prompt nativo o el modal iOS/fallback
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const HOME_DEFAULT = 'https://dotrino.com'
|
|
34
|
+
|
|
35
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
Estado singleton a nivel de módulo.
|
|
37
|
+
Capturamos `beforeinstallprompt`/`appinstalled` UNA sola vez, en import, para
|
|
38
|
+
no perder el evento temprano. Todos los <dotrino-install> y cualquier
|
|
39
|
+
código de la app leen de aquí y se suscriben a los cambios.
|
|
40
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
let _deferred = null // el BeforeInstallPromptEvent diferido (o null)
|
|
43
|
+
let _installed = false // se marcó appinstalled en esta sesión
|
|
44
|
+
const _subs = new Set() // suscriptores a cambios de estado (re-render)
|
|
45
|
+
|
|
46
|
+
function _emit () {
|
|
47
|
+
for (const fn of _subs) {
|
|
48
|
+
try { fn() } catch (_) {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _onBIP (e) {
|
|
53
|
+
// Evita que el navegador muestre su mini-infobar; nosotros decidimos cuándo.
|
|
54
|
+
try { e.preventDefault() } catch (_) {}
|
|
55
|
+
_deferred = e
|
|
56
|
+
_emit()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _onInstalled () {
|
|
60
|
+
_deferred = null
|
|
61
|
+
_installed = true
|
|
62
|
+
_emit()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof window !== 'undefined') {
|
|
66
|
+
try {
|
|
67
|
+
window.addEventListener('beforeinstallprompt', _onBIP)
|
|
68
|
+
window.addEventListener('appinstalled', _onInstalled)
|
|
69
|
+
} catch (_) {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ¿La app ya corre como instalada (standalone) o se instaló en esta sesión? */
|
|
73
|
+
export function isAppInstalled () {
|
|
74
|
+
if (_installed) return true
|
|
75
|
+
try {
|
|
76
|
+
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) return true
|
|
77
|
+
// iOS Safari expone navigator.standalone (no es estándar).
|
|
78
|
+
if (window.navigator && window.navigator.standalone === true) return true
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Detección de iOS/iPadOS (incluye el iPad que se hace pasar por Mac). */
|
|
84
|
+
export function isIOS () {
|
|
85
|
+
try {
|
|
86
|
+
const ua = navigator.userAgent || ''
|
|
87
|
+
if (/iPad|iPhone|iPod/.test(ua) && !window.MSStream) return true
|
|
88
|
+
// iPadOS 13+ se reporta como "Macintosh"; lo delatan los eventos táctiles.
|
|
89
|
+
if (ua.includes('Macintosh') && typeof document !== 'undefined' && 'ontouchend' in document) return true
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ¿Tiene sentido ofrecer instalar? true si hay prompt nativo disponible, o si es
|
|
96
|
+
* iOS (instalable a mano vía Compartir), siempre que NO esté ya instalada.
|
|
97
|
+
*/
|
|
98
|
+
export function canInstall () {
|
|
99
|
+
if (isAppInstalled()) return false
|
|
100
|
+
return !!_deferred || isIOS()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** ¿Hay un prompt nativo (Chromium) listo para dispararse sin instrucciones? */
|
|
104
|
+
export function hasNativePrompt () {
|
|
105
|
+
return !!_deferred
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Suscribe un callback a los cambios de estado de instalación
|
|
110
|
+
* (llega prompt, se instala, etc.). Devuelve la función para desuscribir.
|
|
111
|
+
*/
|
|
112
|
+
export function onInstallStateChange (fn) {
|
|
113
|
+
_subs.add(fn)
|
|
114
|
+
return () => _subs.delete(fn)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Dispara la instalación.
|
|
119
|
+
* - Si hay prompt nativo: lo lanza y devuelve 'accepted' | 'dismissed'.
|
|
120
|
+
* - Si no (iOS / navegador sin soporte): devuelve 'instructions' para que el
|
|
121
|
+
* llamador muestre las instrucciones. El Web Component lo hace solo.
|
|
122
|
+
* @returns {Promise<'accepted'|'dismissed'|'instructions'|'installed'>}
|
|
123
|
+
*/
|
|
124
|
+
export async function promptInstall () {
|
|
125
|
+
if (isAppInstalled()) return 'installed'
|
|
126
|
+
if (_deferred) {
|
|
127
|
+
const evt = _deferred
|
|
128
|
+
try {
|
|
129
|
+
evt.prompt()
|
|
130
|
+
const choice = await evt.userChoice
|
|
131
|
+
_deferred = null
|
|
132
|
+
_emit()
|
|
133
|
+
return (choice && choice.outcome) || 'dismissed'
|
|
134
|
+
} catch (_) {
|
|
135
|
+
_deferred = null
|
|
136
|
+
_emit()
|
|
137
|
+
return 'dismissed'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return 'instructions'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
i18n
|
|
145
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
146
|
+
|
|
147
|
+
const I18N = {
|
|
148
|
+
es: {
|
|
149
|
+
install: 'Instalar App',
|
|
150
|
+
title: 'Instalar la app',
|
|
151
|
+
iosIntro: 'Para instalar esta app en tu iPhone o iPad:',
|
|
152
|
+
iosStep1: 'Pulsa el botón Compartir',
|
|
153
|
+
iosStep2: 'Elige «Añadir a pantalla de inicio»',
|
|
154
|
+
otherIntro: 'Tu navegador no permite la instalación con un toque. Para instalarla:',
|
|
155
|
+
otherStep: 'Abre el menú del navegador y elige «Instalar app» (o «Añadir a pantalla de inicio»).',
|
|
156
|
+
close: 'Cerrar'
|
|
157
|
+
},
|
|
158
|
+
en: {
|
|
159
|
+
install: 'Install App',
|
|
160
|
+
title: 'Install the app',
|
|
161
|
+
iosIntro: 'To install this app on your iPhone or iPad:',
|
|
162
|
+
iosStep1: 'Tap the Share button',
|
|
163
|
+
iosStep2: 'Choose “Add to Home Screen”',
|
|
164
|
+
otherIntro: 'Your browser can’t install with one tap. To install it:',
|
|
165
|
+
otherStep: 'Open the browser menu and choose “Install app” (or “Add to Home Screen”).',
|
|
166
|
+
close: 'Close'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveLang (attr) {
|
|
171
|
+
const a = (attr || '').toLowerCase()
|
|
172
|
+
if (a === 'es' || a === 'en') return a
|
|
173
|
+
let doc = 'es'
|
|
174
|
+
try { doc = (document.documentElement.lang || navigator.language || 'es').slice(0, 2) } catch (_) {}
|
|
175
|
+
return doc === 'en' ? 'en' : 'es'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
Iconos (inline SVG, sin assets externos)
|
|
180
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
181
|
+
|
|
182
|
+
// Flecha de descarga "instalar" (consistente con el ⬇ que ya usan varias apps).
|
|
183
|
+
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><path d="M5 21h14"/></svg>'
|
|
184
|
+
|
|
185
|
+
// Icono "Compartir" de iOS (caja con flecha hacia arriba) para las instrucciones.
|
|
186
|
+
const ICON_IOS_SHARE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><polyline points="8 7 12 3 16 7"/><path d="M7 11H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-2"/></svg>'
|
|
187
|
+
|
|
188
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
Web Component: <dotrino-install>
|
|
190
|
+
Botón de instalar para el header de cualquier app. Se oculta solo si ya está
|
|
191
|
+
instalada o si no hay forma de instalar. En iOS abre un modal con instrucciones.
|
|
192
|
+
|
|
193
|
+
Atributos:
|
|
194
|
+
lang "es" | "en" (default: <html lang> / navigator)
|
|
195
|
+
label texto del botón (default i18n "Instalar"/"Install")
|
|
196
|
+
icon "false" para ocultar el icono y dejar solo texto
|
|
197
|
+
Custom properties (estilo):
|
|
198
|
+
--cc-install-color, --cc-install-bg, --cc-install-bg-hover, --cc-install-radius,
|
|
199
|
+
--cc-install-pad, --cc-install-gap, --cc-install-font-size, --cc-install-icon,
|
|
200
|
+
--cc-install-focus
|
|
201
|
+
Parts: button, icon, label, modal, modal-card
|
|
202
|
+
Eventos: cc-install (cancelable, antes de actuar), cc-install-result (detail.outcome)
|
|
203
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
204
|
+
|
|
205
|
+
const STYLE = `
|
|
206
|
+
:host { all: initial; display: inline-flex; vertical-align: middle; font-family: inherit; }
|
|
207
|
+
:host([hidden]) { display: none; }
|
|
208
|
+
button.trigger {
|
|
209
|
+
all: unset;
|
|
210
|
+
box-sizing: border-box;
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
gap: var(--cc-install-gap, 6px);
|
|
215
|
+
padding: var(--cc-install-pad, 7px 12px);
|
|
216
|
+
border-radius: var(--cc-install-radius, 12px);
|
|
217
|
+
color: var(--cc-install-color, currentColor);
|
|
218
|
+
background: var(--cc-install-bg, transparent);
|
|
219
|
+
font-size: var(--cc-install-font-size, .95em);
|
|
220
|
+
font-weight: 600;
|
|
221
|
+
line-height: 1;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
transition: background .15s ease, transform .1s ease, opacity .15s ease;
|
|
224
|
+
-webkit-tap-highlight-color: transparent;
|
|
225
|
+
}
|
|
226
|
+
button.trigger:hover { background: var(--cc-install-bg-hover, rgba(127,127,127,.16)); }
|
|
227
|
+
button.trigger:active { transform: scale(.96); }
|
|
228
|
+
button.trigger:focus-visible { outline: 2px solid var(--cc-install-focus, currentColor); outline-offset: 2px; }
|
|
229
|
+
.ico { display: inline-flex; }
|
|
230
|
+
.ico svg { width: var(--cc-install-icon, 18px); height: var(--cc-install-icon, 18px); display: block; }
|
|
231
|
+
.lbl:empty { display: none; }
|
|
232
|
+
|
|
233
|
+
/* Modal de instrucciones (iOS / fallback). */
|
|
234
|
+
.backdrop {
|
|
235
|
+
position: fixed; inset: 0; z-index: 2147483600;
|
|
236
|
+
display: flex; align-items: center; justify-content: center;
|
|
237
|
+
padding: 16px; box-sizing: border-box;
|
|
238
|
+
background: rgba(0,0,0,.5);
|
|
239
|
+
-webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
|
|
240
|
+
}
|
|
241
|
+
.card {
|
|
242
|
+
box-sizing: border-box;
|
|
243
|
+
width: 100%; max-width: 360px;
|
|
244
|
+
background: var(--cc-install-modal-bg, #fff);
|
|
245
|
+
color: var(--cc-install-modal-color, #14110f);
|
|
246
|
+
border-radius: 18px;
|
|
247
|
+
padding: 22px 20px 18px;
|
|
248
|
+
box-shadow: 0 20px 60px rgba(0,0,0,.35);
|
|
249
|
+
font-size: 15px; line-height: 1.5;
|
|
250
|
+
font-family: inherit;
|
|
251
|
+
}
|
|
252
|
+
@media (prefers-color-scheme: dark) {
|
|
253
|
+
.card {
|
|
254
|
+
background: var(--cc-install-modal-bg, #1c1917);
|
|
255
|
+
color: var(--cc-install-modal-color, #f5f3f0);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
.card h2 { margin: 0 0 10px; font-size: 18px; font-weight: 700; }
|
|
259
|
+
.card p { margin: 0 0 12px; }
|
|
260
|
+
.steps { list-style: none; margin: 0 0 16px; padding: 0; display: grid; gap: 10px; }
|
|
261
|
+
.steps li { display: flex; align-items: center; gap: 10px; }
|
|
262
|
+
.steps .n {
|
|
263
|
+
flex: none; width: 24px; height: 24px; border-radius: 50%;
|
|
264
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
265
|
+
font-size: 13px; font-weight: 700;
|
|
266
|
+
background: var(--cc-install-accent, #84cc16); color: #14110f;
|
|
267
|
+
}
|
|
268
|
+
.steps svg { width: 20px; height: 20px; flex: none; }
|
|
269
|
+
.card .ok {
|
|
270
|
+
all: unset; box-sizing: border-box; cursor: pointer;
|
|
271
|
+
display: block; width: 100%; text-align: center;
|
|
272
|
+
padding: 11px; border-radius: 12px; font-weight: 700;
|
|
273
|
+
background: var(--cc-install-accent, #84cc16); color: #14110f;
|
|
274
|
+
}
|
|
275
|
+
.card .ok:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
276
|
+
`
|
|
277
|
+
|
|
278
|
+
class DotrinoInstall extends HTMLElement {
|
|
279
|
+
static get observedAttributes () { return ['lang', 'label', 'icon'] }
|
|
280
|
+
|
|
281
|
+
constructor () {
|
|
282
|
+
super()
|
|
283
|
+
this.attachShadow({ mode: 'open' })
|
|
284
|
+
this._modalOpen = false
|
|
285
|
+
this._unsub = null
|
|
286
|
+
this._onState = this._render.bind(this)
|
|
287
|
+
this._onKey = this._onKey.bind(this)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
connectedCallback () {
|
|
291
|
+
this._unsub = onInstallStateChange(this._onState)
|
|
292
|
+
this._render()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
disconnectedCallback () {
|
|
296
|
+
if (this._unsub) { this._unsub(); this._unsub = null }
|
|
297
|
+
try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
attributeChangedCallback () {
|
|
301
|
+
if (this.shadowRoot) this._render()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_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
|
+
}
|
|
312
|
+
|
|
313
|
+
const lang = resolveLang(this.getAttribute('lang'))
|
|
314
|
+
const t = I18N[lang]
|
|
315
|
+
const label = this.getAttribute('label') != null ? this.getAttribute('label') : t.install
|
|
316
|
+
const showIcon = (this.getAttribute('icon') || '').toLowerCase() !== 'false'
|
|
317
|
+
|
|
318
|
+
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
|
+
|
|
325
|
+
this.shadowRoot.innerHTML = html
|
|
326
|
+
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)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_modalHTML (lang) {
|
|
339
|
+
const t = I18N[lang]
|
|
340
|
+
let steps
|
|
341
|
+
if (isIOS()) {
|
|
342
|
+
steps = `<p>${t.iosIntro}</p><ul class="steps">` +
|
|
343
|
+
`<li><span class="n">1</span><span>${t.iosStep1}</span>${ICON_IOS_SHARE}</li>` +
|
|
344
|
+
`<li><span class="n">2</span><span>${t.iosStep2}</span></li></ul>`
|
|
345
|
+
} else {
|
|
346
|
+
steps = `<p>${t.otherIntro}</p><ul class="steps"><li><span class="n">1</span><span>${t.otherStep}</span></li></ul>`
|
|
347
|
+
}
|
|
348
|
+
return `<div class="backdrop" part="modal" role="dialog" aria-modal="true" aria-label="${t.title}">` +
|
|
349
|
+
`<div class="card" part="modal-card"><h2>${t.title}</h2>${steps}` +
|
|
350
|
+
`<button class="ok" type="button">${t.close}</button></div></div>`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async _activate () {
|
|
354
|
+
const ev = new CustomEvent('cc-install', { bubbles: true, composed: true, cancelable: true })
|
|
355
|
+
if (!this.dispatchEvent(ev)) return // la app canceló para hacer lo suyo
|
|
356
|
+
|
|
357
|
+
const outcome = await promptInstall()
|
|
358
|
+
if (outcome === 'instructions') {
|
|
359
|
+
this._openModal()
|
|
360
|
+
}
|
|
361
|
+
this.dispatchEvent(new CustomEvent('cc-install-result', {
|
|
362
|
+
bubbles: true, composed: true, detail: { outcome }
|
|
363
|
+
}))
|
|
364
|
+
this._render()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_openModal () {
|
|
368
|
+
this._modalOpen = true
|
|
369
|
+
try { document.addEventListener('keydown', this._onKey) } catch (_) {}
|
|
370
|
+
this._render()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_closeModal () {
|
|
374
|
+
this._modalOpen = false
|
|
375
|
+
try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
|
|
376
|
+
this._render()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
_onKey (e) {
|
|
380
|
+
if (e.key === 'Escape') this._closeModal()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Dispara la instalación desde JS de la app (igual que el click). */
|
|
384
|
+
install () { return this._activate() }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (typeof customElements !== 'undefined' && !customElements.get('dotrino-install')) {
|
|
388
|
+
customElements.define('dotrino-install', DotrinoInstall)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { DotrinoInstall, HOME_DEFAULT }
|
package/src/vue.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { InstallOutcome } from './index'
|
|
3
|
+
|
|
4
|
+
export interface UseInstall {
|
|
5
|
+
/** hay forma de instalar y la app no está instalada. */
|
|
6
|
+
canInstall: Ref<boolean>
|
|
7
|
+
/** ya corre instalada (standalone). */
|
|
8
|
+
isInstalled: Ref<boolean>
|
|
9
|
+
/** hay prompt nativo (Chromium) listo, sin instrucciones. */
|
|
10
|
+
hasNativePrompt: Ref<boolean>
|
|
11
|
+
/** true si la plataforma es iOS (instalación manual vía Compartir). */
|
|
12
|
+
isIOS(): boolean
|
|
13
|
+
/** Dispara la instalación; resuelve el desenlace. */
|
|
14
|
+
install(): Promise<InstallOutcome>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Composable Vue 3: lógica compartida de instalación PWA para apps que usan su
|
|
19
|
+
* propio botón. Para el botón ya hecho, usá el Web Component <dotrino-install>.
|
|
20
|
+
*/
|
|
21
|
+
export function useInstall(): UseInstall
|
package/src/vue.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/install/vue
|
|
3
|
+
*
|
|
4
|
+
* Helper opcional para apps Vue 3 que prefieren su PROPIO botón (estilo a medida)
|
|
5
|
+
* pero quieren la lógica compartida de instalación: captura temprana del prompt,
|
|
6
|
+
* detección de iOS/standalone y modal de instrucciones sin alert().
|
|
7
|
+
*
|
|
8
|
+
* La mayoría de apps deberían usar directamente el Web Component
|
|
9
|
+
* <dotrino-install> (funciona en Vue tras importar el paquete). Usá este
|
|
10
|
+
* composable solo si necesitás integrar el botón en tu propio markup reactivo.
|
|
11
|
+
*
|
|
12
|
+
* import { useInstall } from '@dotrino/install/vue'
|
|
13
|
+
* const { canInstall, isInstalled, install } = useInstall()
|
|
14
|
+
* // <button v-if="canInstall" @click="install">Instalar</button>
|
|
15
|
+
*
|
|
16
|
+
* `install()` dispara el prompt nativo si existe; si no (iOS / sin soporte)
|
|
17
|
+
* devuelve 'instructions'. Si querés el modal de instrucciones ya hecho, usá el
|
|
18
|
+
* Web Component; el composable deja esa decisión a tu UI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
22
|
+
import {
|
|
23
|
+
canInstall as _canInstall,
|
|
24
|
+
isAppInstalled,
|
|
25
|
+
isIOS,
|
|
26
|
+
hasNativePrompt,
|
|
27
|
+
promptInstall,
|
|
28
|
+
onInstallStateChange
|
|
29
|
+
} from './index.js'
|
|
30
|
+
|
|
31
|
+
export function useInstall () {
|
|
32
|
+
const canInstall = ref(false)
|
|
33
|
+
const isInstalled = ref(false)
|
|
34
|
+
const native = ref(false)
|
|
35
|
+
let unsub = null
|
|
36
|
+
|
|
37
|
+
const sync = () => {
|
|
38
|
+
canInstall.value = _canInstall()
|
|
39
|
+
isInstalled.value = isAppInstalled()
|
|
40
|
+
native.value = hasNativePrompt()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onMounted(() => {
|
|
44
|
+
sync()
|
|
45
|
+
unsub = onInstallStateChange(sync)
|
|
46
|
+
})
|
|
47
|
+
onUnmounted(() => { if (unsub) { unsub(); unsub = null } })
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/** ref<boolean>: hay forma de instalar y no está instalada. */
|
|
51
|
+
canInstall,
|
|
52
|
+
/** ref<boolean>: ya corre instalada (standalone). */
|
|
53
|
+
isInstalled,
|
|
54
|
+
/** ref<boolean>: hay prompt nativo (Chromium) listo, sin instrucciones. */
|
|
55
|
+
hasNativePrompt: native,
|
|
56
|
+
/** true si la plataforma es iOS (instalación manual vía Compartir). */
|
|
57
|
+
isIOS,
|
|
58
|
+
/** Dispara la instalación; resuelve 'accepted'|'dismissed'|'instructions'|'installed'. */
|
|
59
|
+
install: () => promptInstall()
|
|
60
|
+
}
|
|
61
|
+
}
|