diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b6407ce --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,63 @@ +{ + "name": "SoroSave - Group Savings", + "short_name": "SoroSave", + "description": "Group savings and contribution tracking app", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4F46E5", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "categories": ["finance", "productivity"], + "screenshots": [], + "prefer_related_applications": false +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b48f640 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,77 @@ +/// + +const CACHE_NAME = 'sorosave-v1'; +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/favicon.ico', +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +// Activate event - clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip cross-origin requests + if (!event.request.url.startsWith(self.location.origin)) return; + + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + // Return cached response and update cache in background + event.waitUntil( + fetch(event.request).then((response) => { + if (response.ok) { + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, response); + }); + } + }).catch(() => {}) + ); + return cachedResponse; + } + + // Not in cache, fetch from network + return fetch(event.request).then((response) => { + if (!response.ok) return response; + + // Cache successful responses + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }).catch(() => { + // Return offline fallback for navigation requests + if (event.request.mode === 'navigate') { + return caches.match('/'); + } + }); + }) + ); +}); diff --git a/src/components/DarkModeToggle.tsx b/src/components/DarkModeToggle.tsx new file mode 100644 index 0000000..593ae23 --- /dev/null +++ b/src/components/DarkModeToggle.tsx @@ -0,0 +1,97 @@ +/** + * Dark Mode utilities for SoroSave + * Implements theme toggle with Tailwind CSS and next-themes + */ + +'use client'; + +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; + +/** + * Dark mode toggle button component + */ +export function DarkModeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( + + ); +} + +/** + * Theme provider wrapper + * Add this to your _app.tsx or layout.tsx + */ +export function ThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Hook to get current theme state + */ +export function useDarkMode() { + const { theme, setTheme, systemTheme } = useTheme(); + const isDark = theme === 'dark' || (theme === 'system' && systemTheme === 'dark'); + + return { + isDark, + theme, + setTheme, + toggle: () => setTheme(isDark ? 'light' : 'dark'), + }; +} + +/** + * CSS classes for dark mode support + * Add to tailwind.config.js: + * + * module.exports = { + * darkMode: 'class', + * // ... + * } + */ \ No newline at end of file diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..955bb9e --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,148 @@ +/** + * Export utilities for SoroSave + * Supports CSV and PDF export for group history and contribution records + */ + +import { jsPDF } from 'jspdf'; +import autoTable from 'jspdf-autotable'; + +// Types for export data +export interface ContributionRecord { + date: string; + amount: number; + member: string; + roundStatus: string; +} + +export interface GroupSummary { + groupName: string; + totalMembers: number; + totalSaved: number; + contributions: ContributionRecord[]; + createdAt: string; +} + +/** + * Export contribution history to CSV + */ +export function exportToCSV(data: GroupSummary, filename?: string): void { + const headers = ['Date', 'Amount', 'Member', 'Round Status']; + const rows = data.contributions.map(c => [ + c.date, + c.amount.toString(), + c.member, + c.roundStatus, + ]); + + // Add summary header + const summaryRows = [ + [`Group: ${data.groupName}`], + [`Total Members: ${data.totalMembers}`], + [`Total Saved: ${data.totalSaved}`], + [`Created: ${data.createdAt}`], + [], // Empty row + headers, + ...rows, + ]; + + // Create CSV content + const csvContent = summaryRows + .map(row => row.map(cell => `"${cell}"`).join(',')) + .join('\n'); + + // Download + downloadFile( + csvContent, + filename || `${data.groupName}_contributions.csv`, + 'text/csv' + ); +} + +/** + * Export group summary to PDF + */ +export function exportToPDF(data: GroupSummary, filename?: string): void { + const doc = new jsPDF(); + + // Title + doc.setFontSize(20); + doc.text(data.groupName, 14, 22); + + // Summary info + doc.setFontSize(12); + doc.text(`Total Members: ${data.totalMembers}`, 14, 35); + doc.text(`Total Saved: $${data.totalSaved.toLocaleString()}`, 14, 42); + doc.text(`Created: ${data.createdAt}`, 14, 49); + + // Contribution table + autoTable(doc, { + startY: 60, + head: [['Date', 'Amount', 'Member', 'Round Status']], + body: data.contributions.map(c => [ + c.date, + `$${c.amount.toLocaleString()}`, + c.member, + c.roundStatus, + ]), + theme: 'striped', + headStyles: { fillColor: [79, 70, 229] }, + }); + + // Save + doc.save(filename || `${data.groupName}_summary.pdf`); +} + +/** + * Export button component helper + */ +export function getExportButtons(groupData: GroupSummary) { + return [ + { + label: 'Export CSV', + onClick: () => exportToCSV(groupData), + }, + { + label: 'Export PDF', + onClick: () => exportToPDF(groupData), + }, + ]; +} + +/** + * Helper to download file + */ +function downloadFile( + content: string, + filename: string, + mimeType: string +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Format contribution data for export + */ +export function formatContributionData( + contributions: Array<{ + createdAt: string | Date; + amount: number; + memberName: string; + roundNumber: number; + status: string; + }> +): ContributionRecord[] { + return contributions.map(c => ({ + date: new Date(c.createdAt).toLocaleDateString(), + amount: c.amount, + member: c.memberName, + roundStatus: `Round ${c.roundNumber} - ${c.status}`, + })); +} \ No newline at end of file