فهرست منبع

add pwa install in settings (#251)

Bernt Christian Egeland 1 ماه پیش
والد
کامیت
5f366b7a23

+ 2 - 1
messages/de/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Manuell eingeben",
     "enterBarcode": "Barcode eingeben…",
     "scanHint": "Richten Sie die Kamera auf einen Barcode oder geben Sie ihn manuell ein",
-    "iosInstallHint": "Auf Teilen > Zum Home-Bildschirm tippen"
+    "iosInstallHint": "Auf Teilen > Zum Home-Bildschirm tippen",
+    "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff"
   }
 }

+ 13 - 0
messages/de/settings.json

@@ -103,6 +103,10 @@
         "title": "Daten",
         "description": "Export & Import-Backup"
       },
+      "install": {
+        "title": "App installieren",
+        "description": "Torqvoice auf diesem Gerät hinzufügen"
+      },
       "license": {
         "title": "Lizenz",
         "description": "White-Label-Lizenz"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Support kontaktieren",
     "contactHint": "Öffnet das Supportformular direkt.",
     "contactAction": "Formular öffnen"
+  },
+  "install": {
+    "title": "Torqvoice installieren",
+    "description": "Installieren Sie Torqvoice auf diesem Gerät, um es im Vollbild vom Home-Bildschirm aus zu öffnen, ohne Browserleiste. Es bleibt dieselbe App mit denselben Daten.",
+    "installed": "Torqvoice ist auf diesem Gerät installiert.",
+    "iosHint": "Tippen Sie in Safari auf Teilen und dann auf „Zum Home-Bildschirm“.",
+    "unavailable": "Dieser Browser hat keine Installation angeboten. Öffnen Sie das Browsermenü und suchen Sie nach „App installieren“ oder „Zum Home-Bildschirm“.",
+    "bannerHidden": "Das Installations-Banner ist auf diesem Gerät ausgeblendet.",
+    "showBanner": "Banner wieder anzeigen"
   }
 }

+ 2 - 1
messages/en/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Enter manually",
     "enterBarcode": "Enter barcode...",
     "scanHint": "Point your camera at a barcode or enter it manually",
-    "iosInstallHint": "Tap Share > Add to Home Screen"
+    "iosInstallHint": "Tap Share > Add to Home Screen",
+    "installDescription": "Add to your home screen for quick access"
   }
 }

+ 13 - 0
messages/en/settings.json

@@ -103,6 +103,10 @@
         "title": "Data",
         "description": "Export & import backup"
       },
+      "install": {
+        "title": "Install app",
+        "description": "Add Torqvoice to this device"
+      },
       "license": {
         "title": "License",
         "description": "White-label license"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Contact support",
     "contactHint": "Open the support form straight away.",
     "contactAction": "Open form"
+  },
+  "install": {
+    "title": "Install Torqvoice",
+    "description": "Install Torqvoice on this device to open it from the home screen, full screen and without the browser bar. It stays the same app and the same data.",
+    "installed": "Torqvoice is installed on this device.",
+    "iosHint": "In Safari, tap the Share button, then \"Add to Home Screen\".",
+    "unavailable": "This browser has not offered an install prompt. Open the browser menu and look for \"Install app\" or \"Add to Home Screen\".",
+    "bannerHidden": "The install banner is hidden on this device.",
+    "showBanner": "Show banner again"
   }
 }

+ 2 - 1
messages/es/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Introducir manualmente",
     "enterBarcode": "Introducir código de barras…",
     "scanHint": "Apunte la cámara a un código de barras o introdúzcalo manualmente",
-    "iosInstallHint": "Toque Compartir > Añadir a inicio"
+    "iosInstallHint": "Toque Compartir > Añadir a inicio",
+    "installDescription": "Añádela a tu pantalla de inicio para acceder rápido"
   }
 }

+ 13 - 0
messages/es/settings.json

@@ -103,6 +103,10 @@
         "title": "Datos",
         "description": "Exportar e importar respaldo"
       },
