diff --git a/components/settings/InstallAppSection.tsx b/components/settings/InstallAppSection.tsx new file mode 100644 index 00000000..2223d458 --- /dev/null +++ b/components/settings/InstallAppSection.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useEffect, useState, useSyncExternalStore } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { MonitorDown } from 'lucide-react' + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +function subscribeDisplayMode(onChange: () => void) { + const mql = window.matchMedia('(display-mode: standalone)') + mql.addEventListener('change', onChange) + return () => mql.removeEventListener('change', onChange) +} + +function getIsStandalone() { + return ( + window.matchMedia('(display-mode: standalone)').matches || + // iOS Safari reports installed mode via navigator.standalone instead + (navigator as Navigator & { standalone?: boolean }).standalone === true + ) +} + +/** + * "Install as app" section for account settings. Chromium fires + * beforeinstallprompt when the PWA is installable; we capture it and offer a + * real install button. Other browsers get per-platform instructions. Hidden + * entirely when already running standalone (installed) or after installing. + */ +export function InstallAppSection() { + const t = useTranslations('settings') + // Server snapshot says standalone so the section is absent from server HTML + // and only appears client-side when actually running in a browser tab. + const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true) + const [installed, setInstalled] = useState(false) + const [installPrompt, setInstallPrompt] = useState(null) + + useEffect(() => { + const onBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + const onInstalled = () => { + setInstallPrompt(null) + setInstalled(true) + } + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt) + window.addEventListener('appinstalled', onInstalled) + return () => { + window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt) + window.removeEventListener('appinstalled', onInstalled) + } + }, []) + + async function handleInstall() { + if (!installPrompt) return + await installPrompt.prompt() + const choice = await installPrompt.userChoice + if (choice.outcome === 'accepted') setInstallPrompt(null) + } + + if (isStandalone || installed) return null + + const ua = navigator.userAgent + const isIos = /iPad|iPhone|iPod/.test(ua) + const isSafari = /Safari/.test(ua) && !/Chrome|Chromium|Edg|CriOS/.test(ua) + const hint = isIos + ? t('install_app_hint_ios') + : isSafari + ? t('install_app_hint_safari') + : t('install_app_hint_generic') + + return ( +
+

+ {t('install_app_title')} +

+
+

+ {installPrompt ? t('install_app_description') : hint} +

+ {installPrompt && ( + + )} +
+
+ ) +} diff --git a/components/settings/sections/AccountSettingsContent.tsx b/components/settings/sections/AccountSettingsContent.tsx index c7feda97..f8d02c65 100644 --- a/components/settings/sections/AccountSettingsContent.tsx +++ b/components/settings/sections/AccountSettingsContent.tsx @@ -11,6 +11,7 @@ import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-reac import { useTheme } from 'next-themes' import { createClient } from '@/lib/supabase/client' import { SecuritySettings } from '@/components/settings/SecuritySettings' +import { InstallAppSection } from '@/components/settings/InstallAppSection' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' import { AccountDangerZone } from '@/components/settings/AccountDangerZone' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' @@ -205,6 +206,9 @@ export function AccountSettingsContent() { + {/* Install as app: renders nothing when already running installed */} + + {/* Security */}
diff --git a/messages/en.json b/messages/en.json index 5c04dac7..dadd6276 100644 --- a/messages/en.json +++ b/messages/en.json @@ -373,6 +373,12 @@ "language_description": "Choose the language for the interface. Invoices and reports always stay in Swedish.", "language_saved": "Language updated", "language_save_failed": "Could not save language preference", + "install_app_title": "Install as an app", + "install_app_description": "Use Accounted in its own window with its own icon in the Dock or taskbar.", + "install_app_button": "Install", + "install_app_hint_ios": "Open the share menu in Safari and choose \"Add to Home Screen\" to install Accounted as an app.", + "install_app_hint_safari": "In Safari, choose File and then \"Add to Dock\" to install Accounted as an app.", + "install_app_hint_generic": "Install Accounted as an app via the install icon in your browser's address bar (Chrome and Edge).", "legal_title": "Privacy & agreements", "legal_privacy": "Privacy policy", "legal_dpa": "Data Processing Agreement (DPA)" diff --git a/messages/sv.json b/messages/sv.json index e023bb24..ef04fff7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -373,6 +373,12 @@ "language_description": "Välj språket för gränssnittet. Fakturor och rapporter förblir alltid på svenska.", "language_saved": "Språket har uppdaterats", "language_save_failed": "Kunde inte spara språkinställningen", + "install_app_title": "Installera som app", + "install_app_description": "Använd Accounted i ett eget fönster med egen ikon i Dock eller aktivitetsfältet.", + "install_app_button": "Installera", + "install_app_hint_ios": "Öppna delningsmenyn i Safari och välj \"Lägg till på hemskärmen\" för att installera Accounted som app.", + "install_app_hint_safari": "Välj Arkiv och sedan \"Lägg till i Dock\" i Safari för att installera Accounted som app.", + "install_app_hint_generic": "Installera Accounted som app via installationsikonen i webbläsarens adressfält (Chrome och Edge).", "legal_title": "Sekretess och avtal", "legal_privacy": "Integritetspolicy", "legal_dpa": "Personuppgiftsbiträdesavtal (DPA)"