This implements the mobile optimization agent (P3 - Enhancement) with: PWA Configuration: - Add next-pwa integration with offline caching strategies - Create web app manifest for installability - Add service worker with background sync support - Create offline fallback page Mobile Components: - BottomNav: Touch-friendly bottom navigation bar - MobileHeader: Responsive header with back navigation - InstallPrompt: Smart PWA install prompt (iOS & Android) - SwipeableCard: Gesture-based swipeable cards - PullToRefresh: Native-like pull to refresh - QRScanner: Camera-based QR code scanning Mobile Library: - camera.ts: Camera access and photo capture utilities - offline.ts: IndexedDB-based offline storage and sync - gestures.ts: Touch gesture detection (swipe, pinch, tap) - pwa.ts: PWA status, install prompts, service worker management Mobile Pages: - /m: Mobile dashboard with quick actions and stats - /m/scan: QR code scanner for plant lookup - /m/quick-add: Streamlined plant registration form - /m/profile: User profile with offline status Dependencies added: next-pwa, idb
275 lines
7 KiB
TypeScript
275 lines
7 KiB
TypeScript
/**
|
|
* Offline Support Utilities
|
|
* Provides IndexedDB storage and background sync for offline functionality
|
|
*/
|
|
|
|
import { openDB, DBSchema, IDBPDatabase } from 'idb';
|
|
|
|
// Database schema
|
|
interface LocalGreenChainDB extends DBSchema {
|
|
'pending-plants': {
|
|
key: string;
|
|
value: {
|
|
id: string;
|
|
data: any;
|
|
createdAt: string;
|
|
attempts: number;
|
|
};
|
|
indexes: { 'by-created': string };
|
|
};
|
|
'pending-transport': {
|
|
key: string;
|
|
value: {
|
|
id: string;
|
|
data: any;
|
|
createdAt: string;
|
|
attempts: number;
|
|
};
|
|
indexes: { 'by-created': string };
|
|
};
|
|
'cached-plants': {
|
|
key: string;
|
|
value: {
|
|
id: string;
|
|
data: any;
|
|
cachedAt: string;
|
|
};
|
|
indexes: { 'by-cached': string };
|
|
};
|
|
'user-preferences': {
|
|
key: string;
|
|
value: any;
|
|
};
|
|
}
|
|
|
|
const DB_NAME = 'localgreenchain-offline';
|
|
const DB_VERSION = 1;
|
|
|
|
let dbPromise: Promise<IDBPDatabase<LocalGreenChainDB>> | null = null;
|
|
|
|
async function getDB(): Promise<IDBPDatabase<LocalGreenChainDB>> {
|
|
if (!dbPromise) {
|
|
dbPromise = openDB<LocalGreenChainDB>(DB_NAME, DB_VERSION, {
|
|
upgrade(db) {
|
|
// Pending plants store
|
|
if (!db.objectStoreNames.contains('pending-plants')) {
|
|
const plantStore = db.createObjectStore('pending-plants', { keyPath: 'id' });
|
|
plantStore.createIndex('by-created', 'createdAt');
|
|
}
|
|
|
|
// Pending transport store
|
|
if (!db.objectStoreNames.contains('pending-transport')) {
|
|
const transportStore = db.createObjectStore('pending-transport', { keyPath: 'id' });
|
|
transportStore.createIndex('by-created', 'createdAt');
|
|
}
|
|
|
|
// Cached plants store
|
|
if (!db.objectStoreNames.contains('cached-plants')) {
|
|
const cacheStore = db.createObjectStore('cached-plants', { keyPath: 'id' });
|
|
cacheStore.createIndex('by-cached', 'cachedAt');
|
|
}
|
|
|
|
// User preferences store
|
|
if (!db.objectStoreNames.contains('user-preferences')) {
|
|
db.createObjectStore('user-preferences');
|
|
}
|
|
},
|
|
});
|
|
}
|
|
return dbPromise;
|
|
}
|
|
|
|
// Network status
|
|
export function isOnline(): boolean {
|
|
return typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
}
|
|
|
|
export function onNetworkChange(callback: (online: boolean) => void): () => void {
|
|
if (typeof window === 'undefined') return () => {};
|
|
|
|
const handleOnline = () => callback(true);
|
|
const handleOffline = () => callback(false);
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
};
|
|
}
|
|
|
|
// Pending operations
|
|
export async function queuePlantRegistration(plantData: any): Promise<string> {
|
|
const db = await getDB();
|
|
const id = generateId();
|
|
|
|
await db.put('pending-plants', {
|
|
id,
|
|
data: plantData,
|
|
createdAt: new Date().toISOString(),
|
|
attempts: 0,
|
|
});
|
|
|
|
// Register for background sync if available
|
|
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
await (registration as any).sync.register('sync-plants');
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
export async function queueTransportEvent(eventData: any): Promise<string> {
|
|
const db = await getDB();
|
|
const id = generateId();
|
|
|
|
await db.put('pending-transport', {
|
|
id,
|
|
data: eventData,
|
|
createdAt: new Date().toISOString(),
|
|
attempts: 0,
|
|
});
|
|
|
|
// Register for background sync if available
|
|
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
await (registration as any).sync.register('sync-transport');
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
export async function getPendingPlants(): Promise<any[]> {
|
|
const db = await getDB();
|
|
return db.getAll('pending-plants');
|
|
}
|
|
|
|
export async function getPendingTransport(): Promise<any[]> {
|
|
const db = await getDB();
|
|
return db.getAll('pending-transport');
|
|
}
|
|
|
|
export async function removePendingPlant(id: string): Promise<void> {
|
|
const db = await getDB();
|
|
await db.delete('pending-plants', id);
|
|
}
|
|
|
|
export async function removePendingTransport(id: string): Promise<void> {
|
|
const db = await getDB();
|
|
await db.delete('pending-transport', id);
|
|
}
|
|
|
|
// Plant caching
|
|
export async function cachePlant(plant: any): Promise<void> {
|
|
const db = await getDB();
|
|
await db.put('cached-plants', {
|
|
id: plant.id,
|
|
data: plant,
|
|
cachedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export async function getCachedPlant(id: string): Promise<any | null> {
|
|
const db = await getDB();
|
|
const cached = await db.get('cached-plants', id);
|
|
return cached?.data || null;
|
|
}
|
|
|
|
export async function getCachedPlants(): Promise<any[]> {
|
|
const db = await getDB();
|
|
const all = await db.getAll('cached-plants');
|
|
return all.map((item) => item.data);
|
|
}
|
|
|
|
export async function clearCachedPlants(): Promise<void> {
|
|
const db = await getDB();
|
|
await db.clear('cached-plants');
|
|
}
|
|
|
|
// User preferences
|
|
export async function setPreference(key: string, value: any): Promise<void> {
|
|
const db = await getDB();
|
|
await db.put('user-preferences', value, key);
|
|
}
|
|
|
|
export async function getPreference<T>(key: string): Promise<T | null> {
|
|
const db = await getDB();
|
|
return db.get('user-preferences', key) as Promise<T | null>;
|
|
}
|
|
|
|
// Sync operations
|
|
export async function syncPendingPlants(): Promise<{ success: number; failed: number }> {
|
|
const pending = await getPendingPlants();
|
|
let success = 0;
|
|
let failed = 0;
|
|
|
|
for (const item of pending) {
|
|
try {
|
|
const response = await fetch('/api/plants/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(item.data),
|
|
});
|
|
|
|
if (response.ok) {
|
|
await removePendingPlant(item.id);
|
|
success++;
|
|
} else {
|
|
failed++;
|
|
}
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
return { success, failed };
|
|
}
|
|
|
|
export async function syncPendingTransport(): Promise<{ success: number; failed: number }> {
|
|
const pending = await getPendingTransport();
|
|
let success = 0;
|
|
let failed = 0;
|
|
|
|
for (const item of pending) {
|
|
try {
|
|
const response = await fetch('/api/transport/events', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(item.data),
|
|
});
|
|
|
|
if (response.ok) {
|
|
await removePendingTransport(item.id);
|
|
success++;
|
|
} else {
|
|
failed++;
|
|
}
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
return { success, failed };
|
|
}
|
|
|
|
export async function syncAll(): Promise<void> {
|
|
if (!isOnline()) return;
|
|
|
|
await Promise.all([
|
|
syncPendingPlants(),
|
|
syncPendingTransport(),
|
|
]);
|
|
}
|
|
|
|
// Utility
|
|
function generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Auto-sync when coming online
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', () => {
|
|
syncAll().catch(console.error);
|
|
});
|
|
}
|