11.5 - PWA - פתרון
פתרון - PWA¶
פתרון תרגיל 1 - מניפסט מלא¶
// public/manifest.json
{
"name": "מנהל משימות - Task Manager",
"short_name": "משימות",
"description": "אפליקציה לניהול משימות יומיות, תכנון פרויקטים ומעקב אחר התקדמות",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#1565c0",
"dir": "rtl",
"lang": "he",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{
"src": "/screenshots/desktop-dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "לוח הבקרה - תצוגת מחשב"
},
{
"src": "/screenshots/mobile-tasks.png",
"sizes": "375x812",
"type": "image/png",
"form_factor": "narrow",
"label": "רשימת משימות - תצוגת מובייל"
}
],
"shortcuts": [
{
"name": "משימה חדשה",
"short_name": "חדש",
"description": "יצירת משימה חדשה",
"url": "/tasks/new",
"icons": [{ "src": "/icons/shortcut-new.png", "sizes": "96x96" }]
},
{
"name": "משימות היום",
"short_name": "היום",
"description": "הצגת משימות להיום",
"url": "/tasks/today",
"icons": [{ "src": "/icons/shortcut-today.png", "sizes": "96x96" }]
}
]
}
// app/layout.tsx
import { type Metadata, type Viewport } from 'next';
export const viewport: Viewport = {
themeColor: '#1565c0',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export const metadata: Metadata = {
title: 'מנהל משימות',
description: 'אפליקציה לניהול משימות יומיות',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'משימות',
startupImage: '/icons/apple-splash.png',
},
other: {
'mobile-web-app-capable': 'yes',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="he" dir="rtl">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head>
<body>{children}</body>
</html>
);
}
פתרון תרגיל 2 - Service Worker בסיסי¶
// public/sw.js
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const IMAGE_CACHE = `images-${CACHE_VERSION}`;
const STATIC_ASSETS = [
'/',
'/offline',
'/manifest.json',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// Installation
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activation - cleaning old cache
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => !key.endsWith(CACHE_VERSION))
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// Fetch - strategy by type
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Images - Cache First
if (request.destination === 'image') {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
return;
}
// API - Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
return;
}
// Fonts - Stale While Revalidate
if (request.destination === 'font' || url.hostname.includes('fonts')) {
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
return;
}
// HTML pages - Network First with fallback to offline
if (request.mode === 'navigate') {
event.respondWith(
networkFirst(request, DYNAMIC_CACHE).catch(() =>
caches.match('/offline')
)
);
return;
}
// Default - Network First
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
});
// Strategies
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('', { status: 404 });
}
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok && request.method === 'GET') {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (request.mode === 'navigate') {
return caches.match('/offline');
}
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
return cached || (await fetchPromise) || new Response('', { status: 404 });
}
פתרון תרגיל 3 - זיהוי state רשת ותמיכה אופליין¶
// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true
);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return isOnline;
}
export default useOnlineStatus;
// components/OfflineBanner.tsx
'use client';
import useOnlineStatus from '@/hooks/useOnlineStatus';
function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div
role="alert"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '12px 24px',
backgroundColor: '#424242',
color: 'white',
textAlign: 'center',
zIndex: 9999,
}}
>
אתה במצב אופליין. שינויים יישמרו ויסונכרנו כשהחיבור יחזור.
</div>
);
}
export default OfflineBanner;
// lib/actionQueue.ts
interface QueuedAction {
id: string;
type: string;
url: string;
method: string;
body: unknown;
timestamp: number;
}
const QUEUE_KEY = 'offline-action-queue';
export function getQueue(): QueuedAction[] {
if (typeof window === 'undefined') return [];
const stored = localStorage.getItem(QUEUE_KEY);
return stored ? JSON.parse(stored) : [];
}
function saveQueue(queue: QueuedAction[]) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export function enqueue(action: Omit<QueuedAction, 'id' | 'timestamp'>) {
const queue = getQueue();
queue.push({
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
});
saveQueue(queue);
}
export function dequeue(id: string) {
const queue = getQueue().filter((a) => a.id !== id);
saveQueue(queue);
}
export async function processQueue() {
const queue = getQueue();
if (queue.length === 0) return;
for (const action of queue) {
try {
const response = await fetch(action.url, {
method: action.method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body),
});
if (response.ok) {
dequeue(action.id);
}
} catch {
// Still offline - we'll keep trying later
break;
}
}
}
// Hook for automatic sync
import { useEffect } from 'react';
import useOnlineStatus from '@/hooks/useOnlineStatus';
export function useAutoSync() {
const isOnline = useOnlineStatus();
useEffect(() => {
if (isOnline) {
processQueue();
}
}, [isOnline]);
}
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div style={{ textAlign: 'center', padding: '64px 24px', maxWidth: '500px', margin: '0 auto' }}>
<div style={{ fontSize: '4rem', marginBottom: '24px' }}>X</div>
<h1>אין חיבור לאינטרנט</h1>
<p style={{ color: '#666', marginBottom: '24px' }}>
נראה שאתה לא מחובר לרשת כרגע. האפליקציה תמשיך לעבוד במצב מוגבל -
שינויים שתבצע יישמרו ויסונכרנו כשהחיבור יחזור.
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '12px 32px',
backgroundColor: '#1565c0',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
cursor: 'pointer',
}}
>
נסה להתחבר שוב
</button>
</div>
);
}
פתרון תרגיל 4 - כפתור התקנה מותאם¶
'use client';
import { useState, useEffect, useCallback } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
function useInstallPrompt() {
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
// Check if iOS
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent);
setIsIOS(isIOSDevice);
// Check if recently dismissed
const dismissedAt = localStorage.getItem('install-dismissed-at');
if (dismissedAt) {
const daysSince = (Date.now() - parseInt(dismissedAt)) / (1000 * 60 * 60 * 24);
if (daysSince < 7) {
setIsDismissed(true);
}
}
const handler = (e: Event) => {
e.preventDefault();
setPromptEvent(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', () => setIsInstalled(true));
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const install = useCallback(async () => {
if (!promptEvent) return;
await promptEvent.prompt();
const { outcome } = await promptEvent.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
}
setPromptEvent(null);
}, [promptEvent]);
const dismiss = useCallback(() => {
setIsDismissed(true);
localStorage.setItem('install-dismissed-at', Date.now().toString());
}, []);
const canInstall = !isInstalled && !isDismissed && (!!promptEvent || isIOS);
return { canInstall, isInstalled, isIOS, install, dismiss };
}
function InstallBanner() {
const { canInstall, isInstalled, isIOS, install, dismiss } = useInstallPrompt();
const [showModal, setShowModal] = useState(false);
if (isInstalled) {
return null;
}
if (!canInstall) return null;
return (
<>
{/* Banner */}
<div style={{
padding: '16px 24px',
backgroundColor: '#e3f2fd',
borderBottom: '1px solid #bbdefb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div>
<strong>התקינו את האפליקציה על המכשיר</strong>
<p style={{ margin: '4px 0 0', fontSize: '0.9em', color: '#555' }}>
גישה מהירה, עבודה אופליין וביצועים משופרים
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setShowModal(true)} style={{
padding: '8px 16px', fontSize: '0.9em', border: 'none',
background: 'none', color: '#1565c0', cursor: 'pointer',
}}>
פרטים נוספים
</button>
{isIOS ? (
<button onClick={() => setShowModal(true)} style={{
padding: '8px 20px', backgroundColor: '#1565c0', color: 'white',
border: 'none', borderRadius: '6px', cursor: 'pointer',
}}>
כיצד להתקין
</button>
) : (
<button onClick={install} style={{
padding: '8px 20px', backgroundColor: '#1565c0', color: 'white',
border: 'none', borderRadius: '6px', cursor: 'pointer',
}}>
התקן
</button>
)}
<button onClick={dismiss} aria-label="סגור" style={{
padding: '8px', border: 'none', background: 'none', cursor: 'pointer',
}}>
X
</button>
</div>
</div>
{/* modal */}
{showModal && (
<div style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}}>
<div style={{
background: 'white', borderRadius: '12px', padding: '32px',
maxWidth: '450px', width: '90%',
}}>
<h2>למה להתקין?</h2>
<ul style={{ padding: '0 20px', lineHeight: '2' }}>
<li>גישה מהירה מהמסך הראשי</li>
<li>עובדת גם ללא אינטרנט</li>
<li>ביצועים מהירים יותר</li>
<li>התראות על עדכונים חשובים</li>
<li>לא תופסת מקום בזיכרון</li>
</ul>
{isIOS && (
<div style={{ backgroundColor: '#f5f5f5', padding: '16px', borderRadius: '8px', marginTop: '16px' }}>
<h3>הוראות התקנה ב-iPhone/iPad:</h3>
<ol style={{ paddingInlineStart: '20px', lineHeight: '2' }}>
<li>לחצו על כפתור השיתוף (Share) בתחתית המסך</li>
<li>גללו ובחרו "Add to Home Screen"</li>
<li>לחצו "Add" בפינה העליונה</li>
</ol>
</div>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
<button onClick={() => setShowModal(false)} style={{
padding: '10px 20px', border: '1px solid #ccc', borderRadius: '6px',
background: 'white', cursor: 'pointer',
}}>
סגור
</button>
{!isIOS && (
<button onClick={() => { install(); setShowModal(false); }} style={{
padding: '10px 20px', backgroundColor: '#1565c0', color: 'white',
border: 'none', borderRadius: '6px', cursor: 'pointer',
}}>
התקן עכשיו
</button>
)}
</div>
</div>
</div>
)}
</>
);
}
export default InstallBanner;
פתרון תרגיל 5 - הגדרת next-pwa¶
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
fallbacks: {
document: '/offline',
},
runtimeCaching: [
// Google Fonts - Cache First, a year
{
urlPattern: /^https:\/\/fonts\.(?:gstatic|googleapis)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 20,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
// Images - Cache First, 30 days, maximum 100
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif|ico)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
// API - Network First, 10 minutes
{
urlPattern: /^https?:\/\/.*\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 10 * 60,
},
networkTimeoutSeconds: 10,
cacheableResponse: {
statuses: [0, 200],
},
},
},
// HTML pages - Stale While Revalidate
{
urlPattern: /^https?:\/\/.*$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pages',
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = withPWA(nextConfig);
פתרון תרגיל 6 - עדכון אפליקציה¶
// hooks/useServiceWorkerUpdate.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
function useServiceWorkerUpdate() {
const [hasUpdate, setHasUpdate] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.ready.then((reg) => {
setRegistration(reg);
// Check for update on every visit
reg.update();
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
setHasUpdate(true);
}
});
});
});
// Periodic check - every hour
const interval = setInterval(() => {
registration?.update();
}, 60 * 60 * 1000);
return () => clearInterval(interval);
}, [registration]);
const applyUpdate = useCallback(() => {
if (!registration?.waiting) return;
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}, [registration]);
const dismissUpdate = useCallback(() => {
setHasUpdate(false);
}, []);
return { hasUpdate, applyUpdate, dismissUpdate };
}
export default useServiceWorkerUpdate;
// components/UpdateBanner.tsx
'use client';
import { useState } from 'react';
import useServiceWorkerUpdate from '@/hooks/useServiceWorkerUpdate';
function UpdateBanner() {
const { hasUpdate, applyUpdate, dismissUpdate } = useServiceWorkerUpdate();
const [showBadge, setShowBadge] = useState(false);
if (!hasUpdate && !showBadge) return null;
// Small badge after dismissal
if (showBadge && !hasUpdate) {
return (
<button
onClick={() => {
setShowBadge(false);
applyUpdate();
}}
aria-label="עדכון זמין"
style={{
position: 'fixed',
bottom: '20px',
insetInlineEnd: '20px',
width: '48px',
height: '48px',
borderRadius: '50%',
backgroundColor: '#ff9800',
color: 'white',
border: 'none',
fontSize: '1.2em',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
zIndex: 9999,
}}
>
!
</button>
);
}
return (
<div
role="alert"
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: '#323232',
color: 'white',
padding: '12px 24px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 9999,
}}
>
<span>גרסה חדשה זמינה</span>
<button
onClick={applyUpdate}
style={{
padding: '6px 16px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
עדכן עכשיו
</button>
<button
onClick={() => {
dismissUpdate();
setShowBadge(true);
}}
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: '#aaa',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
אחר כך
</button>
</div>
);
}
export default UpdateBanner;
// Inside the Service Worker - handling the SKIP_WAITING message
// public/sw.js
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
תשובות לשאלות¶
1. שלושת התנאים להתקנת PWA:
הדפדפן דורש: (א) קובץ manifest.json תקין עם name, icons, start_url ו-display, (ב) Service Worker רשום ופעיל עם handler ל-fetch, (ג) האתר מוגש דרך HTTPS (או localhost בפיתוח). בנוסף, המשתמש צריך לבקר באתר מספר פעמים לפני שהדפדפן יציע התקנה.
2. אסטרטגיות cache:
- Cache First - בודק קודם ב-cache, רק אם אין שם פונה לרשת. מתאים לתוכן שלא משתנה: תמונות, פונטים, קבצי CSS/JS עם hash.
- Network First - פונה קודם לרשת, אם נכשל מחזיר מה-cache. מתאים לתוכן דינמי: בקשות API, דפי HTML שמתעדכנים.
- Stale While Revalidate - מחזיר מיד מה-cache (מהיר) ובמקביל מבקש מהרשת ומעדכן את ה-cache. מתאים לתוכן שצריך להיות מהיר אך גם עדכני: פידים, דפי תוכן.
3. Service Worker מול Web Worker:
Service Worker פועל כ-proxy בין הדפדפן לרשת - הוא מיירט בקשות, מנהל cache ומטפל בהתראות push. הוא נשאר פעיל גם כשהדף סגור. Web Worker לעומת זאת מיועד לביצוע חישובים כבדים ברקע מבלי לחסום את ה-UI. הוא חי ומת עם הדף שיצר אותו.
4. אייקון maskable:
אייקון maskable מאפשר למערכת ההפעלה לחתוך (mask) את האייקון לצורות שונות - עיגול ב-Android, מרובע מעוגל ב-iOS, וכו'. ללא maskable, האייקון יוצג עם רקע לבן סביבו ויראה לא מקצועי. 512x512 נדרש כי מערכות הפעלה משתמשות בגודל הגדול ביותר ומקטינות לפי הצורך, ואייקון קטן מדי ייראה מטושטש.
5. עדכון Service Worker:
כשיש גרסה חדשה, הדפדפן מוריד את ה-SW החדש ומתקין אותו, אך הוא ממתין (waiting) עד שכל הלשוניות עם ה-SW הישן ייסגרו. הדרך המומלצת: (א) זיהוי שיש גרסה חדשה (event updatefound), (ב) הצגת הודעה למשתמש, (ג) כשהמשתמש מאשר - שליחת הודעת SKIP_WAITING ל-SW החדש, (ד) רענון הדף כשה-controller משתנה. חשוב לא לעשות skipWaiting אוטומטי כי זה עלול לשבור דפים שכבר טעונו עם הגרסה הישנה.