Web Development

Progressive Web App: Website yang Seperti App

Tutorial lengkap belajar PWA dari nol β€” service worker, web manifest, offline support, push notification, dan install prompt dengan contoh kode praktis

1. Pengenalan Progressive Web App

Progressive Web App (PWA) adalah jenis aplikasi web yang menggunakan teknologi modern untuk memberikan pengalaman seperti aplikasi native (aplikasi yang di-install dari App Store/Play Store). PWA bisa di-install di perangkat, berjalan offline, mengirim push notification, dan terasa sangat cepat.

PWA menggunakan tiga teknologi utama: Web App Manifest (memberi info aplikasi ke browser), Service Worker (script background yang mengelola caching dan offline), dan HTTPS (keamanan wajib).

PWA vs Native App vs Web Biasa

Fitur Web Biasa PWA Native App
Install ke Home ScreenβŒβœ…βœ…
Offline SupportβŒβœ…βœ…
Push NotificationβŒβœ…βœ…
Akses Hardware🟑 Terbatas🟑 Cukupβœ… Penuh
Biaya Develop🟒 Rendah🟒 RendahπŸ”΄ Tinggi
Update Otomatisβœ…βœ…βŒ Perlu download
App Store❌🟑 Sebagianβœ…
Cross-Platformβœ…βœ…βŒ Perlu build terpisah

Contoh PWA yang Sukses

Aplikasi Manfaat PWA
Twitter Lite65% lebih banyak halaman per sesi, 75% lebih banyak tweet
Pinterest60% peningkatan engagement, 44% peningkatan revenue per user
StarbucksUkuran app 99.8% lebih kecil dari native, 2x daily active users
SpotifyExperience mirip native di web, termasuk offline dan notification
UberBisa dimuat dalam 3 detik di jaringan 2G

Persyaratan PWA

Checklist β€” PWA Requirements
# Checklist untuk membuat PWA:
#
# βœ… 1. HTTPS (wajib) β€” service worker hanya bekerja di HTTPS
# βœ… 2. Web App Manifest β€” file manifest.json
# βœ… 3. Service Worker β€” script background
# βœ… 4. Responsive Design β€” tampil baik di semua perangkat
# βœ… 5. Offline Fallback β€” minimal ada halaman offline
# βœ… 6. Ikon β€” berbagai ukuran (192x192, 512x512)
#
# Bonus (meningkatkan kualitas):
# ⭐ Push Notification
# ⭐ Background Sync
# ⭐ App-like Navigation (tanpa URL bar)
# ⭐ Splash Screen
# ⭐ Install Prompt
Diagram: Arsitektur PWA
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              ARSITEKTUR PWA                            β”‚
β”‚                                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                     β”‚
β”‚  β”‚   Browser    β”‚                                     β”‚
β”‚  β”‚  (User UI)   │◀──── User berinteraksi              β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                                     β”‚
β”‚         β”‚                                             β”‚
β”‚         β–Ό                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚   App Shell  β”‚    β”‚     Web App Manifest        β”‚  β”‚
β”‚  β”‚  (HTML/CSS/  β”‚    β”‚  - Nama app                 β”‚  β”‚
β”‚  β”‚   JS)        β”‚    β”‚  - Ikon                     β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚  - Theme color              β”‚  β”‚
β”‚         β”‚            β”‚  - Display mode             β”‚  β”‚
β”‚         β–Ό            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                     β”‚
β”‚  β”‚   Service    │◀──── Otak dari PWA                  β”‚
β”‚  β”‚   Worker     β”‚                                     β”‚
β”‚  β”‚              β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ - Cache API  β”‚    β”‚    Cache Storage            β”‚  β”‚
β”‚  β”‚ - Fetch      │───▢│  - HTML, CSS, JS            β”‚  β”‚
β”‚  β”‚ - Push       β”‚    β”‚  - Gambar, Font             β”‚  β”‚
β”‚  β”‚ - Sync       β”‚    β”‚  - API Response             β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                       β”‚
β”‚  Keuntungan:                                           β”‚
β”‚  βœ… Bekerja offline    βœ… Install ke home screen       β”‚
β”‚  βœ… Push notification  βœ… Loading sangat cepat         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Web App Manifest

