diff --git a/package-lock.json b/package-lock.json index d6e7bfc..9e4bf3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,9 @@ "react-joyride": "^3.1.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "vite-plugin-pwa": "^1.3.0", + "workbox-window": "^7.4.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -30,6 +32,7 @@ "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-react": "^6.0.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", @@ -46,7 +49,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.7", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -243,6 +247,9 @@ }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-plugin-utils": { @@ -1408,7 +1415,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", - "optional": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -1431,7 +1437,13 @@ "license": "MIT", "optional": true, "dependencies": { - "tslib": "^2.4.0" + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/@epic-web/invariant": { @@ -1546,7 +1558,7 @@ "node_modules/@eslint/js": { "version": "9.39.4", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4356,9 +4368,7 @@ "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "hasown": "^2.0.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6212,7 +6222,7 @@ "node": "^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/protobufjs": { @@ -6477,7 +6487,10 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/require-from-string": { diff --git a/package.json b/package.json index 1525380..20f5fed 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "react-joyride": "^3.1.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "vite-plugin-pwa": "^1.3.0", + "workbox-window": "^7.4.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -38,6 +40,7 @@ "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-react": "^6.0.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", diff --git a/src/main.tsx b/src/main.tsx index c495c46..6187699 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,11 +4,14 @@ import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' import './index.css' import App from './App.tsx' +import { initTheme } from './lib/theme'; +import { syncOfflineScans } from './utils/syncManager' import { initTheme } from './lib/theme' import "./i18n"; // Initialize theme before rendering the app to prevent flicker initTheme(); +syncOfflineScans() // PostHog is only initialized when the key is present. // Contributors running locally without the key will have it silently disabled. diff --git a/src/utils/offlineQueue.ts b/src/utils/offlineQueue.ts new file mode 100644 index 0000000..928ea02 --- /dev/null +++ b/src/utils/offlineQueue.ts @@ -0,0 +1,44 @@ +const DB_NAME = 'FreshScanDB' +const STORE_NAME = 'scanQueue' + +export function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1) + req.onupgradeneeded = (e: IDBVersionChangeEvent) => { + const db = (e.target as IDBOpenDBRequest).result + db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }) + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +export async function queueScan(scanData: Record) { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).add({ ...scanData, timestamp: Date.now() }) + tx.oncomplete = resolve + tx.onerror = () => reject(tx.error) + }) +} + +export async function getPendingScans() { + const db = await openDB() + return new Promise[]>((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const req = tx.objectStore(STORE_NAME).getAll() + req.onsuccess = () => resolve(req.result as Record[]) + req.onerror = () => reject(req.error) + }) +} + +export async function removeScan(id: number) { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).delete(id) + tx.oncomplete = resolve + tx.onerror = () => reject(tx.error) + }) +} \ No newline at end of file diff --git a/src/utils/syncManager.ts b/src/utils/syncManager.ts new file mode 100644 index 0000000..a8060d8 --- /dev/null +++ b/src/utils/syncManager.ts @@ -0,0 +1,40 @@ +import { getPendingScans, removeScan } from './offlineQueue' + +// 1. Define a clean interface for a Scan item to resolve type issues +interface ScanItem { + id: number; + [key: string]: unknown; // Allows any other dynamic fields +} + +async function uploadScan(scan: ScanItem) { + const response = await fetch('/api/scans', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scan), + }) + if (!response.ok) throw new Error('Upload failed') + return response.json() +} + +export async function syncOfflineScans() { + if (!navigator.onLine) return + + // Cast the pending scans array to our ScanItem interface + const pending = (await getPendingScans()) as ScanItem[] + + for (const scan of pending) { + try { + await uploadScan(scan) + await removeScan(scan.id) + console.log(`✅ Synced scan ID: ${scan.id}`) + } catch (err) { + console.error(`❌ Failed to sync scan ${scan.id}:`, err) + } + } +} + +// Auto sync when internet comes back +window.addEventListener('online', () => { + console.log('🌐 Back online – syncing offline scans...') + syncOfflineScans() +}) \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index d85ccae..49acbcb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { VitePWA } from 'vite-plugin-pwa' import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; @@ -10,6 +14,45 @@ export default defineConfig({ tailwindcss(), VitePWA({ registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png'], + manifest: { + name: 'FreshScan AI', + short_name: 'FreshScan', + description: 'AI-powered fish freshness scanner', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png' + } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + runtimeCaching: [ + { + urlPattern: /^https?:\/\/localhost:8000\/api\//, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 50, + maxAgeSeconds: 60 * 60 * 24 + } + } + } + ] + } + }) + ], // Use the existing manifest.json in public/ manifest: false, workbox: { @@ -45,10 +88,6 @@ export default defineConfig({ ], server: { - // In local dev, proxy /api/* to the FastAPI backend at :8000. - // This avoids CORS issues and means the frontend never needs to - // hard-code the backend port. - // In production, VITE_API_URL is set externally so this block is unused. proxy: { '/api': { target: 'http://localhost:8000', @@ -56,4 +95,5 @@ export default defineConfig({ }, }, }, +}) });