diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..af73b9b --- /dev/null +++ b/public/manifest.json @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..c4e2272 --- /dev/null +++ b/public/sw.js @@ -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))); +}); diff --git a/src/app/invite/[code]/page.tsx b/src/app/invite/[code]/page.tsx new file mode 100644 index 0000000..3c4f022 --- /dev/null +++ b/src/app/invite/[code]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Oops!

+

{error}

+
+
+ ); + } + + if (joinSuccess) { + return ( +
+
+
🎉
+

You're In!

+

+ You've successfully joined {group?.name}. + Head to the group page to start contributing. +

+ +
+
+ ); + } + + return ( +
+
+
+ {/* Group Header */} +
+
🤝
+

{group?.name}

+

{group?.description}

+
+ + {/* Group Details */} +
+
+
+

Contribution

+

{group?.contributionAmount}

+
+
+

Members

+

{group?.currentMembers}/{group?.maxMembers}

+
+
+

Status

+

+ {group?.status} +

+
+
+

Admin

+

{group?.admin}

+
+
+
+ + {/* Wallet Warning */} + {!walletConnected && ( +
+

+ ⚠️ Please connect your wallet before joining +

+
+ )} + + {/* Join Button */} + + +

+ By joining, you agree to the group terms and conditions +

+
+
+
+ ); +} diff --git a/src/components/GroupAnalytics.tsx b/src/components/GroupAnalytics.tsx new file mode 100644 index 0000000..096a3ba --- /dev/null +++ b/src/components/GroupAnalytics.tsx @@ -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(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 ( +
+ {/* Chart Tabs */} +
+ {(['contributions', 'payouts', 'members'] as const).map((tab) => ( + + ))} +
+ + {/* Charts */} + {loading ? ( +
+ ) : ( +
+ {/* Contributions Line Chart (ASCII fallback) */} + {activeChart === 'contributions' && data && ( +
+

Contribution History

+
+ {data.contributions.map((c, i) => ( +
+ {c.date} +
+
+
+ ${c.amount} +
+ ))} +
+
+ )} + + {/* Payouts Bar Chart */} + {activeChart === 'payouts' && data && ( +
+

Payout Distribution

+
+ {data.payouts.map((p) => ( +
+ {p.recipient} +
+
+
+ ${p.amount} +
+ ))} +
+
+ )} + + {/* Member Participation Pie (as bars) */} + {activeChart === 'members' && data && ( +
+

Member Participation

+
+ {data.memberParticipation.map((m) => ( +
+ {m.name} +
+
+
+ {m.percentage}% +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/GroupChat.tsx b/src/components/GroupChat.tsx new file mode 100644 index 0000000..46c20fa --- /dev/null +++ b/src/components/GroupChat.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState, useEffect, useRef } from 'react'; + +interface Message { + id: string; + sender: string; + content: string; + timestamp: number; +} + +interface GroupChatProps { + groupId: number; + walletAddress: string; +} + +export function GroupChat({ groupId, walletAddress }: GroupChatProps) { + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const messagesEndRef = useRef(null); + + useEffect(() => { + if (isOpen) { + connectChat(); + } + }, [isOpen]); + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const connectChat = async () => { + // Mock XMTP connection - replace with actual XMTP SDK + setIsConnected(true); + + // Load mock messages + const mockMessages: Message[] = [ + { id: '1', sender: 'GABC123...', content: 'Hey everyone! Ready for this cycle?', timestamp: Date.now() - 3600000 }, + { id: '2', sender: 'GDEF456...', content: 'Yes! Just contributed.', timestamp: Date.now() - 1800000 }, + { id: '3', sender: 'GHIJ789...', content: 'Same here. Excited!', timestamp: Date.now() - 600000 }, + ]; + setMessages(mockMessages); + }; + + const sendMessage = async () => { + if (!newMessage.trim()) return; + + const message: Message = { + id: Date.now().toString(), + sender: walletAddress.slice(0, 8) + '...', + content: newMessage, + timestamp: Date.now(), + }; + + setMessages([...messages, message]); + setNewMessage(''); + + // Here you would actually send via XMTP + // await xmtpClient.sendMessage(groupId.toString(), newMessage); + }; + + const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const formatAddress = (address: string) => { + return address.length > 12 ? address.slice(0, 6) + '...' + address.slice(-4) : address; + }; + + return ( + <> + {/* Chat Toggle Button */} + + + {/* Chat Sidebar */} + {isOpen && ( +
+ {/* Header */} +
+
+

Group Chat

+

+ {isConnected ? '🟢 Connected' : '🔴 Disconnected'} +

+
+ +
+ + {/* Messages */} +
+ {messages.map((msg) => { + const isOwn = msg.sender === walletAddress.slice(0, 8) + '...' || + msg.sender === formatAddress(walletAddress); + return ( +
+
+

{msg.sender}

+

{msg.content}

+

+ {formatTime(msg.timestamp)} +

+
+
+ ); + })} +
+
+ + {/* Input */} +
+
+ setNewMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && sendMessage()} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + +
+
+
+ )} + + ); +} diff --git a/src/components/GroupCompare.tsx b/src/components/GroupCompare.tsx new file mode 100644 index 0000000..ef00c86 --- /dev/null +++ b/src/components/GroupCompare.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from 'react'; + +interface GroupData { + id: number; + name: string; + contributionAmount: string; + cycleLength: string; + currentMembers: number; + maxMembers: number; + status: string; + description: string; +} + +interface GroupCompareProps { + groups: GroupData[]; +} + +export function GroupCompare({ groups }: GroupCompareProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + + const toggleGroup = (id: number) => { + if (selectedIds.includes(id)) { + setSelectedIds(selectedIds.filter(i => i !== id)); + } else if (selectedIds.length < 3) { + setSelectedIds([...selectedIds, id]); + } + }; + + const selectedGroups = groups.filter(g => selectedIds.includes(g.id)); + + const getStatusColor = (status: string) => { + switch (status) { + case 'Active': return 'bg-green-100 text-green-700'; + case 'Forming': return 'bg-yellow-100 text-yellow-700'; + case 'Completed': return 'bg-gray-100 text-gray-700'; + default: return 'bg-gray-100 text-gray-500'; + } + }; + + return ( + <> + {/* Compare Button */} + {selectedIds.length >= 2 && ( + + )} + + {/* Comparison Modal */} + {isOpen && ( +
+
+ {/* Header */} +
+

Compare Groups

+ +
+ + {/* Comparison Table */} +
+ + + + + {selectedGroups.map(g => ( + + ))} + + + + + + {selectedGroups.map(g => ( + + ))} + + + + {selectedGroups.map(g => ( + + ))} + + + + {selectedGroups.map(g => ( + + ))} + + + + {selectedGroups.map(g => ( + + ))} + + + + {selectedGroups.map(g => ( + + ))} + + +
Feature{g.name}
Status + + {g.status} + +
Contribution{g.contributionAmount}
Cycle Length{g.cycleLength}
Members{g.currentMembers}/{g.maxMembers}
Description{g.description}
+ + {/* Differences Highlight */} +
+

💡 Recommendation

+

+ {selectedGroups.length > 0 && ( + <> + {selectedGroups.sort((a, b) => { + const aVal = parseInt(a.contributionAmount.replace(/[^0-9]/g, '')); + const bVal = parseInt(b.contributionAmount.replace(/[^0-9]/g, '')); + return bVal - aVal; + })[0]?.name} has the highest contribution amount. + + )} +

+
+
+
+
+ )} + + ); +} + +// Checkbox component for GroupCard +export function CompareCheckbox({ groupId, checked, onChange }: { + groupId: number; + checked: boolean; + onChange: (id: number) => void; +}) { + return ( + onChange(groupId)} + className="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500" + title="Select to compare (max 3)" + /> + ); +} diff --git a/src/components/GroupRecommendations.tsx b/src/components/GroupRecommendations.tsx new file mode 100644 index 0000000..5a0d1eb --- /dev/null +++ b/src/components/GroupRecommendations.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { SavingsGroup } from '@sorosave/sdk'; + +interface Recommendation { + id: number; + name: string; + reason: 'popular' | 'new' | 'similar'; + memberCount: number; + status: string; +} + +export function GroupRecommendations() { + const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'all' | 'popular' | 'new' | 'similar'>('all'); + + useEffect(() => { + // Fetch recommendations from API + loadRecommendations(); + }, []); + + const loadRecommendations = async () => { + setLoading(true); + try { + // Mock data - in production, this would call your recommendation API + const mockRecommendations: Recommendation[] = [ + { id: 1, name: 'Weekly Savings Circle', reason: 'popular', memberCount: 8, status: 'Active' }, + { id: 2, name: 'Vacation Fund 2026', reason: 'new', memberCount: 2, status: 'Forming' }, + { id: 3, name: 'Emergency Fund', reason: 'similar', memberCount: 5, status: 'Active' }, + { id: 4, name: 'Tech Gadget Pool', reason: 'popular', memberCount: 6, status: 'Active' }, + { id: 5, name: 'Holiday Shopping', reason: 'new', memberCount: 1, status: 'Forming' }, + ]; + setRecommendations(mockRecommendations); + } catch (error) { + console.error('Failed to load recommendations:', error); + } finally { + setLoading(false); + } + }; + + const filteredRecommendations = filter === 'all' + ? recommendations + : recommendations.filter(r => r.reason === filter); + + const getReasonLabel = (reason: string) => { + switch (reason) { + case 'popular': return '🔥 Popular'; + case 'new': return '✨ New'; + case 'similar': return '👤 Similar'; + default: return reason; + } + }; + + return ( +
+ {/* Filter Tabs */} +
+ {(['all', 'popular', 'new', 'similar'] as const).map((f) => ( + + ))} +
+ + {/* Recommendations Grid */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( +
+ {filteredRecommendations.map((group) => ( +
+
+

{group.name}

+ + {group.status} + +
+
+ {group.memberCount} members + {getReasonLabel(group.reason)} +
+ +
+ ))} +
+ )} + + {filteredRecommendations.length === 0 && !loading && ( +
+ No recommendations found. Try a different filter! +
+ )} +
+ ); +} diff --git a/src/components/InstallPrompt.tsx b/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..da8834b --- /dev/null +++ b/src/components/InstallPrompt.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showPrompt, setShowPrompt] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem('sorosave_pwa_dismissed'); + if (stored) setDismissed(true); + + const handler = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + if (!stored) setShowPrompt(true); + }; + + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const handleInstall = async () => { + if (!deferredPrompt) return; + await deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === 'accepted') { + setShowPrompt(false); + } + setDeferredPrompt(null); + }; + + const handleDismiss = () => { + setShowPrompt(false); + setDismissed(true); + localStorage.setItem('sorosave_pwa_dismissed', 'true'); + }; + + if (!showPrompt) return null; + + return ( +
+
+
📱
+
+

Install SoroSave

+

+ Add to your home screen for quick access +

+
+
+
+ + +
+
+ ); +} diff --git a/src/components/InviteButton.tsx b/src/components/InviteButton.tsx new file mode 100644 index 0000000..9362f6c --- /dev/null +++ b/src/components/InviteButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from 'react'; + +interface InviteButtonProps { + groupId: number; + groupName: string; +} + +export function InviteButton({ groupId, groupName }: InviteButtonProps) { + const [copied, setCopied] = useState(false); + const [generating, setGenerating] = useState(false); + + const generateInviteLink = async () => { + setGenerating(true); + try { + // Generate invite code (base64 encoded group ID) + const inviteCode = btoa(groupId.toString()); + const inviteUrl = `${window.location.origin}/invite/${inviteCode}`; + + await navigator.clipboard.writeText(inviteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } finally { + setGenerating(false); + } + }; + + return ( + + ); +} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..3e63414 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { locales, type Locale } from '@/i18n'; + +export function LanguageSwitcher() { + const [locale, setLocale] = useState('en'); + + useEffect(() => { + const stored = localStorage.getItem('sorosave_locale') as Locale; + if (stored && locales.includes(stored)) { + setLocale(stored); + } + }, []); + + const handleChange = (newLocale: Locale) => { + setLocale(newLocale); + localStorage.setItem('sorosave_locale', newLocale); + window.location.reload(); + }; + + return ( + + ); +} diff --git a/src/components/NetworkSelector.tsx b/src/components/NetworkSelector.tsx new file mode 100644 index 0000000..0f815c3 --- /dev/null +++ b/src/components/NetworkSelector.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { + getStoredNetwork, + setStoredNetwork, + getNetworkConfig, + clearNetworkCache, + type Network +} from '@/lib/networks'; + +interface NetworkSelectorProps { + onNetworkChange?: (network: Network) => void; +} + +export function NetworkSelector({ onNetworkChange }: NetworkSelectorProps) { + const [network, setNetwork] = useState('testnet'); + const [showConfirm, setShowConfirm] = useState(false); + const [pendingNetwork, setPendingNetwork] = useState(null); + + useEffect(() => { + setNetwork(getStoredNetwork()); + }, []); + + const handleNetworkChange = (newNetwork: Network) => { + if (newNetwork === network) return; + + if (network && network !== newNetwork) { + // Show confirmation dialog + setPendingNetwork(newNetwork); + setShowConfirm(true); + } else { + switchNetwork(newNetwork); + } + }; + + const switchNetwork = (newNetwork: Network) => { + setNetwork(newNetwork); + setStoredNetwork(newNetwork); + clearNetworkCache(); + onNetworkChange?.(newNetwork); + setShowConfirm(false); + setPendingNetwork(null); + }; + + const cancelSwitch = () => { + setShowConfirm(false); + setPendingNetwork(null); + }; + + const config = getNetworkConfig(network); + + return ( + <> +
+ + +
+ + {/* Confirmation Dialog */} + {showConfirm && pendingNetwork && ( +
+
+

+ Switch Networks? +

+

+ You are about to switch from{' '} + {config.name} to{' '} + + {pendingNetwork === 'mainnet' ? 'Mainnet' : 'Testnet'} + + . +

+

+ ⚠️ Cached data will be cleared. Make sure any pending transactions + are completed first. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..533ecc5 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; + +interface TourStep { + target: string; + title: string; + content: string; + placement?: 'top' | 'bottom' | 'left' | 'right' | 'center'; +} + +const ONBOARDING_KEY = 'sorosave_onboarding_complete'; + +const TOUR_STEPS: TourStep[] = [ + { + target: '[data-tour="connect-wallet"]', + title: 'Connect Your Wallet', + content: 'First, connect your wallet to interact with the Stellar network. We support Freighter, xBull, and Albedo.', + }, + { + target: '[data-tour="groups-list"]', + title: 'Browse Groups', + content: 'Explore existing savings groups or create your own. Join a group to start saving together!', + }, + { + target: '[data-tour="create-group"]', + title: 'Create a Group', + content: 'Start your own savings circle. Set the contribution amount, cycle length, and maximum members.', + }, + { + target: '[data-tour="contribute"]', + title: 'Make Contributions', + content: 'Contribute to your group each cycle. Track your progress and see when it\'s your turn to receive the pot!', + }, +]; + +export function useOnboarding() { + const [isComplete, setIsComplete] = useState(true); + const [currentStep, setCurrentStep] = useState(0); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + const completed = localStorage.getItem(ONBOARDING_KEY); + setIsComplete(completed === 'true'); + setIsRunning(completed !== 'true'); + }, []); + + const startTour = useCallback(() => { + setCurrentStep(0); + setIsRunning(true); + setIsComplete(false); + }, []); + + const nextStep = useCallback(() => { + if (currentStep < TOUR_STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + completeTour(); + } + }, [currentStep]); + + const prevStep = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }, [currentStep]); + + const completeTour = useCallback(() => { + localStorage.setItem(ONBOARDING_KEY, 'true'); + setIsComplete(true); + setIsRunning(false); + setCurrentStep(0); + }, []); + + const skipTour = useCallback(() => { + completeTour(); + }, [completeTour]); + + return { + isComplete, + isRunning, + currentStep, + steps: TOUR_STEPS, + startTour, + nextStep, + prevStep, + completeTour, + skipTour, + }; +} + +export function OnboardingTour() { + const { + isRunning, + currentStep, + steps, + nextStep, + prevStep, + completeTour, + skipTour, + } = useOnboarding(); + + if (!isRunning) return null; + + const step = steps[currentStep]; + const progress = ((currentStep + 1) / steps.length) * 100; + + return ( +
+ {/* Backdrop */} +
+ + {/* Tour Card */} +
+ {/* Progress Bar */} +
+
+
+ + {/* Step Counter */} +
+ + Step {currentStep + 1} of {steps.length} + + +
+ + {/* Content */} +

+ {step.title} +

+

+ {step.content} +

+ + {/* Actions */} +
+ +
+ + +
+
+
+
+ ); +} + +// Hook to replay tour from settings +export function useTourReplay() { + const { startTour } = useOnboarding(); + + const replayTour = useCallback(() => { + localStorage.removeItem(ONBOARDING_KEY); + startTour(); + }, [startTour]); + + return { replayTour }; +} diff --git a/src/components/RoundProgress.tsx b/src/components/RoundProgress.tsx index 8105152..70b289f 100644 --- a/src/components/RoundProgress.tsx +++ b/src/components/RoundProgress.tsx @@ -1,27 +1,88 @@ "use client"; +import { useState, useEffect, useCallback } from 'react'; + interface RoundProgressProps { - currentRound: number; - totalRounds: number; - contributionsReceived: number; - totalMembers: number; + groupId: number; + initialRound?: number; + initialTotalRounds?: number; + initialContributions?: number; + initialMembers?: number; } export function RoundProgress({ - currentRound, - totalRounds, - contributionsReceived, - totalMembers, + groupId, + initialRound = 1, + initialTotalRounds = 12, + initialContributions = 0, + initialMembers = 5, }: RoundProgressProps) { + const [currentRound, setCurrentRound] = useState(initialRound); + const [totalRounds] = useState(initialTotalRounds); + const [contributionsReceived, setContributionsReceived] = useState(initialContributions); + const [totalMembers] = useState(initialMembers); + const [isPolling, setIsPolling] = useState(true); + const [lastUpdated, setLastUpdated] = useState(new Date()); + + // Poll for updates every 10 seconds + useEffect(() => { + if (!isPolling) return; + + const pollInterval = setInterval(async () => { + try { + // Poll contract for updates - replace with actual SDK call + // const group = await sorosaveClient.getGroup(groupId); + + // For demo, simulate random contribution + if (contributionsReceived < totalMembers && Math.random() > 0.7) { + setContributionsReceived(prev => { + const newCount = prev + 1; + // Stop polling if round is complete + if (newCount >= totalMembers) { + setIsPolling(false); + } + return newCount; + }); + } + + setLastUpdated(new Date()); + } catch (error) { + console.error('Poll error:', error); + } + }, 10000); + + return () => clearInterval(pollInterval); + }, [groupId, isPolling, contributionsReceived, totalMembers]); + const roundProgress = totalRounds > 0 ? (currentRound / totalRounds) * 100 : 0; - const contributionProgress = - totalMembers > 0 ? (contributionsReceived / totalMembers) * 100 : 0; + const contributionProgress = totalMembers > 0 ? (contributionsReceived / totalMembers) * 100 : 0; + const isRoundComplete = contributionsReceived >= totalMembers; + + const formatTime = (date: Date) => { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }; return (
-

Progress

+
+

Progress

+
+ {isPolling ? ( + + + Live + + ) : ( + Paused + )} + + Updated: {formatTime(lastUpdated)} + +
+
+ {/* Round Progress */}
Overall Progress @@ -31,12 +92,13 @@ export function RoundProgress({
+ {/* Contribution Progress */}
Current Round Contributions @@ -46,11 +108,22 @@ export function RoundProgress({
+ + {/* Round Complete Message */} + {isRoundComplete && ( +
+

+ 🎉 Round {currentRound} Complete! Payout being processed... +

+
+ )}
); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..762d3d5 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,20 @@ +import en from './locales/en.json'; +import es from './locales/es.json'; + +export const locales = ['en', 'es'] as const; +export type Locale = typeof locales[number]; + +export const translations = { en, es }; + +export function getTranslation(locale: Locale, key: string): string { + const keys = key.split('.'); + let value: any = translations[locale]; + + for (const k of keys) { + value = value?.[k]; + } + + return value || key; +} + +export const defaultLocale: Locale = 'en'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..70ad347 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,44 @@ +{ + "common": { + "appName": "SoroSave", + "loading": "Loading...", + "error": "An error occurred", + "retry": "Retry", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "close": "Close" + }, + "nav": { + "home": "Home", + "groups": "Groups", + "createGroup": "Create Group", + "myGroups": "My Groups", + "settings": "Settings" + }, + "wallet": { + "connect": "Connect Wallet", + "disconnect": "Disconnect", + "connected": "Connected", + "balance": "Balance" + }, + "group": { + "create": "Create Group", + "join": "Join Group", + "leave": "Leave Group", + "contribute": "Contribute", + "members": "Members", + "status": "Status", + "contribution": "Contribution", + "payout": "Payout", + "cycles": "Cycles" + }, + "status": { + "forming": "Forming", + "active": "Active", + "completed": "Completed", + "disputed": "Disputed", + "paused": "Paused" + } +} diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json new file mode 100644 index 0000000..8443b57 --- /dev/null +++ b/src/i18n/locales/es.json @@ -0,0 +1,44 @@ +{ + "common": { + "appName": "SoroSave", + "loading": "Cargando...", + "error": "Ocurrió un error", + "retry": "Reintentar", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "close": "Cerrar" + }, + "nav": { + "home": "Inicio", + "groups": "Grupos", + "createGroup": "Crear Grupo", + "myGroups": "Mis Grupos", + "settings": "Configuración" + }, + "wallet": { + "connect": "Conectar Billetera", + "disconnect": "Desconectar", + "connected": "Conectado", + "balance": "Saldo" + }, + "group": { + "create": "Crear Grupo", + "join": "Unirse", + "leave": "Salir", + "contribute": "Contribuir", + "members": "Miembros", + "status": "Estado", + "contribution": "Contribución", + "payout": "Pago", + "cycles": "Ciclos" + }, + "status": { + "forming": "Formando", + "active": "Activo", + "completed": "Completado", + "disputed": "Disputado", + "paused": "Pausado" + } +} diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..e37a612 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,175 @@ +/** + * Data Export Utilities + * Export group data as CSV or PDF + * Issue: #60 + */ + +export interface ContributionRecord { + date: string; + member: string; + amount: string; + round: number; + status: 'pending' | 'completed'; +} + +export interface GroupSummary { + name: string; + id: number; + createdAt: string; + totalRounds: number; + currentRound: number; + totalContributed: string; + members: string[]; + payouts: string[]; +} + +/** + * Export contributions to CSV format + */ +export function exportContributionsCSV(contributions: ContributionRecord[], groupName: string): string { + const headers = ['Date', 'Member', 'Amount', 'Round', 'Status']; + const rows = contributions.map(c => [ + c.date, + c.member, + c.amount, + c.round.toString(), + c.status + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + return csvContent; +} + +/** + * Download CSV file + */ +export function downloadCSV(csvContent: string, filename: string): void { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +/** + * Export group summary as simple printable HTML (for PDF alternative) + */ +export function exportGroupSummaryPDF(group: GroupSummary, contributions: ContributionRecord[]): string { + const html = ` + + + + ${group.name} - Group Summary + + + +

${group.name}

+

Group ID: ${group.id} | Created: ${group.createdAt}

+ +

Summary

+
+
+
Total Rounds
+
${group.totalRounds}
+
+
+
Current Round
+
${group.currentRound}
+
+
+
Total Contributed
+
${group.totalContributed}
+
+
+
Members
+
${group.members.length}
+
+
+ +

Contribution History

+ + + + + + + + + + + + ${contributions.map(c => ` + + + + + + + + `).join('')} + +
DateMemberAmountRoundStatus
${c.date}${c.member}${c.amount}${c.round}${c.status}
+ + + + + `.trim(); + + return html; +} + +/** + * Download as PDF (opens print dialog) + */ +export function downloadPDF(html: string, filename: string): void { + const blob = new Blob([html], { type: 'text/html' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename.replace('.pdf', '.html'); + link.click(); + URL.revokeObjectURL(link.href); + + // Optionally trigger print dialog + setTimeout(() => { + window.print(); + }, 500); +} + +/** + * Main export function + */ +export async function exportGroupData( + group: GroupSummary, + contributions: ContributionRecord[], + format: 'csv' | 'pdf' +): Promise { + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `${group.name.replace(/\s+/g, '_')}_export_${timestamp}`; + + if (format === 'csv') { + const csv = exportContributionsCSV(contributions, group.name); + downloadCSV(csv, `${filename}.csv`); + } else { + const html = exportGroupSummaryPDF(group, contributions); + downloadPDF(html, filename); + } +} diff --git a/src/lib/horizon.ts b/src/lib/horizon.ts new file mode 100644 index 0000000..eee6cd5 --- /dev/null +++ b/src/lib/horizon.ts @@ -0,0 +1,164 @@ +/** + * Horizon API Client + * Fetch and display transaction history from Stellar Horizon + * Issue: #63 + */ + +import { SoroSaveClient } from "@sorosave/sdk"; + +const HORIZON_TESTNET = 'https://horizon-testnet.stellar.org'; +const HORIZON_MAINNET = 'https://horizon-mainnet.stellar.org'; + +export interface Transaction { + id: string; + hash: string; + ledger: number; + timestamp: number; + account: string; + operations: Operation[]; + successful: boolean; +} + +export interface Operation { + type: string; + from?: string; + to?: string; + amount?: string; + asset?: string; + contractId?: string; +} + +export interface TransactionHistoryOptions { + limit?: number; + cursor?: string; +} + +export class HorizonClient { + private horizonUrl: string; + private contractId: string; + + constructor(network: 'testnet' | 'mainnet' = 'testnet', contractId: string = '') { + this.horizonUrl = network === 'testnet' ? HORIZON_TESTNET : HORIZON_MAINNET; + this.contractId = contractId; + } + + /** + * Fetch transactions for a specific account + */ + async getAccountTransactions( + account: string, + options: TransactionHistoryOptions = {} + ): Promise { + const { limit = 20, cursor } = options; + + let url = `${this.horizonUrl}/accounts/${account}/transactions?limit=${limit}&order=desc`; + if (cursor) { + url += `&cursor=${cursor}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch transactions: ${response.status}`); + } + + const data = await response.json(); + + return this.parseTransactions(data.records || []); + } + + /** + * Fetch transactions involving the contract + */ + async getContractTransactions( + options: TransactionHistoryOptions = {} + ): Promise { + const { limit = 20, cursor } = options; + + // Query transactions where this contract is involved + let url = `${this.horizonUrl}/transactions?limit=${limit}&order=desc`; + if (this.contractId) { + url += `&transaction=${this.contractId}`; + } + if (cursor) { + url += `&cursor=${cursor}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch contract transactions: ${response.status}`); + } + + const data = await response.json(); + + return this.parseTransactions(data.records || []); + } + + /** + * Parse Horizon transaction records into our format + */ + private parseTransactions(records: Record[]): Transaction[] { + return records.map((record) => ({ + id: record.id as string, + hash: record.hash as string, + ledger: record.ledger as number, + timestamp: new Date(record.created_at as string).getTime(), + account: record.source_account as string, + operations: this.parseOperations(record.operations as string), + successful: (record.successful as boolean) ?? true, + })); + } + + /** + * Parse operation URLs into operation details + */ + private parseOperations(operationsUrl: string): Operation[] { + // In a real implementation, you'd fetch the operations + // For now, return a placeholder + return []; + } + + /** + * Fetch a specific transaction by hash + */ + async getTransaction(hash: string): Promise { + const response = await fetch(`${this.horizonUrl}/transactions/${hash}`); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`Failed to fetch transaction: ${response.status}`); + } + + const record = await response.json(); + return this.parseTransactions([record])[0]; + } +} + +// Cache for transaction results +const transactionCache = new Map(); +const CACHE_TTL = 60000; // 1 minute + +export async function getCachedTransactions( + client: HorizonClient, + account: string, + limit: number = 20 +): Promise { + const cacheKey = `${account}_${limit}`; + const cached = transactionCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + + const transactions = await client.getAccountTransactions(account, { limit }); + + transactionCache.set(cacheKey, { + data: transactions, + timestamp: Date.now(), + }); + + return transactions; +} + +export function clearTransactionCache(): void { + transactionCache.clear(); +} diff --git a/src/lib/networks.ts b/src/lib/networks.ts new file mode 100644 index 0000000..42fd037 --- /dev/null +++ b/src/lib/networks.ts @@ -0,0 +1,70 @@ +/** + * Network Configuration + * Support for Stellar Testnet and Mainnet + * Issue: #80 + */ + +export type Network = 'testnet' | 'mainnet'; + +export interface NetworkConfig { + name: string; + rpcUrl: string; + networkPassphrase: string; + contractId: string; +} + +export const NETWORKS: Record = { + testnet: { + name: 'Testnet', + rpcUrl: process.env.NEXT_PUBLIC_TESTNET_RPC_URL || 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: process.env.NEXT_PUBLIC_TESTNET_CONTRACT_ID || '', + }, + mainnet: { + name: 'Mainnet', + rpcUrl: process.env.NEXT_PUBLIC_MAINNET_RPC_URL || 'https://soroban-mainnet.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + contractId: process.env.NEXT_PUBLIC_MAINNET_CONTRACT_ID || '', + }, +}; + +const NETWORK_STORAGE_KEY = 'sorosave_network'; + +// Get current network from localStorage or default to testnet +export function getStoredNetwork(): Network { + if (typeof window === 'undefined') return 'testnet'; + + const stored = localStorage.getItem(NETWORK_STORAGE_KEY); + if (stored === 'testnet' || stored === 'mainnet') { + return stored; + } + return 'testnet'; +} + +// Store network selection in localStorage +export function setStoredNetwork(network: Network): void { + if (typeof window === 'undefined') return; + localStorage.setItem(NETWORK_STORAGE_KEY, network); +} + +// Get config for a network +export function getNetworkConfig(network: Network): NetworkConfig { + return NETWORKS[network]; +} + +// Get current network config +export function getCurrentNetworkConfig(): NetworkConfig { + return getNetworkConfig(getStoredNetwork()); +} + +// Clear cached data when switching networks +export function clearNetworkCache(): void { + if (typeof window === 'undefined') return; + + // Clear any cached contract data + Object.keys(localStorage).forEach(key => { + if (key.startsWith('sorosave_cache_')) { + localStorage.removeItem(key); + } + }); +} diff --git a/src/lib/wallets/adapters.ts b/src/lib/wallets/adapters.ts new file mode 100644 index 0000000..0c6503a --- /dev/null +++ b/src/lib/wallets/adapters.ts @@ -0,0 +1,243 @@ +/** + * Multi-Wallet Support + * Interface and adapters for Freighter, xBull, and Albedo + * Issue: #66 + */ + +export interface WalletInfo { + id: string; + name: string; + icon: string; + installed: boolean; +} + +export interface WalletAccount { + publicKey: string; + address: string; +} + +export interface WalletAdapter { + getWalletInfo(): WalletInfo; + isInstalled(): Promise; + connect(): Promise; + getPublicKey(): Promise; + signTransaction(txXdr: string, networkPassphrase: string): Promise; + signMessage(message: string): Promise; + disconnect(): void; + onAccountChange(callback: (account: WalletAccount) => void): void; +} + +// Base adapter with common functionality +abstract class BaseWalletAdapter implements WalletAdapter { + abstract getWalletInfo(): WalletInfo; + + async isInstalled(): Promise { + return true; + } + + abstract connect(): Promise; + abstract getPublicKey(): Promise; + abstract signTransaction(txXdr: string, networkPassphrase: string): Promise; + abstract signMessage(message: string): Promise; + + disconnect(): void { + localStorage.removeItem('sorosave_wallet'); + } + + onAccountChange(_callback: (account: WalletAccount) => void): void { + // Override in subclasses if supported + } + + protected async getWindow(): Promise { + return typeof window !== 'undefined' ? window : null; + } +} + +// Freighter Wallet Adapter +export class FreighterAdapter extends BaseWalletAdapter { + getWalletInfo(): WalletInfo { + return { + id: 'freighter', + name: 'Freighter', + icon: 'https://www.freighter.app/icon.png', + installed: true, // Will be checked dynamically + }; + } + + async isInstalled(): Promise { + try { + const win = await this.getWindow(); + return !!(win as any)?.freighter?.isConnected; + } catch { + return false; + } + } + + async connect(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const freighter = (win as any).freighter; + const result = await freighter.connect(); + + const account: WalletAccount = { + publicKey: result.publicKey, + address: result.publicKey, + }; + + localStorage.setItem('sorosave_wallet', 'freighter'); + return account; + } + + async getPublicKey(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const freighter = (win as any).freighter; + return await freighter.getPublicKey(); + } + + async signTransaction(txXdr: string, networkPassphrase: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const freighter = (win as any).freighter; + return await freighter.signTransaction(txXdr, { networkPassphrase }); + } + + async signMessage(message: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const freighter = (win as any).freighter; + return await freighter.signMessage(message); + } +} + +// xBull Wallet Adapter +export class xBullAdapter extends BaseWalletAdapter { + getWalletInfo(): WalletInfo { + return { + id: 'xbull', + name: 'xBull', + icon: '/wallets/xbull.png', + installed: false, + }; + } + + async isInstalled(): Promise { + const win = await this.getWindow(); + return !!(win as any)?.xbull?.isConnected; + } + + async connect(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const xbull = (win as any).xbull; + await xbull.connect(); + const publicKey = await xbull.getPublicKey(); + + const account: WalletAccount = { + publicKey, + address: publicKey, + }; + + localStorage.setItem('sorosave_wallet', 'xbull'); + return account; + } + + async getPublicKey(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).xbull.getPublicKey(); + } + + async signTransaction(txXdr: string, networkPassphrase: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).xbull.signTx(txXdr, { network: networkPassphrase }); + } + + async signMessage(message: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).xbull.signMessage(message); + } +} + +// Albedo Wallet Adapter +export class AlbedoAdapter extends BaseWalletAdapter { + getWalletInfo(): WalletInfo { + return { + id: 'albedo', + name: 'Albedo', + icon: '/wallets/albedo.png', + installed: false, + }; + } + + async isInstalled(): Promise { + return true; // Albedo is web-based + } + + async connect(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + + const albedo = (win as any).albedo; + const result = await albedo.connect(); + + const account: WalletAccount = { + publicKey: result.publicKey, + address: result.publicKey, + }; + + localStorage.setItem('sorosave_wallet', 'albedo'); + return account; + } + + async getPublicKey(): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).albedo.getPublicKey(); + } + + async signTransaction(txXdr: string, networkPassphrase: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).albedo.signTransaction(txXdr, { network: networkPassphrase }); + } + + async signMessage(message: string): Promise { + const win = await this.getWindow(); + if (!win) throw new Error('No window'); + return await (win as any).albedo.signMessage(message); + } +} + +// Factory function to get available wallets +export function getAvailableWallets(): WalletAdapter[] { + return [ + new FreighterAdapter(), + new xBullAdapter(), + new AlbedoAdapter(), + ]; +} + +// Get last used wallet +export function getLastUsedWallet(): string | null { + return localStorage.getItem('sorosave_wallet'); +} + +// Create wallet adapter from ID +export function createWalletAdapter(walletId: string): WalletAdapter | null { + const adapters: Record WalletAdapter> = { + freighter: () => new FreighterAdapter(), + xbull: () => new xBullAdapter(), + albedo: () => new AlbedoAdapter(), + }; + + const create = adapters[walletId]; + return create ? create() : null; +}