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:
2026-11-13 23:45:58 +03:00
parent 8ee3672d57
commit 5cbf97d884
14 changed files with 4701 additions and 17 deletions
+4443 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -45,6 +45,8 @@
"prettier": "^3.10.0",
"typescript": "~7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.1.0",
"workbox-window": "^7.4.1"
}
}
@@ -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 />
}
+19
View File
@@ -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,
)
}
+53
View File
@@ -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>
)
}
+8
View File
@@ -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',
+7 -1
View File
@@ -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>,
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/react"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
+69 -1
View File
@@ -2,10 +2,78 @@ import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
// PWA: telefona "Ana ekrana ekle" ile kurulum + uygulama kabuğunun önbelleği.
// Veri (/api) ASLA önbelleğe alınmaz: ekranda her zaman sunucudaki güncel veri olur.
VitePWA({
// Yeni sürüm kendiliğinden devreye girmez; kullanıcıya "Yenile" sorulur (lib/pwa.tsx)
registerType: 'prompt',
injectRegister: false,
manifest: {
name: 'Kakeibo — Ev Defteri',
short_name: 'Kakeibo',
description: 'Kakeibo yöntemiyle hane bazlı ev bütçesi ve harcama defteri',
lang: 'tr',
dir: 'ltr',
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#ffffff',
categories: ['finance', 'productivity'],
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
// Ana ekran ikonuna uzun basınca kısayollar
shortcuts: [
{
name: 'Harcama ekle',
short_name: 'Harcama',
url: '/defter',
icons: [{ src: '/icon-192.png', sizes: '192x192' }],
},
{ name: 'Panel', url: '/', icons: [{ src: '/icon-192.png', sizes: '192x192' }] },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// Nadiren gerekenler önceden indirilmez (ilk kurulum küçük kalsın): Excel okuyucu,
// Türkçede kullanılmayan font alt kümeleri. Gerekirse açılınca önbelleğe girer (aşağıda).
globIgnores: [
'**/xlsx-*.js',
'**/inter-{vietnamese,greek,greek-ext,cyrillic,cyrillic-ext}-*',
],
// Tek sayfa uygulama: gezinmeler index.html'e; /api asla
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
// Hash'li dosya adı → içerik değişmez; önceden alınmamışsa ilk kullanımda saklanır
urlPattern: ({ url }) => url.pathname.startsWith('/assets/'),
handler: 'CacheFirst',
options: { cacheName: 'kakeibo-assets', expiration: { maxEntries: 60 } },
},
],
},
// Geliştirmede service worker yok (hot reload ile çakışmasın)
devOptions: { enabled: false },
}),
],
resolve: {
// "@/lib/format" → src/lib/format
alias: { '@': path.resolve(import.meta.dirname, './src') },