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:
Yoga Pangestu 2026-07-30 22:23:41 +07:00
parent aecd8eaf1f
commit bfad1ae65c
12 changed files with 2115 additions and 20 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

BIN
public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

35
public/manifest.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -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>

View 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;
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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">

View File

@ -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,
},
}),
],
});