@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 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
+ }