feat(pwa): telefona kurulum, uygulama kabuğu önbelleği, güncelleme bildirimi, çevrimdışı uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { RotateCw, WifiOff } from 'lucide-react'
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { safeReturnPath } from '@/lib/return-path'
|
||||
@@ -14,12 +16,37 @@ export function Splash() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Oturum sorulamadı (bağlantı yok / sunucu yanıt vermiyor): giriş sayfasına ATMA —
|
||||
* kullanıcı çıkış yapılmış sanır. Bağlantı gelince sorgu kendiliğinden yenilenir.
|
||||
*/
|
||||
function SessionUnknown({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center p-6">
|
||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center" role="alert">
|
||||
<Logo size={56} />
|
||||
<WifiOff className="size-6 text-muted-foreground" aria-hidden />
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
<Button onClick={onRetry}>
|
||||
<RotateCw aria-hidden />
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
|
||||
export function RequireAuth() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
|
||||
if (me.isPending && me.fetchStatus === 'paused')
|
||||
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
|
||||
if (me.isPending) return <Splash />
|
||||
if (me.isError)
|
||||
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
|
||||
if (!me.data) {
|
||||
const back = location.pathname + location.search
|
||||
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
|
||||
@@ -36,7 +63,12 @@ export function RequireAuth() {
|
||||
export function RequireGuest() {
|
||||
const me = useMe()
|
||||
const [params] = useSearchParams()
|
||||
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
|
||||
if (me.isPending && me.fetchStatus === 'paused')
|
||||
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
|
||||
if (me.isPending) return <Splash />
|
||||
if (me.isError)
|
||||
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
|
||||
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Tarayıcı çevrimiçi mi? (`online` / `offline` olaylarıyla güncellenir) */
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
window.addEventListener('online', cb)
|
||||
window.addEventListener('offline', cb)
|
||||
return () => {
|
||||
window.removeEventListener('online', cb)
|
||||
window.removeEventListener('offline', cb)
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnline(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => navigator.onLine,
|
||||
() => true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PWA: yeni sürüm bildirimi ve çevrimdışı uyarısı.
|
||||
* Service worker yalnızca uygulama kabuğunu saklar; veri (/api) her zaman sunucudan gelir.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { WifiOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { useOnline } from './online'
|
||||
import { t } from './strings'
|
||||
|
||||
const s = t.pwa
|
||||
/** Uygulama uzun süre açık kalırsa (telefonda sık) yeni sürüm saatte bir yoklanır */
|
||||
const CHECK_EVERY = 60 * 60 * 1000
|
||||
|
||||
/** Yeni sürüm hazır olunca kalıcı bildirim: "Yenile" → yeni service worker devreye girer */
|
||||
export function PwaUpdater() {
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(_url, reg) {
|
||||
if (reg) setInterval(() => void reg.update().catch(() => undefined), CHECK_EVERY)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!needRefresh) return
|
||||
const id = toast(s.updateTitle, {
|
||||
description: s.updateText,
|
||||
duration: Infinity,
|
||||
action: { label: s.reload, onClick: () => void updateServiceWorker(true) },
|
||||
})
|
||||
return () => void toast.dismiss(id)
|
||||
}, [needRefresh, updateServiceWorker])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Bağlantı yokken ekranın üstünde sabit uyarı (kayıtlar sunucuya yazıldığı için girilemez) */
|
||||
export function OfflineBanner() {
|
||||
const online = useOnline()
|
||||
if (online) return null
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed inset-x-0 top-0 z-50 flex items-center justify-center gap-2 bg-warning px-4 py-2 text-sm font-medium text-white shadow print:hidden"
|
||||
>
|
||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||
{s.offline}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -939,6 +939,14 @@ export const t = {
|
||||
savingsUpdated: 'Birikimlerdeki kayıt yeni tutarla güncellendi.',
|
||||
},
|
||||
|
||||
pwa: {
|
||||
updateTitle: 'Yeni sürüm hazır',
|
||||
updateText: 'Değişiklikleri görmek için yenile. Girdiğin kayıtlar kaybolmaz.',
|
||||
reload: 'Yenile',
|
||||
sessionOffline: 'Oturum bilgisi alınamadı. Bağlantı gelince kendiliğinden devam eder.',
|
||||
offline:
|
||||
'Bağlantı yok — veriler gösterilemez ve kayıt girilemez. Bağlantı gelince devam edebilirsin.',
|
||||
},
|
||||
common: {
|
||||
loading: 'Yükleniyor…',
|
||||
retry: 'Tekrar dene',
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { onlineManager, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from 'react-router'
|
||||
import './index.css'
|
||||
import { queryClient } from '@/lib/query'
|
||||
import { Toaster } from '@/components/toaster'
|
||||
import { prefetchMe } from '@/lib/auth'
|
||||
import { OfflineBanner, PwaUpdater } from '@/lib/pwa'
|
||||
import { installPrintTheme } from '@/lib/print'
|
||||
import { ThemeProvider } from '@/lib/theme'
|
||||
import { router } from './router'
|
||||
|
||||
installPrintTheme()
|
||||
// TanStack Query yalnız sonraki online/offline olaylarını dinler; açılıştaki durumu da bilsin
|
||||
// (çevrimdışı açılışta sorgular hemen duraklar → bekleme yerine bağlantı mesajı)
|
||||
onlineManager.setOnline(navigator.onLine)
|
||||
prefetchMe(queryClient)
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
@@ -19,6 +23,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
<PwaUpdater />
|
||||
<OfflineBanner />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
Reference in New Issue
Block a user