+      "install": {
+        "title": "Instalar app",
+        "description": "Añade Torqvoice a este dispositivo"
+      },
       "license": {
         "title": "Licencia",
         "description": "Licencia marca blanca"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Contactar con soporte",
     "contactHint": "Abrir el formulario de soporte directamente.",
     "contactAction": "Abrir formulario"
+  },
+  "install": {
+    "title": "Instalar Torqvoice",
+    "description": "Instala Torqvoice en este dispositivo para abrirlo desde la pantalla de inicio, a pantalla completa y sin la barra del navegador. Es la misma app y los mismos datos.",
+    "installed": "Torqvoice está instalada en este dispositivo.",
+    "iosHint": "En Safari, toca el botón Compartir y luego «Añadir a pantalla de inicio».",
+    "unavailable": "Este navegador no ha ofrecido la instalación. Abre el menú del navegador y busca «Instalar app» o «Añadir a pantalla de inicio».",
+    "bannerHidden": "El aviso de instalación está oculto en este dispositivo.",
+    "showBanner": "Mostrar el aviso de nuevo"
   }
 }

+ 2 - 1
messages/fr/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Saisir manuellement",
     "enterBarcode": "Saisir un code-barres…",
     "scanHint": "Pointez la caméra vers un code-barres ou saisissez-le manuellement",
-    "iosInstallHint": "Touchez Partager > Sur l'écran d'accueil"
+    "iosInstallHint": "Touchez Partager > Sur l'écran d'accueil",
+    "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide"
   }
 }

+ 13 - 0
messages/fr/settings.json

@@ -103,6 +103,10 @@
         "title": "Données",
         "description": "Exporter et importer une sauvegarde"
       },
+      "install": {
+        "title": "Installer l'application",
+        "description": "Ajoutez Torqvoice à cet appareil"
+      },
       "license": {
         "title": "Licence",
         "description": "Licence marque blanche"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Contacter l'assistance",
     "contactHint": "Ouvrir directement le formulaire d'assistance.",
     "contactAction": "Ouvrir le formulaire"
+  },
+  "install": {
+    "title": "Installer Torqvoice",
+    "description": "Installez Torqvoice sur cet appareil pour l'ouvrir depuis l'écran d'accueil, en plein écran et sans la barre du navigateur. C'est la même application et les mêmes données.",
+    "installed": "Torqvoice est installée sur cet appareil.",
+    "iosHint": "Dans Safari, touchez le bouton Partager, puis « Sur l'écran d'accueil ».",
+    "unavailable": "Ce navigateur n'a pas proposé d'installation. Ouvrez le menu du navigateur et cherchez « Installer l'application » ou « Sur l'écran d'accueil ».",
+    "bannerHidden": "La bannière d'installation est masquée sur cet appareil.",
+    "showBanner": "Afficher à nouveau la bannière"
   }
 }

+ 2 - 1
messages/it/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Inserisci manualmente",
     "enterBarcode": "Inserisci codice a barre…",
     "scanHint": "Punta la fotocamera su un codice a barre oppure inseriscilo manualmente",
-    "iosInstallHint": "Tocca Condividi > Aggiungi a Home"
+    "iosInstallHint": "Tocca Condividi > Aggiungi a Home",
+    "installDescription": "Aggiungila alla schermata Home per un accesso rapido"
   }
 }

+ 13 - 0
messages/it/settings.json

@@ -103,6 +103,10 @@
         "title": "Dati",
         "description": "Esportazione e importazione backup"
       },
