ITSkillsCenter
الأعمال الرقمية

تطبيق ساعي PWA يعمل offline: درس عملي 2026

7 دقائق للقراءة

📍 المقالة الرئيسية للمجموعة: Stack لوجستية 2026.

الساعي في إفريقيا الغربية يعمل في ظروف صعبة: 3G متذبذب، بطارية محدودة، هاتف منخفض الجودة، شارع مليء. تطبيق ساعي حديث يجب أن يكون offline-first: يخزن التوصيلات محلياً، يعرض GPS دون الحاجة إلى الاتصال، يلتقط صور الدليل ويزامنها لاحقاً. PWA Astro + Service Worker + IndexedDB = حل مثالي بدون رسوم App Store. هذا الدرس يفصل التنفيذ الكامل من الصفر.

المتطلبات

Backend TMS في الإنتاج (راجع تتبع SSE). Astro 4+ مع PWA integration. معرفة TypeScript، React، Service Workers. المستوى المتوقع: متقدم. الوقت المقدر: 1-2 أسبوع للتطبيق الكامل.

الخطوة 1 — تهيئة Astro PWA

Astro خفيف ومناسب لـ PWA. integration officielle yields service worker جاهز.

npm create astro@latest courier-app
cd courier-app
npm install @astrojs/react @astrojs/pwa react react-dom
npm install dexie  # IndexedDB wrapper
npm install workbox-window  # Service worker tools

تكوين astro.config.mjs:

import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import pwa from '@astrojs/pwa';

export default defineConfig({
  integrations: [react(), pwa({
    registerType: 'autoUpdate',
    workbox: {
      globPatterns: ['**/*.{js,css,html,png,svg}'],
      runtimeCaching: [{
        urlPattern: /^https:\/\/api\.tms\.example\.com\//,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'api',
          networkTimeoutSeconds: 10
        }
      }, {
        urlPattern: /^https:\/\/tile\.openstreetmap\.org\//,
        handler: 'CacheFirst',
        options: { cacheName: 'osm-tiles', expiration: { maxAgeSeconds: 30 * 86400 } }
      }]
    },
    manifest: {
      name: 'Coursier App',
      short_name: 'Coursier',
      theme_color: '#1976d2',
      icons: [
        { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
        { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
      ]
    }
  })]
});

الخطوة 2 — IndexedDB للتخزين المحلي

Dexie wrapper بسيط لـ IndexedDB. يخزن التوصيلات، queue المزامنة، GPS history.

// src/lib/db.ts
import Dexie, { type Table } from 'dexie';

export interface Delivery {
  id: string;
  status: 'pending' | 'picked_up' | 'in_transit' | 'delivered' | 'failed';
  pickup: { lat: number; lng: number; address: string };
  dropoff: { lat: number; lng: number; address: string };
  customer: { name: string; phone: string };
  amount: number;
  paymentMethod: 'cash' | 'wave' | 'om';
  proofPhoto?: string;  // base64
  signature?: string;
  syncStatus: 'synced' | 'pending';
  updatedAt: number;
}

export interface SyncQueue {
  id?: number;
  type: 'status_update' | 'photo_upload' | 'location';
  data: any;
  attempts: number;
  createdAt: number;
}

class CourierDB extends Dexie {
  deliveries!: Table<Delivery>;
  syncQueue!: Table<SyncQueue>;
  
  constructor() {
    super('courier-app');
    this.version(1).stores({
      deliveries: 'id, status, syncStatus, updatedAt',
      syncQueue: '++id, type, createdAt'
    });
  }
}

export const db = new CourierDB();

الخطوة 3 — صفحة قائمة التوصيلات

الصفحة الرئيسية تعرض التوصيلات النشطة. تقرأ من IndexedDB أولاً (instant)، ثم تحاول refresh عبر API.

// src/pages/index.astro
---
---
<Layout title="قائمة التوصيلات">
  <DeliveriesList client:load />
</Layout>
// src/components/DeliveriesList.tsx
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';

export default function DeliveriesList() {
  const deliveries = useLiveQuery(() => 
    db.deliveries.where('status').notEqual('delivered').toArray()
  );
  
  // Sync مع API عند الاتصال
  useEffect(() => {
    if (navigator.onLine) {
      fetch('/api/courier/me/deliveries')
        .then(r => r.json())
        .then(async (apiDeliveries) => {
          for (const d of apiDeliveries) {
            await db.deliveries.put({ ...d, syncStatus: 'synced' });
          }
        });
    }
  }, []);
  
  if (!deliveries) return <div>جاري التحميل...</div>;
  
  return (
    <ul className="deliveries-list">
      {deliveries.map(d => (
        <li key={d.id} className={d.status}>
          <a href={`/delivery/${d.id}`}>
            <h3>#{d.id.slice(0, 8)}</h3>
            <p>{d.customer.name}</p>
            <p>{d.dropoff.address}</p>
            <span className="amount">{d.amount} XOF</span>
            <span className={`status ${d.status}`}>{d.status}</span>
          </a>
        </li>
      ))}
    </ul>
  );
}

الخطوة 4 — صفحة تفاصيل التوصيل

كل توصيل: خريطة، أزرار حالة، التقاط صورة، توقيع، دفع.

// src/components/DeliveryDetail.tsx
export default function DeliveryDetail({ deliveryId }: { deliveryId: string }) {
  const delivery = useLiveQuery(() => db.deliveries.get(deliveryId));
  if (!delivery) return null;
  
  const updateStatus = async (newStatus: string) => {
    await db.deliveries.update(deliveryId, {
      status: newStatus,
      syncStatus: 'pending',
      updatedAt: Date.now()
    });
    
    await db.syncQueue.add({
      type: 'status_update',
      data: { deliveryId, status: newStatus },
      attempts: 0,
      createdAt: Date.now()
    });
    
    triggerSync();
  };
  
  return (
    <div>
      <Map pickup={delivery.pickup} dropoff={delivery.dropoff} />
      
      {delivery.status === 'pending' && (
        <button onClick={() => updateStatus('picked_up')}>استلام</button>
      )}
      {delivery.status === 'picked_up' && (
        <button onClick={() => updateStatus('in_transit')}>في الطريق</button>
      )}
      {delivery.status === 'in_transit' && (
        <ProofCapture deliveryId={deliveryId} />
      )}
    </div>
  );
}

الخطوة 5 — التقاط صورة الدليل

الساعي يلتقط صورة عند التسليم: الباب، العميل مع الطلب. ضمان لا rebut. Camera API + base64 + IndexedDB.

// src/components/ProofCapture.tsx
export function ProofCapture({ deliveryId }: { deliveryId: string }) {
  const [photo, setPhoto] = useState<string | null>(null);
  
  const capturePhoto = async () => {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'environment' }
    });
    
    const video = document.createElement('video');
    video.srcObject = stream;
    await video.play();
    
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    canvas.getContext('2d')!.drawImage(video, 0, 0);
    
    // ضغط الصورة (JPEG 70% quality)
    const base64 = canvas.toDataURL('image/jpeg', 0.7);
    setPhoto(base64);
    
    stream.getTracks().forEach(t => t.stop());
  };
  
  const confirmDelivery = async () => {
    await db.deliveries.update(deliveryId, {
      status: 'delivered',
      proofPhoto: photo!,
      syncStatus: 'pending',
      updatedAt: Date.now()
    });
    
    await db.syncQueue.add({
      type: 'photo_upload',
      data: { deliveryId, photo },
      attempts: 0,
      createdAt: Date.now()
    });
    
    triggerSync();
  };
  
  return (
    <div>
      {!photo ? (
        <button onClick={capturePhoto}>التقاط صورة الدليل</button>
      ) : (
        <>
          <img src={photo} alt="Proof" />
          <button onClick={() => setPhoto(null)}>إعادة الالتقاط</button>
          <button onClick={confirmDelivery}>تأكيد التسليم</button>
        </>
      )}
    </div>
  );
}

