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:
Generated
+4443
-1
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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') },
|
||||
|
||||
Reference in New Issue
Block a user