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