Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
57c8db0970

BIN
public/icons/apple-touch-icon.png


BIN
public/icons/icon-192.png


BIN
public/icons/icon-512.png


+ 2 - 0
public/sw.js

@@ -0,0 +1,2 @@
+self.addEventListener('install', () => self.skipWaiting())
+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))

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

@@ -11,6 +11,7 @@ import { DateSettingsProvider } from "@/components/date-settings-context";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { OnlineTracker } from "@/components/online-tracker";
+import { InstallBanner } from "@/components/pwa-install-prompt";
 import { db } from "@/lib/db";
 
 export default async function DashboardLayout({
@@ -89,6 +90,7 @@ export default async function DashboardLayout({
         <SearchCommand />
         {isOwnerOrAdmin && <NotificationInitializer />}
         <OnlineTracker />
+        <InstallBanner />
       </ConfirmProvider>
       </DateSettingsProvider>
     </SidebarProvider>

+ 12 - 0
src/app/layout.tsx

@@ -7,6 +7,7 @@ import { ThemeProvider } from '@/components/theme-provider'
 import { QueryProvider } from '@/lib/query-provider'
 import { GlassModal } from '@/components/glass-modal'
 import { TooltipProvider } from '@/components/ui/tooltip'
+import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import './globals.css'
 
 const geistSans = Geist({
@@ -21,6 +22,14 @@ const geistMono = Geist_Mono({
 
 export const metadata: Metadata = {
   metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
+  appleWebApp: {
+    capable: true,
+    statusBarStyle: 'black-translucent',
+    title: 'TorqVoice',
+  },
+  other: {
+    'mobile-web-app-capable': 'yes',
+  },
   title: {
     default: 'TorqVoice - Workshop Management Platform',
     template: '%s | TorqVoice',
@@ -75,6 +84,8 @@ export default async function RootLayout({
   return (
     <html lang={locale} suppressHydrationWarning>
       <head>
+        <meta name="theme-color" content="#09090b" />
+        <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
         <script
           dangerouslySetInnerHTML={{
             __html: `(function(){try{var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
@@ -89,6 +100,7 @@ export default async function RootLayout({
                 {children}
                 <GlassModal />
                 <Toaster richColors position="bottom-right" />
+                <PWAServiceWorker />
               </TooltipProvider>
             </QueryProvider>
           </ThemeProvider>

+ 16 - 0
src/app/manifest.ts

@@ -0,0 +1,16 @@
+import type { MetadataRoute } from 'next'
+
+export default function manifest(): MetadataRoute.Manifest {
+  return {
+    name: 'TorqVoice - Workshop Management',
+    short_name: 'TorqVoice',
+    start_url: '/',
+    display: 'standalone',
+    background_color: '#09090b',
+    theme_color: '#09090b',
+    icons: [
+      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
+      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
+    ],
+  }
+}

+ 2 - 0
src/components/app-sidebar.tsx

@@ -68,6 +68,7 @@ import { createNewOrganization } from '@/features/team/Actions/createNewOrganiza
 import type { PlanFeatures } from '@/lib/features'
 import { useTheme } from '@/components/theme-provider'
 import { NotificationBell, NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
+import { SidebarInstallButton } from '@/components/pwa-install-prompt'
 
 type OrgInfo = { id: string; name: string; role: string }
 
@@ -305,6 +306,7 @@ export function AppSidebar({
             </SidebarMenu>
           </SidebarGroup>
         )}
+        <SidebarInstallButton />
       </SidebarContent>
       <SidebarFooter>
         <SidebarMenu>

+ 156 - 0
src/components/pwa-install-prompt.tsx

@@ -0,0 +1,156 @@
+'use client'
+
+import { useEffect, useState, useCallback } from 'react'
+import Image from 'next/image'
+import { Download, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  SidebarGroup,
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+} from '@/components/ui/sidebar'
+
+interface BeforeInstallPromptEvent extends Event {
+  prompt(): Promise<void>
+  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
+}
+
+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
+  }
+}
+
+function dismiss() {
+  try {
+    localStorage.setItem(DISMISS_KEY, String(Date.now()))
+  } catch {
+    // localStorage unavailable
+  }
+}
+
+function isStandalone(): boolean {
+  return (
+    window.matchMedia('(display-mode: standalone)').matches ||
+    ('standalone' in navigator && (navigator as unknown as { standalone: boolean }).standalone)
+  )
+}
+
+function isIOSSafari(): boolean {
+  const ua = navigator.userAgent
+  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)
+
+  useEffect(() => {
+    if (isStandalone() || isDismissed()) return
+
+    if (isIOSSafari()) {
+      setIsIOS(true)
+      setCanShow(true)
+      return
+    }
+
+    const handler = (e: Event) => {
+      e.preventDefault()
+      setDeferredPrompt(e as BeforeInstallPromptEvent)
+      setCanShow(true)
+    }
+
+    window.addEventListener('beforeinstallprompt', handler)
+    return () => window.removeEventListener('beforeinstallprompt', handler)
+  }, [])
+
+  const install = useCallback(async () => {
+    if (!deferredPrompt) return
+    await deferredPrompt.prompt()
+    const { outcome } = await deferredPrompt.userChoice
+    if (outcome === 'accepted') {
+      setCanShow(false)
+    }
+    setDeferredPrompt(null)
+  }, [deferredPrompt])
+
+  const hide = useCallback(() => {
+    dismiss()
+    setCanShow(false)
+  }, [])
+
+  return { canShow, isIOS, install, hide }
+}
+
+export function InstallBanner() {
+  const { canShow, isIOS, install, hide } = useInstallPrompt()
+
+  if (!canShow) return null
+
+  return (
+    <div className="fixed bottom-0 left-0 right-0 z-50 animate-in slide-in-from-bottom duration-300">
+      <div className="border-t bg-background/80 backdrop-blur-lg">
+        <div className="mx-auto flex max-w-xl items-center gap-3 px-4 py-3">
+          <Image
+            src="/icons/icon-192.png"
+            alt="TorqVoice"
+            width={40}
+            height={40}
+            className="shrink-0 rounded-lg"
+          />
+          <div className="min-w-0 flex-1">
+            <p className="text-sm font-medium">Install TorqVoice</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'}
+            </p>
+          </div>
+          {!isIOS && (
+            <Button size="sm" onClick={install}>
+              Install
+            </Button>
+          )}
+          <button
+            onClick={hide}
+            className="shrink-0 rounded-full p-1 text-muted-foreground hover:text-foreground"
+            aria-label="Dismiss"
+          >
+            <X className="size-4" />
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function SidebarInstallButton() {
+  const { canShow, isIOS, install, hide } = useInstallPrompt()
+
+  if (!canShow) return null
+
+  return (
+    <SidebarGroup>
+      <SidebarMenu>
+        <SidebarMenuItem>
+          <SidebarMenuButton
+            onClick={isIOS ? hide : install}
+            tooltip={isIOS ? 'Tap Share > Add to Home Screen' : 'Install App'}
+          >
+            <Download className="size-4" />
+            <span className="font-medium">Install App</span>
+          </SidebarMenuButton>
+        </SidebarMenuItem>
+      </SidebarMenu>
+    </SidebarGroup>
+  )
+}

+ 15 - 0
src/components/pwa-service-worker.tsx

@@ -0,0 +1,15 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export function PWAServiceWorker() {
+  useEffect(() => {
+    if ('serviceWorker' in navigator) {
+      navigator.serviceWorker.register('/sw.js').catch(() => {
+        // Service worker registration failed — ignore silently
+      })
+    }
+  }, [])
+
+  return null
+}