Web App Manifest adalah file JSON yang memberikan informasi tentang aplikasi Anda ke browser β€” nama, ikon, warna tema, mode tampilan, dan banyak lagi. File ini memungkinkan browser menawarkan opsi "Add to Home Screen" kepada user.

Membuat Manifest

JSON β€” manifest.json
{
  "name": "BeebaneLabs Tutorial",
  "short_name": "BeebaneLabs",
  "description": "Portal tutorial teknologi Indonesia",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#08090a",
  "theme_color": "#3b82f6",
  "orientation": "portrait",
  "scope": "/",
  "lang": "id",
  "categories": ["education", "technology"],
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Halaman utama BeebaneLabs"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Tampilan mobile BeebaneLabs"
    }
  ],
  "shortcuts": [
    {
      "name": "Tutorial Terbaru",
      "short_name": "Tutorial",
      "url": "/tutorials",
      "icons": [{ "src": "/icons/tutorial.png", "sizes": "96x96" }]
    },
    {
      "name": "Pencarian",
      "short_name": "Cari",
      "url": "/search",
      "icons": [{ "src": "/icons/search.png", "sizes": "96x96" }]
    }
  ]
}

Menyambungkan Manifest ke HTML

HTML β€” Link Manifest
<!-- Tambahkan di <head> halaman HTML Anda -->
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Web App Manifest -->
  <link rel="manifest" href="/manifest.json">

  <!-- Theme color untuk browser chrome -->
  <meta name="theme-color" content="#3b82f6">

  <!-- Apple-specific meta tags -->
  <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="BeebaneLabs">
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">

  <!-- Microsoft-specific -->
  <meta name="msapplication-TileColor" content="#3b82f6">
  <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">

  <title>Aplikasi Saya</title>
</head>
<body>
  <h1>Selamat Datang!</h1>

  <!-- Daftarkan Service Worker -->
  <script>
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('SW registered:', reg.scope))
        .catch(err => console.error('SW registration failed:', err));
    }
  </script>
</body>
</html>
πŸ’‘ Tips Ikon Maskable

Ikon dengan "purpose": "maskable" bisa dipotong oleh sistem operasi sesuai bentuk mask-nya (lingkaran di Android, rounded square di iOS). Pastikan ikon penting berada di area tengah dengan margin yang cukup. Gunakan maskable.app untuk preview.

3. Service Worker

Service Worker adalah script JavaScript yang berjalan di background browser, terpisah dari halaman web utama. Service Worker bisa mencegat network request, mengelola cache, menerima push notification, dan sinkronisasi data di background.

Lifecycle Service Worker

Diagram: Service Worker Lifecycle
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           SERVICE WORKER LIFECYCLE                     β”‚
β”‚                                                       β”‚
β”‚  1. REGISTER    β†’ Browser mendaftarkan SW             β”‚
β”‚       β”‚                                               β”‚
β”‚       β–Ό                                               β”‚
β”‚  2. INSTALL     β†’ SW menginstall (cache aset)         β”‚
β”‚       β”‚          event: install                        β”‚
β”‚       β–Ό                                               β”‚
β”‚  3. ACTIVATE    β†’ SW aktif (bersihkan cache lama)     β”‚
β”‚       β”‚          event: activate                       β”‚
β”‚       β–Ό                                               β”‚
β”‚  4. FETCH       β†’ SW mencegat setiap request          β”‚
β”‚                   event: fetch                         β”‚
β”‚                                                       β”‚
β”‚  Catatan:                                             β”‚
β”‚  - SW baru tidak aktif sampai semua tab lama          β”‚
β”‚    ditutup (atau self.skipWaiting())                  β”‚
β”‚  - SW hanya bisa HTTPS (atau localhost)                β”‚
β”‚  - SW tidak bisa akses DOM langsung                    β”‚
β”‚  - Komunikasi via postMessage()                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Service Worker Dasar

JavaScript β€” sw.js (Service Worker)
// sw.js β€” Service Worker file
// Letakkan di root folder public

const CACHE_NAME = 'beebanelabs-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/css/style.css',
  '/js/app.js',
  '/images/logo.png',
  '/offline.html',  // Halaman fallback saat offline
];

// ========== INSTALL ==========
// Dijalankan saat SW pertama kali diinstall
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');

  // event.waitUntil() menunggu promise selesai
  // sebelum SW dianggap terinstall
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Caching assets');
        return cache.addAll(ASSETS_TO_CACHE);
      })
      .then(() => self.skipWaiting()) // Langsung aktif
  );
});

// ========== ACTIVATE ==========
// Dijalankan saat SW baru menggantikan SW lama
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME) // Cari cache lama
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name); // Hapus cache lama
          })
      );
    }).then(() => self.clients.claim()) // Kontrol semua tab
  );
});

// ========== FETCH ==========
// Dijalankan setiap kali browser melakukan request
self.addEventListener('fetch', (event) => {
  // Strategi: Cache First, falling back to Network
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse; // Ambil dari cache
        }
        return fetch(event.request) // Fetch dari network
          .then((response) => {
            // Cache response baru
            const responseClone = response.clone();
            caches.open(CACHE_NAME).then((cache) => {
              cache.put(event.request, responseClone);
            });
            return response;
          })
          .catch(() => {
            // Jika offline dan tidak ada cache
            if (event.request.destination === 'document') {
              return caches.match('/offline.html');
            }
          });
      })
  );
});

Berbagai Strategi Caching

JavaScript β€” Caching Strategies
// 1. CACHE FIRST (Cache, falling back to Network)
// Cocok untuk: aset statis (CSS, JS, gambar, font)
// Prioritas cache β†’ jika tidak ada, fetch dari network
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open(CACHE_NAME);
  cache.put(request, response.clone());
  return response;
}

// 2. NETWORK FIRST (Network, falling back to Cache)
// Cocok untuk: data dinamis yang harus selalu terbaru
// Prioritas network β†’ jika gagal (offline), ambil dari cache
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (err) {
    return caches.match(request);
  }
}

// 3. STALE WHILE REVALIDATE
// Cocok untuk: konten yang boleh sedikit outdated
// Langsung kirim cache, tapi update cache di background
async function staleWhileRevalidate(request) {
  const cached = await caches.match(request);
  const fetchPromise = fetch(request).then((response) => {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  });
  return cached || fetchPromise;
}

// Menggunakan strategi berbeda untuk tipe request berbeda
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    // API β†’ Network First (data harus terbaru)
    event.respondWith(networkFirst(event.request));
  } else if (url.pathname.match(/\.(css|js|png|jpg|woff2)$/)) {
    // Aset statis β†’ Cache First (jarang berubah)
    event.respondWith(cacheFirst(event.request));
  } else {
    // HTML β†’ Stale While Revalidate
    event.respondWith(staleWhileRevalidate(event.request));
  }
});
⚠️ Cache Versioning

Saat Anda update aplikasi, ubah nama cache (misalnya dari v1 ke v2). Service Worker baru akan menginstall cache baru dan menghapus cache lama saat activate. Tanpa cache versioning, user akan melihat versi lama dari aplikasi Anda karena cache belum di-invalidate.

4. Offline Support & Caching

Salah satu keunggulan utama PWA adalah kemampuan bekerja tanpa koneksi internet. Dengan caching yang tepat, user bisa tetap mengakses konten, navigasi, dan bahkan menggunakan fitur-fitur tertentu saat offline.

Halaman Offline Fallback

