diff --git a/public/manifest.json b/public/manifest.json index 451e923..af0d36d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,32 +1,55 @@ { - "name": "StellarSplit", + "name": "StellarSplit - Decentralized Payment Splitter", "short_name": "StellarSplit", - "description": "On-chain invoice splitting on Stellar", + "description": "Split payments on Stellar blockchain with zero fees", "start_url": "/", "scope": "/", "display": "standalone", - "background_color": "#030712", - "theme_color": "#0B1628", - "orientation": "any", - "categories": ["finance","productivity"], + "orientation": "portrait-primary", + "background_color": "#ffffff", + "theme_color": "#000000", "icons": [ { - "src": "/icons/icon-192.png", + "src": "/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/icons/icon-512.png", + "src": "/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" + } + ], + "screenshots": [ + { + "src": "/screenshot-540x720.png", + "sizes": "540x720", + "type": "image/png", + "form_factor": "narrow" }, { - "src": "/icons/icon-512.png", - "sizes": "512x512", + "src": "/screenshot-1280x720.png", + "sizes": "1280x720", "type": "image/png", - "purpose": "maskable" + "form_factor": "wide" + } + ], + "shortcuts": [ + { + "name": "Create Payment", + "short_name": "Create", + "description": "Quickly create a new payment split", + "url": "/create?utm_source=shortcut" + }, + { + "name": "View History", + "short_name": "History", + "description": "View your payment history", + "url": "/history?utm_source=shortcut" } - ] -} \ No newline at end of file + ], + "categories": ["finance", "productivity"], + "prefer_related_applications": false +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e62ded2 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,143 @@ +const CACHE_VERSION = 'v1'; +const CACHE_NAMES = { + static: `${CACHE_VERSION}-static`, + dynamic: `${CACHE_VERSION}-dynamic`, + api: `${CACHE_VERSION}-api`, +}; + +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAMES.static).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (!Object.values(CACHE_NAMES).includes(cacheName)) { + return caches.delete(cacheName); + } + }) + ); + }) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + if (request.method !== 'GET') { + return; + } + + if (url.pathname.startsWith('/api/')) { + return event.respondWith(networkFirst(request)); + } + + event.respondWith(cacheFirst(request)); +}); + +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAMES.dynamic); + const cached = await cache.match(request); + + if (cached) { + return cached; + } + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch { + return new Response('Offline', { status: 503 }); + } +} + +async function networkFirst(request) { + const cache = await caches.open(CACHE_NAMES.api); + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await cache.match(request); + return cached || new Response('Offline', { status: 503 }); + } +} + +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-payments') { + event.waitUntil(syncOfflinePayments()); + } +}); + +async function syncOfflinePayments() { + const db = await openIndexedDB(); + const pendingPayments = await getAllPending(db); + + for (const payment of pendingPayments) { + try { + await fetch('/api/v1/payments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payment), + }); + await deletePending(db, payment.id); + } catch { + console.error('Sync failed for payment:', payment.id); + } + } +} + +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('StellarSplitDB', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('pendingPayments')) { + db.createObjectStore('pendingPayments', { keyPath: 'id' }); + } + }; + }); +} + +function getAllPending(db) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['pendingPayments'], 'readonly'); + const store = transaction.objectStore('pendingPayments'); + const request = store.getAll(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +function deletePending(db, id) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['pendingPayments'], 'readwrite'); + const store = transaction.objectStore('pendingPayments'); + const request = store.delete(id); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); +} diff --git a/src/lib/mobile-scanner.ts b/src/lib/mobile-scanner.ts new file mode 100644 index 0000000..c64de64 --- /dev/null +++ b/src/lib/mobile-scanner.ts @@ -0,0 +1,177 @@ +export interface ScanResult { + type: 'qr' | 'barcode' | 'payment_address'; + value: string; + timestamp: number; +} + +export class MobileScanner { + private stream: MediaStream | null = null; + private videoElement: HTMLVideoElement | null = null; + + async requestPermissions(): Promise { + try { + const permission = await navigator.permissions.query({ name: 'camera' }); + return permission.state === 'granted' || permission.state === 'prompt'; + } catch { + return true; + } + } + + async startCamera(videoElementId: string): Promise { + const video = document.getElementById(videoElementId) as HTMLVideoElement; + if (!video) throw new Error('Video element not found'); + + this.videoElement = video; + + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + + video.srcObject = this.stream; + video.play(); + } catch (error) { + throw new Error(`Camera access denied: ${error}`); + } + } + + stopCamera(): void { + if (this.stream) { + this.stream.getTracks().forEach((track) => track.stop()); + this.stream = null; + } + if (this.videoElement) { + this.videoElement.srcObject = null; + } + } + + async scanQRCode(): Promise { + if (!this.videoElement) throw new Error('Camera not started'); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return null; + + canvas.width = this.videoElement.videoWidth; + canvas.height = this.videoElement.videoHeight; + context.drawImage(this.videoElement, 0, 0); + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const result = this.decodeQR(imageData); + + if (result) { + return { + type: 'qr', + value: result, + timestamp: Date.now(), + }; + } + + return null; + } + + async scanBarcode(): Promise { + if (!this.videoElement) throw new Error('Camera not started'); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return null; + + canvas.width = this.videoElement.videoWidth; + canvas.height = this.videoElement.videoHeight; + context.drawImage(this.videoElement, 0, 0); + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const result = this.decodeBarcode(imageData); + + if (result) { + return { + type: 'barcode', + value: result, + timestamp: Date.now(), + }; + } + + return null; + } + + async scanPaymentAddress(): Promise { + const qrResult = await this.scanQRCode(); + if (!qrResult) return null; + + if (this.isValidPaymentAddress(qrResult.value)) { + return { + ...qrResult, + type: 'payment_address', + }; + } + + return null; + } + + private decodeQR(imageData: ImageData): string | null { + const pattern = /^[A-Z0-9]{56}$/; + + for (let i = 0; i < imageData.data.length; i += 4) { + const r = imageData.data[i]; + const g = imageData.data[i + 1]; + const b = imageData.data[i + 2]; + + if (r < 128 && g < 128 && b < 128) { + const byte = String.fromCharCode((r + g + b) / 3); + const address = byte.repeat(56); + + if (pattern.test(address)) { + return address; + } + } + } + + return null; + } + + private decodeBarcode(imageData: ImageData): string | null { + let barcode = ''; + let inBar = false; + let barCount = 0; + + for (let i = 0; i < imageData.data.length; i += 4) { + const r = imageData.data[i]; + const g = imageData.data[i + 1]; + const b = imageData.data[i + 2]; + const isBlack = r < 128 && g < 128 && b < 128; + + if (isBlack !== inBar) { + barcode += barCount.toString(); + barCount = 0; + inBar = isBlack; + } + barCount++; + } + + return barcode.length > 20 ? barcode : null; + } + + private isValidPaymentAddress(address: string): boolean { + const stellarPattern = /^G[A-Z2-7]{55}$/; + return stellarPattern.test(address); + } + + async linkPaymentFromScan(scanResult: ScanResult): Promise { + if (scanResult.type !== 'payment_address') { + throw new Error('Invalid payment address scan'); + } + + localStorage.setItem('lastScannedAddress', scanResult.value); + localStorage.setItem('lastScanTime', scanResult.timestamp.toString()); + } + + getLastScannedAddress(): string | null { + return localStorage.getItem('lastScannedAddress'); + } +} diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts new file mode 100644 index 0000000..b4428fe --- /dev/null +++ b/src/lib/offline-sync.ts @@ -0,0 +1,172 @@ +export interface SyncPayload { + id: string; + type: 'payment' | 'invoice' | 'split'; + data: unknown; + timestamp: number; + status: 'pending' | 'synced' | 'conflict'; + retries: number; +} + +export interface SyncConflict { + localVersion: SyncPayload; + remoteVersion: SyncPayload; + resolution: 'local' | 'remote' | 'merge'; +} + +export class OfflineSyncEngine { + private db: IDBDatabase | null = null; + private pendingQueue: Map = new Map(); + private conflicts: Map = new Map(); + + async initialize(): Promise { + this.db = await this.openDatabase(); + await this.loadPendingFromStorage(); + } + + async addPending(payload: SyncPayload): Promise { + payload.status = 'pending'; + payload.retries = 0; + this.pendingQueue.set(payload.id, payload); + await this.savePending(payload); + } + + async sync(): Promise<{ synced: number; conflicts: number }> { + let synced = 0; + let conflicts = 0; + + for (const [id, payload] of this.pendingQueue) { + try { + const response = await this.uploadPayload(payload); + + if (response.conflict) { + this.handleConflict(payload, response.remote); + conflicts++; + } else { + payload.status = 'synced'; + this.pendingQueue.delete(id); + await this.deletePending(id); + synced++; + } + } catch (error) { + payload.retries++; + if (payload.retries > 3) { + payload.status = 'pending'; + } + } + } + + return { synced, conflicts }; + } + + resolveConflict(id: string, resolution: 'local' | 'remote' | 'merge'): void { + const conflict = this.conflicts.get(id); + if (!conflict) return; + + let resolved = conflict.localVersion; + if (resolution === 'remote') { + resolved = conflict.remoteVersion; + } else if (resolution === 'merge') { + resolved = this.mergePayloads(conflict.localVersion, conflict.remoteVersion); + } + + resolved.status = 'pending'; + this.pendingQueue.set(id, resolved); + this.conflicts.delete(id); + } + + getPending(): SyncPayload[] { + return Array.from(this.pendingQueue.values()); + } + + getConflicts(): SyncConflict[] { + return Array.from(this.conflicts.values()); + } + + private async uploadPayload(payload: SyncPayload): Promise { + const endpoint = `/api/v1/${payload.type}s`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return response.json(); + } + + private handleConflict(local: SyncPayload, remote: SyncPayload): void { + this.conflicts.set(local.id, { + localVersion: local, + remoteVersion: remote, + resolution: 'local', + }); + } + + private mergePayloads(local: SyncPayload, remote: SyncPayload): SyncPayload { + return { + ...local, + data: { + ...remote.data, + ...local.data, + }, + timestamp: Math.max(local.timestamp, remote.timestamp), + }; + } + + private async openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open('StellarSplitSync', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('syncPayloads')) { + db.createObjectStore('syncPayloads', { keyPath: 'id' }); + } + }; + }); + } + + private async loadPendingFromStorage(): Promise { + if (!this.db) return; + + const transaction = this.db.transaction(['syncPayloads'], 'readonly'); + const store = transaction.objectStore('syncPayloads'); + + return new Promise((resolve) => { + const request = store.getAll(); + request.onsuccess = () => { + const payloads = request.result as SyncPayload[]; + payloads.forEach((p) => this.pendingQueue.set(p.id, p)); + resolve(); + }; + }); + } + + private async savePending(payload: SyncPayload): Promise { + if (!this.db) return; + + const transaction = this.db.transaction(['syncPayloads'], 'readwrite'); + const store = transaction.objectStore('syncPayloads'); + + return new Promise((resolve) => { + const request = store.put(payload); + request.onsuccess = () => resolve(); + }); + } + + private async deletePending(id: string): Promise { + if (!this.db) return; + + const transaction = this.db.transaction(['syncPayloads'], 'readwrite'); + const store = transaction.objectStore('syncPayloads'); + + return new Promise((resolve) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + }); + } +}