feat: add PWA support with manifest and service worker registration
- Added icons for PWA: icon-192x192.png, icon-512x512.png, and maskable-icon.png. - Created manifest.json for PWA configuration. - Integrated PWA registration in app.tsx with update notifications. - Implemented PWA update toast component for user notifications. - Enhanced permissions page with platform-specific denied messages. - Updated app.blade.php to include manifest and meta tags for PWA. - Configured Vite to support PWA with caching strategies for assets and APIs.
This commit is contained in:
parent
aecd8eaf1f
commit
bfad1ae65c
@ -26,7 +26,8 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"typescript-eslint": "^8.23.0"
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@ -73,7 +74,8 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^8.0.0"
|
||||
"vite": "^8.0.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
||||
1940
pnpm-lock.yaml
generated
1940
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/icon-192x192.png
Normal file
BIN
public/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icon-512x512.png
Normal file
BIN
public/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
35
public/manifest.json
Normal file
35
public/manifest.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "DST Collection",
|
||||
"short_name": "DST",
|
||||
"description": "DST Collection - DST Punya Gaya",
|
||||
"theme_color": "#1a1a1a",
|
||||
"background_color": "#1a1a1a",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"business",
|
||||
"shopping"
|
||||
],
|
||||
"lang": "id",
|
||||
"dir": "ltr"
|
||||
}
|
||||
BIN
public/maskable-icon.png
Normal file
BIN
public/maskable-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@ -1,5 +1,7 @@
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { FlashToast } from '@/components/flash-toast';
|
||||
import { PWAUpdateToast } from '@/components/pwa-update-toast';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
@ -9,6 +11,15 @@ import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
registerSW({
|
||||
onNeedRefresh() {
|
||||
window.dispatchEvent(new CustomEvent('sw-update-available'));
|
||||
},
|
||||
onOfflineReady() {
|
||||
window.dispatchEvent(new CustomEvent('sw-offline-ready'));
|
||||
},
|
||||
});
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
layout: (name) => {
|
||||
@ -28,6 +39,7 @@ createInertiaApp({
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<FlashToast />
|
||||
<PWAUpdateToast />
|
||||
{app}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
|
||||
36
resources/js/components/pwa-update-toast.tsx
Normal file
36
resources/js/components/pwa-update-toast.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function PWAUpdateToast() {
|
||||
useEffect(() => {
|
||||
const handleOfflineReady = () => {
|
||||
toast.success('Aplikasi siap offline!', {
|
||||
description: 'Aplikasi dapat digunakan tanpa koneksi internet.',
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateAvailable = () => {
|
||||
toast.info('Update tersedia!', {
|
||||
description: 'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Update',
|
||||
onClick: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('sw-offline-ready', handleOfflineReady);
|
||||
window.addEventListener('sw-update-available', handleUpdateAvailable);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('sw-offline-ready', handleOfflineReady);
|
||||
window.removeEventListener('sw-update-available', handleUpdateAvailable);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -7,11 +7,41 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
const isPWA = () => {
|
||||
return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as { standalone?: boolean }).standalone === true;
|
||||
};
|
||||
|
||||
const getPlatform = (): 'android' | 'ios' | 'desktop' => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (/android/.test(ua)) return 'android';
|
||||
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
|
||||
return 'desktop';
|
||||
};
|
||||
|
||||
const getDeniedMessage = (appName: string): string => {
|
||||
if (!isPWA()) {
|
||||
return 'Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.';
|
||||
}
|
||||
|
||||
const platform = getPlatform();
|
||||
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
return `Izin ditolak. Buka Pengaturan > Aplikasi > ${appName} > Izin untuk mengaktifkannya.`;
|
||||
case 'ios':
|
||||
return `Izin ditolak. Buka Pengaturan > ${appName} > Safari > Izin untuk mengaktifkannya.`;
|
||||
default:
|
||||
return `Izin ditolak. Buka Pengaturan sistem untuk mengaktifkan izin ${appName}.`;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Permissions() {
|
||||
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
|
||||
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
||||
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
||||
|
||||
const appName = 'DST Collection';
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') setNotifications('granted');
|
||||
@ -94,6 +124,7 @@ export default function Permissions() {
|
||||
};
|
||||
|
||||
const isDenied = (status: PermissionStatus) => status === 'denied';
|
||||
const deniedMessage = getDeniedMessage(appName);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -112,7 +143,7 @@ export default function Permissions() {
|
||||
</AlertDescription>
|
||||
{isDenied(notifications) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
||||
{deniedMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -136,7 +167,7 @@ export default function Permissions() {
|
||||
</AlertDescription>
|
||||
{isDenied(camera) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
||||
{deniedMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -160,7 +191,7 @@ export default function Permissions() {
|
||||
</AlertDescription>
|
||||
{isDenied(location) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
||||
{deniedMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
12
resources/js/types/vite-env.d.ts
vendored
12
resources/js/types/vite-env.d.ts
vendored
@ -1 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegisteredSW?: (swUrl: string, registration: ServiceWorkerRegistration | undefined) => void;
|
||||
onRegisterError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => void;
|
||||
}
|
||||
|
||||
@ -33,13 +33,20 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
<meta name="description" content="DST Collection - DST Punya Gaya">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="DST Collection">
|
||||
|
||||
@fonts
|
||||
|
||||
@viteReactRefresh
|
||||
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||
<x-inertia::head>
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<title>{{ config('app.name', 'DST Collection') }}</title>
|
||||
</x-inertia::head>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
|
||||
@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@ -27,5 +28,52 @@ export default defineConfig({
|
||||
wayfinder({
|
||||
formVariants: true,
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'favicon.svg', 'apple-touch-icon.png', 'assets/logo.png'],
|
||||
manifest: false,
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.bunny\.net\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'bunny-fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user