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",
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"typescript-eslint": "^8.23.0"
|
"typescript-eslint": "^8.23.0",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@ -73,7 +74,8 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0",
|
||||||
|
"workbox-window": "^7.4.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@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 { createInertiaApp } from '@inertiajs/react';
|
||||||
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import { FlashToast } from '@/components/flash-toast';
|
import { FlashToast } from '@/components/flash-toast';
|
||||||
|
import { PWAUpdateToast } from '@/components/pwa-update-toast';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { initializeTheme } from '@/hooks/use-appearance';
|
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';
|
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({
|
createInertiaApp({
|
||||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||||
layout: (name) => {
|
layout: (name) => {
|
||||||
@ -28,6 +39,7 @@ createInertiaApp({
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<FlashToast />
|
<FlashToast />
|
||||||
|
<PWAUpdateToast />
|
||||||
{app}
|
{app}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</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';
|
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() {
|
export default function Permissions() {
|
||||||
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
|
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
|
||||||
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
||||||
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
||||||
|
|
||||||
|
const appName = 'DST Collection';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('Notification' in window) {
|
if ('Notification' in window) {
|
||||||
if (Notification.permission === 'granted') setNotifications('granted');
|
if (Notification.permission === 'granted') setNotifications('granted');
|
||||||
@ -94,6 +124,7 @@ export default function Permissions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isDenied = (status: PermissionStatus) => status === 'denied';
|
const isDenied = (status: PermissionStatus) => status === 'denied';
|
||||||
|
const deniedMessage = getDeniedMessage(appName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -112,7 +143,7 @@ export default function Permissions() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(notifications) && (
|
{isDenied(notifications) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
{deniedMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +167,7 @@ export default function Permissions() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(camera) && (
|
{isDenied(camera) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
{deniedMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -160,7 +191,7 @@ export default function Permissions() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(location) && (
|
{isDenied(location) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
|
{deniedMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
/// <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.ico" sizes="any">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<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
|
@fonts
|
||||||
|
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||||
<x-inertia::head>
|
<x-inertia::head>
|
||||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
<title>{{ config('app.name', 'DST Collection') }}</title>
|
||||||
</x-inertia::head>
|
</x-inertia::head>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import { bunny } from 'laravel-vite-plugin/fonts';
|
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -27,5 +28,52 @@ export default defineConfig({
|
|||||||
wayfinder({
|
wayfinder({
|
||||||
formVariants: true,
|
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