+      "install": {
+        "title": "Installa app",
+        "description": "Aggiungi Torqvoice a questo dispositivo"
+      },
       "license": {
         "title": "Licenza",
         "description": "Licenza white-label"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Contatta l'assistenza",
     "contactHint": "Apri subito il modulo di assistenza.",
     "contactAction": "Apri il modulo"
+  },
+  "install": {
+    "title": "Installa Torqvoice",
+    "description": "Installa Torqvoice su questo dispositivo per aprirla dalla schermata Home, a schermo intero e senza la barra del browser. Restano la stessa app e gli stessi dati.",
+    "installed": "Torqvoice è installata su questo dispositivo.",
+    "iosHint": "In Safari, tocca il pulsante Condividi e poi «Aggiungi a Home».",
+    "unavailable": "Questo browser non ha proposto l'installazione. Apri il menu del browser e cerca «Installa app» o «Aggiungi a Home».",
+    "bannerHidden": "Il banner di installazione è nascosto su questo dispositivo.",
+    "showBanner": "Mostra di nuovo il banner"
   }
 }

+ 2 - 1
messages/lt/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Įvesti ranka",
     "enterBarcode": "Įveskite brūkšninį kodą…",
     "scanHint": "Nukreipkite kamerą į brūkšninį kodą arba įveskite jį ranka",
-    "iosInstallHint": "Palieskite Bendrinti > Įtraukti į pradžios ekraną"
+    "iosInstallHint": "Palieskite Bendrinti > Įtraukti į pradžios ekraną",
+    "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau"
   }
 }

+ 13 - 0
messages/lt/settings.json

@@ -103,6 +103,10 @@
         "title": "Duomenys",
         "description": "Eksportas ir importas atsarginei kopijai"
       },
+      "install": {
+        "title": "Įdiegti programą",
+        "description": "Pridėkite „Torqvoice“ į šį įrenginį"
+      },
       "license": {
         "title": "Licencija",
         "description": "\"White-label\" licencija"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Susisiekti su pagalba",
     "contactHint": "Iš karto atidaryti pagalbos formą.",
     "contactAction": "Atidaryti formą"
+  },
+  "install": {
+    "title": "Įdiegti „Torqvoice“",
+    "description": "Įdiekite „Torqvoice“ šiame įrenginyje, kad atidarytumėte ją iš pradžios ekrano visame ekrane ir be naršyklės juostos. Tai ta pati programa ir tie patys duomenys.",
+    "installed": "„Torqvoice“ įdiegta šiame įrenginyje.",
+    "iosHint": "„Safari“ palieskite bendrinimo mygtuką, tada „Į pradžios ekraną“.",
+    "unavailable": "Ši naršyklė nepasiūlė įdiegimo. Atidarykite naršyklės meniu ir ieškokite „Įdiegti programą“ arba „Į pradžios ekraną“.",
+    "bannerHidden": "Įdiegimo juosta šiame įrenginyje paslėpta.",
+    "showBanner": "Vėl rodyti juostą"
   }
 }

+ 2 - 1
messages/nb/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Skriv inn manuelt",
     "enterBarcode": "Skriv inn strekkode…",
     "scanHint": "Rett kameraet mot en strekkode, eller skriv den inn manuelt",
-    "iosInstallHint": "Trykk Del > Legg til på Hjem-skjerm"
+    "iosInstallHint": "Trykk Del > Legg til på Hjem-skjerm",
+    "installDescription": "Legg den til på hjem-skjermen for rask tilgang"
   }
 }

+ 13 - 0
messages/nb/settings.json

@@ -103,6 +103,10 @@
         "title": "Data",
         "description": "Eksporter og importer sikkerhetskopi"
       },
+      "install": {
+        "title": "Installer app",
+        "description": "Legg Torqvoice til på denne enheten"
+      },
       "license": {
         "title": "Lisens",
         "description": "White-label-lisens"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Kontakt support",
     "contactHint": "Åpne supportskjemaet med en gang.",
     "contactAction": "Åpne skjema"
+  },
+  "install": {
+    "title": "Installer Torqvoice",
+    "description": "Installer Torqvoice på denne enheten for å åpne den fra hjem-skjermen, i fullskjerm og uten nettleserlinjen. Det er samme app og samme data.",
+    "installed": "Torqvoice er installert på denne enheten.",
+    "iosHint": "I Safari, trykk Del-knappen og velg «Legg til på Hjem-skjerm».",
+    "unavailable": "Denne nettleseren har ikke tilbudt installasjon. Åpne nettlesermenyen og se etter «Installer app» eller «Legg til på Hjem-skjerm».",
+    "bannerHidden": "Installasjonsbanneret er skjult på denne enheten.",
+    "showBanner": "Vis banner igjen"
   }
 }