الخطوة 6 — توقيع رقمي

للتوصيلات عالية القيمة، توقيع العميل على screen. Canvas + touch events.

function SignatureCanvas({ onSave }: { onSave: (sig: string) => void }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [drawing, setDrawing] = useState(false);
  
  const startDraw = (e: React.PointerEvent) => {
    setDrawing(true);
    const ctx = canvasRef.current!.getContext('2d')!;
    ctx.beginPath();
    ctx.moveTo(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
  };
  
  const draw = (e: React.PointerEvent) => {
    if (!drawing) return;
    const ctx = canvasRef.current!.getContext('2d')!;
    ctx.lineTo(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
    ctx.stroke();
  };
  
  const save = () => {
    const sig = canvasRef.current!.toDataURL('image/png');
    onSave(sig);
  };
  
  return (
    <>
      <canvas ref={canvasRef} width={400} height={200} 
              onPointerDown={startDraw}
              onPointerMove={draw}
              onPointerUp={() => setDrawing(false)}
              style={{ border: '1px solid #ccc' }} />
      <button onClick={save}>حفظ</button>
    </>
  );
}

الخطوة 7 — Sync engine

Service Worker يحاول مزامنة queue كل دقيقة عند الاتصال. يستخدم Background Sync API على Chrome، fallback على setInterval.

// src/lib/sync.ts
export async function triggerSync() {
  if ('serviceWorker' in navigator && 'sync' in navigator.serviceWorker) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-queue');
  } else {
    // Fallback: مزامنة فورية
    await processSyncQueue();
  }
}

async function processSyncQueue() {
  if (!navigator.onLine) return;
  
  const queue = await db.syncQueue.orderBy('createdAt').toArray();
  
  for (const item of queue) {
    try {
      switch (item.type) {
        case 'status_update':
          await fetch(`/api/delivery/${item.data.deliveryId}/status`, {
            method: 'POST',
            body: JSON.stringify({ status: item.data.status })
          });
          break;
        case 'photo_upload':
          const formData = new FormData();
          const blob = await (await fetch(item.data.photo)).blob();
          formData.append('photo', blob);
          await fetch(`/api/delivery/${item.data.deliveryId}/proof`, {
            method: 'POST',
            body: formData
          });
          break;
        case 'location':
          await fetch('/api/courier/me/location', {
            method: 'POST',
            body: JSON.stringify(item.data)
          });
          break;
      }
      
      await db.syncQueue.delete(item.id!);
    } catch (e) {
      // إعادة المحاولة لاحقاً
      await db.syncQueue.update(item.id!, { attempts: item.attempts + 1 });
      if (item.attempts > 5) await db.syncQueue.delete(item.id!);
    }
  }
}

// Sync كل دقيقة
setInterval(processSyncQueue, 60000);
window.addEventListener('online', processSyncQueue);

الخطوة 8 — Geolocation Tracking

watchPosition مع throttling adaptive. يخزن في IndexedDB، يبث عند الاتصال.

// src/lib/gps.ts
let watchId: number | null = null;
let lastSent = 0;

export function startTracking() {
  watchId = navigator.geolocation.watchPosition(
    async (position) => {
      const { latitude, longitude, speed } = position.coords;
      const now = Date.now();
      
      // throttle 10s
      if (now - lastSent < 10000) return;
      lastSent = now;
      
      // إضافة إلى queue
      await db.syncQueue.add({
        type: 'location',
        data: { lat: latitude, lng: longitude, speed, timestamp: now },
        attempts: 0,
        createdAt: now
      });
      
      triggerSync();
    },
    (err) => console.error('GPS error:', err),
    { enableHighAccuracy: true, maximumAge: 5000, timeout: 30000 }
  );
}

export function stopTracking() {
  if (watchId !== null) navigator.geolocation.clearWatch(watchId);
}

الخطوة 9 — تنبيهات Push

توصيل جديد مخصص للساعي = push notification. يتطلب Service Worker + VAPID keys + push subscription.

// service-worker.js
self.addEventListener('push', (event) => {
  const data = event.data.json();
  
  event.waitUntil(
    self.registration.showNotification('توصيل جديد', {
      body: `${data.customer} - ${data.address}`,
      icon: '/icon-192.png',
      badge: '/badge.png',
      vibrate: [200, 100, 200],
      data: { deliveryId: data.id }
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(`/delivery/${event.notification.data.deliveryId}`)
  );
});

الخطوة 10 — التحقق من جودة offline

اختبار كامل: عطل الـ WiFi، استخدم التطبيق لـ 30 دقيقة (التقاط صور، تحديث حالات)، أعد تفعيل WiFi، تحقق من المزامنة.

# DevTools Chrome → Network → Offline
# نفذ:
# 1. التقاط صورة دليل (يجب أن تعمل)
# 2. تحديث حالة (يجب أن تعمل)
# 3. عرض خريطة (tiles مخزنة)
# Network → Online
# مراقبة Network: أن queue يفرغ في < 30 ثانية

الأخطاء الشائعة

الخطأ السبب الحل
IndexedDB quota exceeded صور كبيرة جداً ضغط JPEG 70% + cleanup أقدم من 30 يوم
GPS لا يعمل في خلفية iOS limits Background SW لا يدعم GPS، استخدم Push للتفعيل
Camera permission denied HTTPS مطلوب Caddy + Let’s Encrypt
OSM tiles missing offline Cache TTL منتهٍ workbox cacheFirst + 30 يوم
sync infinite loop API يعيد 5xx max attempts + cleanup
App غير قابل للتثبيت iOS manifest غير صالح تحقق Lighthouse PWA

التكيف مع السياق

أربع توضيحات. هواتف منخفضة الجودة. Tecno Camon 18 = 4 GB RAM. التطبيق يجب أن يعمل بسلاسة. كاش tiles محدود إلى 100 MB. اللغة محلية. ولوف، بامبارا، ديولا. تطبيق فرنسي + ايقونات بصرية. أزرار كبيرة لاستخدام بقفازات. صور بدقة منخفضة. JPEG 70%، 1280×720. كافٍ كدليل، يقلل البيانات وحجم التخزين. تجربة المستخدم. الأزرار الكبيرة، نص واضح، ألوان عالية التباين. الساعي على دراجة نارية، يستخدم بإصبع واحد.

دروس الإخوة

الأسئلة المتكررة

PWA vs تطبيق Android؟ PWA = توزيع فوري بدون Play Store، تحديثات تلقائية، 0% رسوم. تطبيق Android = أسرع قليلاً، push notifications أفضل iOS. للوجستية، PWA كافٍ.

تكلفة بطارية؟ GPS continuous = 30-40% بطارية في 8 ساعات. throttling 10s/60s = 15-20%.

iOS Push؟ iOS 16.4+ يدعم Web Push على PWA installé. الإصدارات الأقدم: SMS fallback.

التكوين للسعاة؟ فيديو 5 دقائق + شخص في كل تكوين فرع. الواجهة بديهية.

للاستزادة

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité