Skip to content
Open
21 changes: 21 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "SoroSave",
"short_name": "SoroSave",
"description": "Group savings on Stellar",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
7 changes: 7 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const CACHE_NAME = 'sorosave-v1';
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(['/', '/groups'])));
});
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request).then((r) => r || fetch(event.request)));
});
180 changes: 180 additions & 0 deletions src/app/invite/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"use client";

import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';

interface GroupDetails {
id: number;
name: string;
description: string;
admin: string;
maxMembers: number;
currentMembers: number;
contributionAmount: string;
status: 'Forming' | 'Active' | 'Completed';
}

export default function InvitePage() {
const { code } = useParams();
const router = useRouter();
const [group, setGroup] = useState<GroupDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [joining, setJoining] = useState(false);
const [joinSuccess, setJoinSuccess] = useState(false);
const [walletConnected, setWalletConnected] = useState(false);

useEffect(() => {
if (code) {
loadGroupDetails();
}
}, [code]);

const loadGroupDetails = async () => {
setLoading(true);
try {
// Decode the invite code (base64 encoded group ID)
const groupId = atob(code as string);

// Mock API call - replace with actual SDK call
const mockGroup: GroupDetails = {
id: parseInt(groupId),
name: 'Weekly Savings Circle',
description: 'A group for weekly savings with friends and family',
admin: 'GABC123...',
maxMembers: 10,
currentMembers: 4,
contributionAmount: '50 USDC',
status: 'Forming',
};

setGroup(mockGroup);
} catch (err) {
setError('Invalid invitation link');
} finally {
setLoading(false);
}
};

const handleJoin = async () => {
if (!walletConnected) {
// Trigger wallet connection
alert('Please connect your wallet first');
return;
}

setJoining(true);
try {
// Mock join API call
await new Promise(resolve => setTimeout(resolve, 1500));
setJoinSuccess(true);
} catch (err) {
setError('Failed to join group. Please try again.');
} finally {
setJoining(false);
}
};

if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}

if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Oops!</h1>
<p className="text-gray-600">{error}</p>
</div>
</div>
);
}

if (joinSuccess) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="text-6xl mb-4">🎉</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">You're In!</h1>
<p className="text-gray-600 mb-6">
You've successfully joined <strong>{group?.name}</strong>.
Head to the group page to start contributing.
</p>
<button
onClick={() => router.push(`/groups/${group?.id}`)}
className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700"
>
Go to Group
</button>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Group Header */}
<div className="text-center mb-6">
<div className="text-4xl mb-2">🤝</div>
<h1 className="text-2xl font-bold text-gray-900">{group?.name}</h1>
<p className="text-gray-600 mt-2">{group?.description}</p>
</div>

{/* Group Details */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Contribution</p>
<p className="font-semibold">{group?.contributionAmount}</p>
</div>
<div>
<p className="text-gray-500">Members</p>
<p className="font-semibold">{group?.currentMembers}/{group?.maxMembers}</p>
</div>
<div>
<p className="text-gray-500">Status</p>
<p className={`font-semibold ${
group?.status === 'Active' ? 'text-green-600' : 'text-yellow-600'
}`}>
{group?.status}
</p>
</div>
<div>
<p className="text-gray-500">Admin</p>
<p className="font-semibold">{group?.admin}</p>
</div>
</div>
</div>

{/* Wallet Warning */}
{!walletConnected && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p className="text-yellow-800 text-sm">
⚠️ Please connect your wallet before joining
</p>
</div>
)}

{/* Join Button */}
<button
onClick={handleJoin}
disabled={joining}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{joining ? 'Joining...' : walletConnected ? 'Join Group' : 'Connect Wallet to Join'}
</button>

<p className="text-xs text-gray-500 text-center mt-4">
By joining, you agree to the group terms and conditions
</p>
</div>
</div>
</div>
);
}
143 changes: 143 additions & 0 deletions src/components/GroupAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import { useState, useEffect } from 'react';