+ 2 - 1
messages/nl/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Handmatig invoeren",
     "enterBarcode": "Barcode invoeren…",
     "scanHint": "Richt uw camera op een barcode of voer hem handmatig in",
-    "iosInstallHint": "Tik op Deel > Zet op beginscherm"
+    "iosInstallHint": "Tik op Deel > Zet op beginscherm",
+    "installDescription": "Zet de app op je beginscherm voor snelle toegang"
   }
 }

+ 13 - 0
messages/nl/settings.json

@@ -103,6 +103,10 @@
         "title": "Gegevens",
         "description": "Exporteer en importeer back-up"
       },
+      "install": {
+        "title": "App installeren",
+        "description": "Voeg Torqvoice toe aan dit apparaat"
+      },
       "license": {
         "title": "Licentie",
         "description": "White-label licentie"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Contact met support",
     "contactHint": "Open het supportformulier meteen.",
     "contactAction": "Formulier openen"
+  },
+  "install": {
+    "title": "Torqvoice installeren",
+    "description": "Installeer Torqvoice op dit apparaat om de app vanaf het beginscherm te openen, schermvullend en zonder browserbalk. Het blijft dezelfde app met dezelfde gegevens.",
+    "installed": "Torqvoice is op dit apparaat geïnstalleerd.",
+    "iosHint": "Tik in Safari op Deel en daarna op 'Zet op beginscherm'.",
+    "unavailable": "Deze browser heeft geen installatie aangeboden. Open het browsermenu en zoek naar 'App installeren' of 'Zet op beginscherm'.",
+    "bannerHidden": "De installatiebanner is verborgen op dit apparaat.",
+    "showBanner": "Banner weer tonen"
   }
 }

+ 2 - 1
messages/pl/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Wprowadź ręcznie",
     "enterBarcode": "Wprowadź kod kreskowy…",
     "scanHint": "Skieruj aparat na kod kreskowy albo wprowadź go ręcznie",
-    "iosInstallHint": "Dotknij Udostępnij > Do ekranu początkowego"
+    "iosInstallHint": "Dotknij Udostępnij > Do ekranu początkowego",
+    "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp"
   }
 }

+ 13 - 0
messages/pl/settings.json

@@ -103,6 +103,10 @@
         "title": "Dane",
         "description": "Eksport i import kopii zapasowej"
       },
+      "install": {
+        "title": "Zainstaluj aplikację",
+        "description": "Dodaj Torqvoice na tym urządzeniu"
+      },
       "license": {
         "title": "Licencja",
         "description": "Licencja white-label"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Skontaktuj się z pomocą",
     "contactHint": "Otwórz formularz pomocy od razu.",
     "contactAction": "Otwórz formularz"
+  },
+  "install": {
+    "title": "Zainstaluj Torqvoice",
+    "description": "Zainstaluj Torqvoice na tym urządzeniu, aby otwierać ją z ekranu głównego, na pełnym ekranie i bez paska przeglądarki. To ta sama aplikacja i te same dane.",
+    "installed": "Torqvoice jest zainstalowana na tym urządzeniu.",
+    "iosHint": "W Safari dotknij przycisku Udostępnij, a następnie „Do ekranu początkowego”.",
+    "unavailable": "Ta przeglądarka nie zaproponowała instalacji. Otwórz menu przeglądarki i poszukaj „Zainstaluj aplikację” lub „Dodaj do ekranu głównego”.",
+    "bannerHidden": "Baner instalacji jest ukryty na tym urządzeniu.",
+    "showBanner": "Pokaż baner ponownie"
   }
 }