HTML β€” offline.html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Anda Sedang Offline</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: system-ui, sans-serif;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      background: #0f172a;
      color: #e2e8f0;
      text-align: center;
      padding: 2rem;
    }
    .container { max-width: 400px; }
    .icon { font-size: 4rem; margin-bottom: 1rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: #94a3b8; margin-bottom: 1.5rem; }
    button {
      background: #3b82f6;
      color: white;
      border: none;
      padding: 0.75rem 1.5rem;
      border-radius: 0.5rem;
      font-size: 1rem;
      cursor: pointer;
    }
    button:hover { background: #2563eb; }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">πŸ“‘</div>
    <h1>Anda Sedang Offline</h1>
    <p>Sepertinya koneksi internet terputus. Beberapa konten mungkin tidak tersedia. Silakan coba lagi nanti.</p>
    <button onclick="window.location.reload()">
      πŸ”„ Coba Lagi
    </button>
  </div>
</body>
</html>

Offline Storage dengan IndexedDB

JavaScript β€” IndexedDB Offline
// Menggunakan IndexedDB untuk menyimpan data saat offline
// IndexedDB adalah database browser yang bisa menyimpan
// data dalam jumlah besar (berbeda dengan localStorage yang terbatas)

// Buka atau buat database
function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('BeebaneLabsDB', 1);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      // Buat object store (seperti tabel)
      if (!db.objectStoreNames.contains('drafts')) {
        db.createObjectStore('drafts', { keyPath: 'id', autoIncrement: true });
      }
      if (!db.objectStoreNames.contains('cache')) {
        db.createObjectStore('cache', { keyPath: 'url' });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Simpan data draft saat offline
async function saveDraft(draft) {
  const db = await openDB();
  const tx = db.transaction('drafts', 'readwrite');
  const store = tx.objectStore('drafts');
  store.add({ ...draft, savedAt: new Date().toISOString() });
  return new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = reject;
  });
}

// Ambil semua draft
async function getDrafts() {
  const db = await openDB();
  const tx = db.transaction('drafts', 'readonly');
  const store = tx.objectStore('drafts');
  const request = store.getAll();
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Background Sync: kirim draft saat online kembali
// Didukung oleh service worker
async function syncWhenOnline() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-drafts');
  }
}

// Deteksi status online/offline
window.addEventListener('online', () => {
  console.log('Kembali online! Menyinkronkan data...');
  syncWhenOnline();
  // Tampilkan notifikasi ke user
  showNotification('Anda kembali online! Data akan disinkronkan.');
});

window.addEventListener('offline', () => {
  console.log('Anda offline. Data akan disimpan lokal.');
  showNotification('Anda offline. Data akan disimpan dan dikirim saat online.');
});

5. Push Notification

Push Notification memungkinkan aplikasi Anda mengirim pesan kepada user bahkan ketika browser tidak aktif. Ini sangat berguna untuk mengingatkan user tentang konten baru, pesan, atau update penting.

Meminta Izin Notifikasi

JavaScript β€” Push Notification
// 1. Meminta izin dari user
async function requestNotificationPermission() {
  // Cek apakah browser mendukung notification
  if (!('Notification' in window)) {
    console.log('Browser ini tidak mendukung notifikasi');
    return false;
  }

  // Cek status izin saat ini
  if (Notification.permission === 'granted') {
    return true;
  }

  if (Notification.permission !== 'denied') {
    // Minta izin ke user
    const permission = await Notification.requestPermission();
    return permission === 'granted';
  }

  return false;
}

// 2. Subscribe ke Push Notification
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  // Subscribe ke push service
  // applicationServerKey (VAPID) dari server Anda
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Kirim subscription ke server
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  console.log('Berhasil subscribe ke push notification!');
  return subscription;
}

// Helper: convert VAPID key
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

// 3. Menampilkan notifikasi lokal (tanpa server)
function showLocalNotification(title, body, icon) {
  if (Notification.permission === 'granted') {
    new Notification(title, {
      body: body,
      icon: icon || '/icons/icon-192x192.png',
      badge: '/icons/badge-72x72.png',
      vibrate: [200, 100, 200],
    });
  }
}

