Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
],
"categories": ["finance", "productivity"],
"prefer_related_applications": false
}
143 changes: 143 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
177 changes: 177 additions & 0 deletions src/lib/mobile-scanner.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
try {
const permission = await navigator.permissions.query({ name: 'camera' });
return permission.state === 'granted' || permission.state === 'prompt';
} catch {
return true;
}
}

async startCamera(videoElementId: string): Promise<void> {
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<ScanResult | null> {
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<ScanResult | null> {
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<ScanResult | null> {
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<void> {
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');
}
}
Loading