+ 2 - 1
messages/pt-BR/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Digitar manualmente",
     "enterBarcode": "Digitar código de barras…",
     "scanHint": "Aponte a câmera para um código de barras ou digite-o manualmente",
-    "iosInstallHint": "Toque em Compartilhar > Adicionar à Tela de Início"
+    "iosInstallHint": "Toque em Compartilhar > Adicionar à Tela de Início",
+    "installDescription": "Adicione à tela de início para acesso rápido"
   }
 }

+ 13 - 0
messages/pt-BR/settings.json

@@ -103,6 +103,10 @@
         "title": "Dados",
         "description": "Exportar e importar backup"
       },
+      "install": {
+        "title": "Instalar app",
+        "description": "Adicione o Torqvoice a este dispositivo"
+      },
       "license": {
         "title": "Licença",
         "description": "Licença marca branca"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Falar com o suporte",
     "contactHint": "Abrir o formulário de suporte imediatamente.",
     "contactAction": "Abrir formulário"
+  },
+  "install": {
+    "title": "Instalar o Torqvoice",
+    "description": "Instale o Torqvoice neste dispositivo para abri-lo pela tela de início, em tela cheia e sem a barra do navegador. É o mesmo app e os mesmos dados.",
+    "installed": "O Torqvoice está instalado neste dispositivo.",
+    "iosHint": "No Safari, toque no botão Compartilhar e depois em \"Adicionar à Tela de Início\".",
+    "unavailable": "Este navegador não ofereceu a instalação. Abra o menu do navegador e procure \"Instalar app\" ou \"Adicionar à Tela de Início\".",
+    "bannerHidden": "O aviso de instalação está oculto neste dispositivo.",
+    "showBanner": "Mostrar o aviso novamente"
   }
 }

+ 2 - 1
messages/ru/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Ввести вручную",
     "enterBarcode": "Введите штрихкод…",
     "scanHint": "Наведите камеру на штрихкод или введите его вручную",
-    "iosInstallHint": "Нажмите «Поделиться» > «На экран «Домой»»"
+    "iosInstallHint": "Нажмите «Поделиться» > «На экран «Домой»»",
+    "installDescription": "Добавьте на главный экран для быстрого доступа"
   }
 }

+ 13 - 0
messages/ru/settings.json

@@ -103,6 +103,10 @@
         "title": "Данные",
         "description": "Экспорт и импорт резервных копий"
       },
+      "install": {
+        "title": "Установить приложение",
+        "description": "Добавьте Torqvoice на это устройство"
+      },
       "license": {
         "title": "Лицензия",
         "description": "White-label лицензия"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Связаться с поддержкой",
     "contactHint": "Открыть форму поддержки сразу.",
     "contactAction": "Открыть форму"
+  },
+  "install": {
+    "title": "Установить Torqvoice",
+    "description": "Установите Torqvoice на это устройство, чтобы открывать его с главного экрана — во весь экран и без адресной строки. Это то же приложение и те же данные.",
+    "installed": "Torqvoice установлен на этом устройстве.",
+    "iosHint": "В Safari нажмите «Поделиться», затем «На экран «Домой»».",
+    "unavailable": "Этот браузер не предложил установку. Откройте меню браузера и найдите «Установить приложение» или «На экран «Домой»».",
+    "bannerHidden": "Баннер установки скрыт на этом устройстве.",
+    "showBanner": "Показать баннер снова"
   }
 }

+ 2 - 1
messages/tr/common.json

@@ -75,6 +75,7 @@
     "enterManually": "Elle gir",
     "enterBarcode": "Barkod girin…",
     "scanHint": "Kamerayı bir barkoda doğrultun ya da elle girin",
-    "iosInstallHint": "Paylaş > Ana Ekrana Ekle'ye dokunun"
+    "iosInstallHint": "Paylaş > Ana Ekrana Ekle'ye dokunun",
+    "installDescription": "Hızlı erişim için ana ekranınıza ekleyin"
   }
 }

+ 13 - 0
messages/tr/settings.json