// Contoh penggunaan:
// showLocalNotification(
//   'Tutorial Baru! πŸ“š',
//   'Belajar Progressive Web App dari nol',
//   '/icons/icon-192x192.png'
// );

Menangani Push di Service Worker

JavaScript β€” SW Push Handler
// Di dalam sw.js β€” Menangani push notification

// Event: push β€” saat notifikasi diterima dari server
self.addEventListener('push', (event) => {
  console.log('[SW] Push received:', event);

  let data = { title: 'Notifikasi Baru', body: 'Ada pesan baru untuk Anda' };

  if (event.data) {
    data = event.data.json();
  }

  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: {
      url: data.url || '/',
      timestamp: Date.now(),
    },
    actions: [
      { action: 'open', title: 'Buka', icon: '/icons/open.png' },
      { action: 'close', title: 'Tutup', icon: '/icons/close.png' },
    ],
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Event: notificationclick β€” saat user mengklik notifikasi
self.addEventListener('notificationclick', (event) => {
  console.log('[SW] Notification click:', event.action);

  event.notification.close();

  if (event.action === 'close') return;

  // Buka halaman yang sesuai
  const urlToOpen = event.notification.data?.url || '/';

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      // Cek apakah sudah ada tab yang terbuka
      for (const client of clients) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus();
        }
      }
      // Jika tidak, buka tab baru
      return self.clients.openWindow(urlToOpen);
    })
  );
});

// Event: notificationclose β€” saat notifikasi ditutup (dismiss)
self.addEventListener('notificationclose', (event) => {
  console.log('[SW] Notification dismissed');
  // Bisa digunakan untuk analytics
});
πŸ’‘ Server Push Notification

Untuk mengirim push notification dari server, Anda membutuhkan library seperti web-push di Node.js. Server menggunakan subscription yang disimpan untuk mengirim notifikasi ke user tertentu melalui push service browser (FCM untuk Chrome, Mozilla Push untuk Firefox).

6. Install Prompt

Browser modern bisa menawarkan user untuk meng-install PWA ke home screen. Anda bisa menangkap event beforeinstallprompt untuk menampilkan tombol install custom yang lebih menarik daripada prompt bawaan browser.

Menangkap Install Prompt

JavaScript β€” Install Prompt
// === app.js β€” Install Prompt Handler ===

let deferredPrompt = null;
const installButton = document.getElementById('installButton');

// Event: beforeinstallprompt β€” browser ingin menampilkan install prompt
window.addEventListener('beforeinstallprompt', (event) => {
  console.log('[App] beforeinstallprompt fired');

  // Cegah browser menampilkan prompt default
  event.preventDefault();

  // Simpan event agar bisa dipanggil nanti
  deferredPrompt = event;

  // Tampilkan tombol install custom
  if (installButton) {
    installButton.style.display = 'block';
  }
});

// Fungsi: tampilkan install prompt saat user klik tombol
async function installApp() {
  if (!deferredPrompt) {
    console.log('[App] Tidak ada install prompt tersedia');
    return;
  }

  // Tampilkan prompt
  deferredPrompt.prompt();

  // Tunggu respon user
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`[App] User choice: ${outcome}`); // 'accepted' atau 'dismissed'

  if (outcome === 'accepted') {
    console.log('[App] User accepted install!');
    showInstallSuccessMessage();
  }

  // Hapus prompt (hanya bisa dipanggil sekali)
  deferredPrompt = null;

  // Sembunyikan tombol install
  if (installButton) {
    installButton.style.display = 'none';
  }
}

// Event: appinstalled β€” PWA berhasil diinstall
window.addEventListener('appinstalled', (event) => {
  console.log('[App] PWA was installed!');
  deferredPrompt = null;
  // Bisa kirim analytics di sini
});

// Deteksi apakah PWA sudah diinstall
function isAppInstalled() {
  // Cara 1: display-mode standalone
  if (window.matchMedia('(display-mode: standalone)').matches) {
    return true;
  }
  // Cara 2: navigator.standalone (iOS)
  if (window.navigator.standalone === true) {
    return true;
  }
  return false;
}