interface AnalyticsData {
contributions: { date: string; amount: number }[];
payouts: { recipient: string; amount: number }[];
memberParticipation: { name: string; percentage: number }[];
}

export function GroupAnalytics({ groupId }: { groupId: number }) {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [activeChart, setActiveChart] = useState<'contributions' | 'payouts' | 'members'>('contributions');

useEffect(() => {
loadAnalytics();
}, [groupId]);

const loadAnalytics = async () => {
setLoading(true);
try {
// Mock data - replace with actual API calls
const mockData: AnalyticsData = {
contributions: [
{ date: '2026-01', amount: 100 },
{ date: '2026-02', amount: 150 },
{ date: '2026-03', amount: 200 },
],
payouts: [
{ recipient: 'Alice', amount: 450 },
{ recipient: 'Bob', amount: 450 },
{ recipient: 'Charlie', amount: 450 },
],
memberParticipation: [
{ name: 'Alice', percentage: 100 },
{ name: 'Bob', percentage: 85 },
{ name: 'Charlie', percentage: 70 },
],
};
setData(mockData);
} catch (error) {
console.error('Failed to load analytics:', error);
} finally {
setLoading(false);
}
};

const maxContribution = data ? Math.max(...data.contributions.map(c => c.amount)) : 0;
const maxPayout = data ? Math.max(...data.payouts.map(p => p.amount)) : 0;

return (
<div className="space-y-6">
{/* Chart Tabs */}
<div className="flex gap-2">
{(['contributions', 'payouts', 'members'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveChart(tab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeChart === tab
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{tab === 'contributions' ? '📈 Contributions' :
tab === 'payouts' ? '💰 Payouts' : '👥 Members'}
</button>
))}
</div>

{/* Charts */}
{loading ? (
<div className="animate-pulse bg-gray-200 h-64 rounded-lg" />
) : (
<div className="bg-white p-6 rounded-lg border border-gray-200">
{/* Contributions Line Chart (ASCII fallback) */}
{activeChart === 'contributions' && data && (
<div>
<h3 className="text-lg font-semibold mb-4">Contribution History</h3>
<div className="space-y-3">
{data.contributions.map((c, i) => (
<div key={c.date} className="flex items-center gap-3">
<span className="text-sm text-gray-500 w-16">{c.date}</span>
<div className="flex-1 bg-gray-100 rounded-full h-6 overflow-hidden">
<div
className="bg-primary-500 h-full rounded-full transition-all duration-500"
style={{ width: `${(c.amount / maxContribution) * 100}%` }}
/>
</div>
<span className="text-sm font-medium w-16 text-right">${c.amount}</span>
</div>
))}
</div>
</div>
)}

{/* Payouts Bar Chart */}
{activeChart === 'payouts' && data && (
<div>
<h3 className="text-lg font-semibold mb-4">Payout Distribution</h3>
<div className="space-y-3">
{data.payouts.map((p) => (
<div key={p.recipient} className="flex items-center gap-3">
<span className="text-sm text-gray-500 w-20">{p.recipient}</span>
<div className="flex-1 bg-gray-100 rounded-full h-6 overflow-hidden">
<div
className="bg-green-500 h-full rounded-full transition-all duration-500"
style={{ width: `${(p.amount / maxPayout) * 100}%` }}
/>
</div>
<span className="text-sm font-medium w-16 text-right">${p.amount}</span>
</div>
))}
</div>
</div>
)}

{/* Member Participation Pie (as bars) */}
{activeChart === 'members' && data && (
<div>
<h3 className="text-lg font-semibold mb-4">Member Participation</h3>
<div className="space-y-3">
{data.memberParticipation.map((m) => (
<div key={m.name} className="flex items-center gap-3">
<span className="text-sm text-gray-500 w-20">{m.name}</span>
<div className="flex-1 bg-gray-100 rounded-full h-6 overflow-hidden">
<div
className="bg-purple-500 h-full rounded-full transition-all duration-500"
style={{ width: `${m.percentage}%` }}
/>
</div>
<span className="text-sm font-medium w-12 text-right">{m.percentage}%</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
Loading