@@ -103,6 +103,10 @@
         "title": "Veri",
         "description": "Dışa & içe aktarma yedeği"
       },
+      "install": {
+        "title": "Uygulamayı yükle",
+        "description": "Torqvoice'i bu cihaza ekleyin"
+      },
       "license": {
         "title": "Lisans",
         "description": "Beyaz etiket lisansı"
@@ -1441,5 +1445,14 @@
     "contactTitle": "Destekle iletişime geç",
     "contactHint": "Destek formunu hemen açın.",
     "contactAction": "Formu aç"
+  },
+  "install": {
+    "title": "Torqvoice'i yükle",
+    "description": "Torqvoice'i bu cihaza yükleyin; ana ekrandan, tam ekran ve tarayıcı çubuğu olmadan açılsın. Aynı uygulama ve aynı veriler kalır.",
+    "installed": "Torqvoice bu cihaza yüklü.",
+    "iosHint": "Safari'de Paylaş düğmesine, ardından \"Ana Ekrana Ekle\" seçeneğine dokunun.",
+    "unavailable": "Bu tarayıcı yükleme önermedi. Tarayıcı menüsünü açın ve \"Uygulamayı yükle\" ya da \"Ana Ekrana Ekle\" seçeneğini arayın.",
+    "bannerHidden": "Yükleme başlığı bu cihazda gizli.",
+    "showBanner": "Başlığı tekrar göster"
   }
 }

+ 75 - 0
src/app/(authenticated)/settings/install/install-client.tsx