// Tampilkan pesan berbeda jika sudah installed
if (isAppInstalled()) {
  console.log('[App] Running as installed PWA');
}

UI Tombol Install

HTML β€” Install Button
<!-- Banner install yang menarik -->
<div id="installBanner" class="install-banner" style="display: none;">
  <div class="install-banner-content">
    <img src="/icons/icon-72x72.png" alt="App Icon" class="install-icon">
    <div class="install-text">
      <h3>Install BeebaneLabs</h3>
      <p>Akses lebih cepat, bisa offline, dan notifikasi tutorial baru!</p>
    </div>
    <div class="install-actions">
      <button id="installButton" onclick="installApp()">
        πŸ“² Install
      </button>
      <button onclick="dismissInstall()" class="btn-secondary">
        Nanti
      </button>
    </div>
  </div>
</div>

<!-- Contoh styling (bisa di CSS file) -->
<style>
  .install-banner {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: linear-gradient(135deg, #1e293b, #0f172a);
    border-top: 2px solid #3b82f6;
    padding: 1rem;
    z-index: 1000;
    animation: slideUp 0.3s ease-out;
  }
  .install-banner-content {
    max-width: 800px;
    margin: 0 auto;
    display: flex;
    align-items: center;
    gap: 1rem;
  }
  .install-icon { width: 48px; height: 48px; }
  .install-text h3 { color: #fff; font-size: 1rem; }
  .install-text p { color: #94a3b8; font-size: 0.875rem; }
  .install-actions {
    margin-left: auto;
    display: flex;
    gap: 0.5rem;
  }
  .install-actions button {
    padding: 0.5rem 1rem;
    border-radius: 0.5rem;
    font-weight: 600;
    cursor: pointer;
  }
  #installButton {
    background: #3b82f6;
    color: white;
    border: none;
  }
  .btn-secondary {
    background: transparent;
    color: #94a3b8;
    border: 1px solid #334155;
  }
  @keyframes slideUp {
    from { transform: translateY(100%); }
    to { transform: translateY(0); }
  }
</style>
⚠️ Kapan Install Prompt Tersedia?

Browser hanya menampilkan beforeinstallprompt jika PWA memenuhi kriteria tertentu: memiliki manifest yang valid, service worker ter-register, HTTPS, dan belum di-install. Di iOS Safari, tidak ada install prompt otomatis β€” user harus menambahkan secara manual via "Add to Home Screen" di menu Share.

7. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Progressive Web App:

Pertanyaan 1: Apa tiga komponen utama yang dibutuhkan untuk membuat PWA?

a) HTML, CSS, JavaScript
b) Web App Manifest, Service Worker, HTTPS
c) React, Node.js, MongoDB
d) IndexedDB, localStorage, Cookies

Pertanyaan 2: Apa fungsi utama dari Service Worker?

a) Menggantikan CSS framework
b) Menjalankan script di background untuk caching, offline support, dan push notification
c) Mengelola database server
d) Mengompresi gambar secara otomatis

Pertanyaan 3: Strategi caching apa yang cocok untuk aset statis (CSS, JS, gambar)?

a) Network Only
b) Cache First (cache sebagai prioritas, network sebagai fallback)
c) Network First
d) Tidak perlu caching untuk aset statis

Pertanyaan 4: Apa yang dilakukan event "beforeinstallprompt"?

a) Menginstall service worker secara otomatis
b) Memberi kesempatan untuk menampilkan tombol install PWA custom kepada user
c) Menghapus cache browser
d) Mengirim push notification

Pertanyaan 5: Mengapa HTTPS wajib untuk PWA?

a) Agar website lebih cepat
b) Karena Service Worker hanya bisa berjalan di HTTPS untuk keamanan
c) HTTPS tidak wajib untuk PWA
d) Agar bisa masuk App Store