@@ -0,0 +1,75 @@
+'use client'
+
+import Image from 'next/image'
+import { useTranslations } from 'next-intl'
+import { CheckCircle2, Download, Info } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Separator } from '@/components/ui/separator'
+import { useInstallPrompt } from '@/components/pwa-install-prompt'
+
+export function InstallSettingsClient() {
+  const t = useTranslations('settings.install')
+  const ts = useTranslations('common.shared')
+  const { canInstall, installed, isIOS, dismissed, install, restore } = useInstallPrompt()
+
+  return (
+    <div className="space-y-6">
+      <Card className="border-0 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <Download className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">{t('title')}</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex items-start gap-3">
+            <Image
+              src="/icons/icon-192.png"
+              alt=""
+              width={48}
+              height={48}
+              className="shrink-0 rounded-xl"
+            />
+            <p className="text-sm text-muted-foreground">{t('description')}</p>
+          </div>
+
+          <Separator />
+
+          {installed ? (
+            <p className="flex items-center gap-2 text-sm font-medium text-emerald-600">
+              <CheckCircle2 className="h-4 w-4 shrink-0" />
+              {t('installed')}
+            </p>
+          ) : isIOS ? (
+            <p className="flex items-start gap-2 text-sm text-muted-foreground">
+              <Info className="mt-0.5 h-4 w-4 shrink-0" />
+              {t('iosHint')}
+            </p>
+          ) : canInstall ? (
+            <Button onClick={install} className="w-full sm:w-auto">
+              <Download className="mr-2 h-4 w-4" />
+              {ts('install')}
+            </Button>
+          ) : (
+            <p className="flex items-start gap-2 text-sm text-muted-foreground">
+              <Info className="mt-0.5 h-4 w-4 shrink-0" />
+              {t('unavailable')}
+            </p>
+          )}
+
+          {/* Only worth showing once the banner has actually been dismissed. */}
+          {dismissed && !installed && (
+            <>
+              <Separator />
+              <div className="flex flex-wrap items-center justify-between gap-2">
+                <p className="text-sm text-muted-foreground">{t('bannerHidden')}</p>
+                <Button variant="outline" size="sm" onClick={restore}>
+                  {t('showBanner')}
+                </Button>
+              </div>
+            </>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 5 - 0
src/app/(authenticated)/settings/install/page.tsx

@@ -0,0 +1,5 @@
+import { InstallSettingsClient } from './install-client'
+
+export default function InstallSettingsPage() {
+  return <InstallSettingsClient />
+}

+ 2 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -18,6 +18,7 @@ import {
   CalendarClock,
   CreditCard,
   Database,
+  Download,
   FileText,
   Gauge,
   Globe,
@@ -111,6 +112,7 @@ const settingsCategories: SettingsCategory[] = [
     key: 'system',
     items: [
       { key: 'data', href: '/settings/data', icon: Database },
+      { key: 'install', href: '/settings/install', icon: Download },
       { key: 'license', href: '/settings/license', icon: Key, selfHostedOnly: true },
       { key: 'about', href: '/settings/about', icon: Info },
     ],

+ 94 - 54
src/components/pwa-install-prompt.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useEffect, useState, useCallback } from 'react'
+import { useCallback, useEffect, useReducer, useState } from 'react'
 import Image from 'next/image'
 import { Download, X } from 'lucide-react'
 import { Button } from '@/components/ui/button'
@@ -20,22 +20,37 @@ interface BeforeInstallPromptEvent extends Event {
 const DISMISS_KEY = 'pwa-install-dismissed'
 const DISMISS_DURATION = 30 * 24 * 60 * 60 * 1000 // 30 days
 
-function isDismissed(): boolean {
-  try {
-    const val = localStorage.getItem(DISMISS_KEY)
-    if (!val) return false
-    return Date.now() - Number(val) < DISMISS_DURATION
-  } catch {
-    return false
-  }
+/**
+ * The install state lives outside React. Chrome fires `beforeinstallprompt`
+ * again on later navigations, so a per-component listener would flip the
+ * banner back on after the user dismissed it (the dismissal was only checked
+ * on mount, which is why it took a hard refresh to stay gone). Keeping the
+ * captured event and the subscriber set at module scope also means the banner,
+ * the sidebar entry and the settings page all see the same state and update
+ * together.
+ */
+let promptEvent: BeforeInstallPromptEvent | null = null
+let installed = false
+let listenersBound = false
+const subscribers = new Set<() => void>()
+
+function emit() {
+  for (const notify of subscribers) notify()
 }
 
-function dismiss() {
-  try {
-    localStorage.setItem(DISMISS_KEY, String(Date.now()))
-  } catch {
-    // localStorage unavailable
-  }
+function bindListeners() {
+  if (listenersBound || typeof window === 'undefined') return
+  listenersBound = true
+  window.addEventListener('beforeinstallprompt', (e) => {
+    e.preventDefault()
+    promptEvent = e as BeforeInstallPromptEvent
+    emit()
+  })
+  window.addEventListener('appinstalled', () => {
+    promptEvent = null
+    installed = true
+    emit()
+  })
 }
 
 function isStandalone(): boolean {
@@ -50,54 +65,81 @@ function isIOSSafari(): boolean {
   return /iP(hone|od|ad)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
 }
 
-function useInstallPrompt() {
-  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
-  const [isIOS, setIsIOS] = useState(false)
-  const [canShow, setCanShow] = useState(false)
+function dismissedUntil(): number {
+  try {
+    return Number(localStorage.getItem(DISMISS_KEY)) || 0
+  } catch {
+    return 0
+  }
+}
 
-  useEffect(() => {
-    if (isStandalone() || isDismissed()) return
-    if (process.env.NODE_ENV === 'development') return
+export function useInstallPrompt() {
+  const [, rerender] = useReducer((n: number) => n + 1, 0)
+  // Nothing about the device is known during SSR or the first client render,
+  // so everything below stays false until after hydration.
+  const [ready, setReady] = useState(false)
 
-    if (isIOSSafari()) {
-      setIsIOS(true)
-      setCanShow(true)
-      return
+  useEffect(() => {
+    bindListeners()
+    subscribers.add(rerender)
+    setReady(true)
+    return () => {
+      subscribers.delete(rerender)
     }
+  }, [])
 
-    const handler = (e: Event) => {
-      e.preventDefault()
-      setDeferredPrompt(e as BeforeInstallPromptEvent)
-      setCanShow(true)
-    }
+  const standalone = ready && isStandalone()
+  const isIOS = ready && !standalone && isIOSSafari()
 
-    window.addEventListener('beforeinstallprompt', handler)
-    return () => window.removeEventListener('beforeinstallprompt', handler)
+  const install = useCallback(async () => {
+    if (!promptEvent) return false
+    await promptEvent.prompt()
+    const { outcome } = await promptEvent.userChoice
+    promptEvent = null
+    if (outcome === 'accepted') installed = true
+    emit()
+    return outcome === 'accepted'
   }, [])
 
-  const install = useCallback(async () => {
-    if (!deferredPrompt) return
-    await deferredPrompt.prompt()
-    const { outcome } = await deferredPrompt.userChoice
-    if (outcome === 'accepted') {
-      setCanShow(false)
+  const dismiss = useCallback(() => {
+    try {
+      localStorage.setItem(DISMISS_KEY, String(Date.now()))
+    } catch {
+      // localStorage unavailable; the in-memory flag below still hides it
     }
-    setDeferredPrompt(null)
-  }, [deferredPrompt])
+    emit()
+  }, [])
 
-  const hide = useCallback(() => {
-    dismiss()
-    setCanShow(false)
+  const restore = useCallback(() => {
+    try {
+      localStorage.removeItem(DISMISS_KEY)
+    } catch {
+      // localStorage unavailable
+    }
+    emit()
   }, [])
 
-  return { canShow, isIOS, install, hide }
+  return {
+    /** The browser offered an install prompt, or this is iOS Safari. */
+    canInstall: ready && !standalone && !installed && (promptEvent !== null || isIOS),
+    /** Already running as an installed app, or installed during this session. */
+    installed: standalone || (ready && installed),
+    isIOS,
+    /** True while the user's "not now" is still in effect (30 days). */
+    dismissed: ready && Date.now() - dismissedUntil() < DISMISS_DURATION,
+    install,
+    dismiss,
+    restore,
+  }
 }
 
 export function InstallBanner() {
   const t = useTranslations('common.shared')
-  const { canShow, isIOS, install, hide } = useInstallPrompt()
+  const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
 
-  if (!canShow) return null
+  // The banner is noise while developing; the settings page still works there.
+  if (process.env.NODE_ENV === 'development') return null
+  if (!canInstall || dismissed) return null
 
   return (
     <div className="fixed bottom-0 left-0 right-0 z-50 animate-in slide-in-from-bottom duration-300">
@@ -113,9 +155,7 @@ export function InstallBanner() {
           <div className="min-w-0 flex-1">
             <p className="text-sm font-medium">{t("installTorqvoice")}</p>
             <p className="truncate text-xs text-muted-foreground">
-              {isIOS
-                ? 'Tap Share then "Add to Home Screen"'
-                : 'Add to your home screen for quick access'}
+              {isIOS ? t("iosInstallHint") : t("installDescription")}
             </p>
           </div>
           {!isIOS && (
@@ -124,7 +164,7 @@ export function InstallBanner() {
             </Button>
           )}
           <button
-            onClick={hide}
+            onClick={dismiss}
             className="shrink-0 rounded-full p-1 text-muted-foreground hover:text-foreground"
             aria-label={t("dismiss")}
           >
@@ -138,16 +178,16 @@ export function InstallBanner() {
 
 export function SidebarInstallButton() {
   const t = useTranslations('common.shared')
-  const { canShow, isIOS, install, hide } = useInstallPrompt()
+  const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
 
-  if (!canShow) return null
+  if (!canInstall || dismissed) return null
 
   return (
     <SidebarGroup>
       <SidebarMenu>
         <SidebarMenuItem>
           <SidebarMenuButton
-            onClick={isIOS ? hide : install}
+            onClick={isIOS ? dismiss : install}
             tooltip={isIOS ? t("iosInstallHint") : t("installApp")}
           >
             <Download className="size-4" />