diff --git a/web/App.tsx b/web/App.tsx index c2bb0a5..2d99d82 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { Layout } from './components/layout/Layout'; +import { ThemeWrapper } from './components/layout/ThemeWrapper'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { Auth } from './pages/Auth'; import { Dashboard } from './pages/Dashboard'; -import { Groups } from './pages/Groups'; -import { GroupDetails } from './pages/GroupDetails'; import { Friends } from './pages/Friends'; -import { Layout } from './components/layout/Layout'; -import { ThemeWrapper } from './components/layout/ThemeWrapper'; +import { GroupDetails } from './pages/GroupDetails'; +import { Groups } from './pages/Groups'; +import { Profile } from './pages/Profile'; // Protected Route Wrapper const ProtectedRoute = ({ children }: { children: React.ReactElement }) => { @@ -35,7 +36,7 @@ const AppRoutes = () => { } /> } /> } /> -
Profile Management Coming Soon
} /> + } /> } /> diff --git a/web/components/layout/Sidebar.tsx b/web/components/layout/Sidebar.tsx index 4549b1f..c1ed305 100644 --- a/web/components/layout/Sidebar.tsx +++ b/web/components/layout/Sidebar.tsx @@ -1,9 +1,8 @@ -import React from 'react'; +import { CreditCard, Layers, LayoutDashboard, LogOut, Moon, Sun, UserCircle, Users } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; -import { useTheme } from '../../contexts/ThemeContext'; -import { useAuth } from '../../contexts/AuthContext'; import { THEMES } from '../../constants'; -import { LayoutDashboard, Users, UserCircle, LogOut, Sun, Moon, Layers, CreditCard, UserPlus } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; +import { useTheme } from '../../contexts/ThemeContext'; import { Button } from '../ui/Button'; export const Sidebar = () => { @@ -56,9 +55,13 @@ export const Sidebar = () => {
{user && (
-
- {user.name.charAt(0)} -
+ {user.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? ( + {user.name} + ) : ( +
+ {user.name.charAt(0)} +
+ )}

{user.name}

diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx index 2580b1a..cfccd8e 100644 --- a/web/components/ui/Button.tsx +++ b/web/components/ui/Button.tsx @@ -1,23 +1,23 @@ import React from 'react'; -import { useTheme } from '../../contexts/ThemeContext'; import { THEMES } from '../../constants'; +import { useTheme } from '../../contexts/ThemeContext'; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg'; } -export const Button: React.FC = ({ - children, - variant = 'primary', - size = 'md', - className = '', - ...props +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + className = '', + ...props }) => { const { style } = useTheme(); const baseStyles = "transition-all duration-200 font-bold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"; - + const sizeStyles = { sm: "px-3 py-1.5 text-sm", md: "px-5 py-2.5 text-base", @@ -27,8 +27,8 @@ export const Button: React.FC = ({ let themeStyles = ""; if (style === THEMES.NEOBRUTALISM) { - themeStyles = "border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none rounded-none uppercase tracking-wider"; - + themeStyles = "border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none rounded-none uppercase tracking-wider font-mono"; + if (variant === 'primary') themeStyles += " bg-neo-main text-white"; if (variant === 'secondary') themeStyles += " bg-neo-second text-black"; if (variant === 'danger') themeStyles += " bg-red-500 text-white"; @@ -37,7 +37,7 @@ export const Button: React.FC = ({ } else { // Glassmorphism themeStyles = "rounded-xl backdrop-blur-md border border-white/20 shadow-lg hover:shadow-xl active:scale-95"; - + if (variant === 'primary') themeStyles += " bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-blue-500/30"; if (variant === 'secondary') themeStyles += " bg-white/10 text-white hover:bg-white/20"; if (variant === 'danger') themeStyles += " bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-red-500/30"; @@ -45,7 +45,7 @@ export const Button: React.FC = ({ } return ( - +
+ +
+
+

+ {isLogin ? 'Welcome back' : 'Create an account'} +

+

+ {isLogin ? 'Enter your details to access your account' : 'Start splitting bills in seconds'} +

-
- {!isLogin && ( - setName(e.target.value)} - required - /> - )} - setEmail(e.target.value)} - required - /> - setPassword(e.target.value)} - required - /> - - {error &&
{error}
} - - -
- -
- -
- -
- +
+
+ Or continue with email +
+
+ +
+ + {!isLogin && ( + + setName(e.target.value)} + required + className={isNeo ? 'rounded-none' : ''} + /> + + )} + + + setEmail(e.target.value)} + required + className={isNeo ? 'rounded-none' : ''} + /> + setPassword(e.target.value)} + required + className={isNeo ? 'rounded-none' : ''} + /> + + {error && ( + + {error} + + )} + + +
+ +
+ +
+
diff --git a/web/pages/Dashboard.tsx b/web/pages/Dashboard.tsx index a5f9b61..2441c19 100644 --- a/web/pages/Dashboard.tsx +++ b/web/pages/Dashboard.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import { DollarSign, TrendingDown, TrendingUp } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Card } from '../components/ui/Card'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; import { getBalanceSummary } from '../services/api'; import { BalanceSummary } from '../types'; -import { useTheme } from '../contexts/ThemeContext'; -import { THEMES } from '../constants'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; -import { TrendingUp, TrendingDown, DollarSign } from 'lucide-react'; export const Dashboard = () => { const [summary, setSummary] = useState(null); @@ -37,32 +37,32 @@ export const Dashboard = () => {
-
- +
+

Owed to You

-

- ${summary?.totalOwedToYou.toFixed(2)} +

+ ${(summary?.totalOwedToYou ?? 0).toFixed(2)}

-
- +
+

You Owe

-

- ${summary?.totalYouOwe.toFixed(2)} +

+ ${(summary?.totalYouOwe ?? 0).toFixed(2)}

-
- +
+

Net Balance

-

= 0 ? 'text-emerald-500' : 'text-red-500'}`}> - ${summary?.netBalance.toFixed(2)} +

= 0 ? (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500') : (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-red-500')}`}> + ${(summary?.netBalance ?? 0).toFixed(2)}

@@ -72,25 +72,25 @@ export const Dashboard = () => {
- - - {chartData.map((entry, index) => ( @@ -101,12 +101,12 @@ export const Dashboard = () => {
- + -
-

No recent activity data available in summary view.

-

Check specific groups for details.

-
+
+

No recent activity data available in summary view.

+

Check specific groups for details.

+
diff --git a/web/pages/Friends.tsx b/web/pages/Friends.tsx index 79f815c..acd1d47 100644 --- a/web/pages/Friends.tsx +++ b/web/pages/Friends.tsx @@ -1,63 +1,318 @@ -import React, { useEffect, useState } from 'react'; -import { getFriendsBalance } from '../services/api'; -import { Card } from '../components/ui/Card'; -import { User } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowRight, Search, TrendingDown, TrendingUp, Users } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; +import { getFriendsBalance, getGroups } from '../services/api'; + +interface GroupBreakdown { + groupId: string; + groupName: string; + balance: number; + imageUrl?: string; +} + +interface Friend { + id: string; + userId: string; + userName: string; + userImageUrl?: string; + netBalance: number; + breakdown: GroupBreakdown[]; +} export const Friends = () => { - const [friends, setFriends] = useState([]); + const [friends, setFriends] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const { style } = useTheme(); useEffect(() => { - const fetchFriends = async () => { + const fetchData = async () => { + setLoading(true); try { - const res = await getFriendsBalance(); - setFriends(res.data.friendsBalance); + const [friendsRes, groupsRes] = await Promise.all([ + getFriendsBalance(), + getGroups() + ]); + + const friendsData = friendsRes.data.friendsBalance || []; + const groups = groupsRes.data.groups || []; + + const gMap = new Map( + groups.map((g: { _id: string; name: string; imageUrl?: string }) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) + ); + + interface FriendBalanceData { + userId: string; + userName: string; + userImageUrl?: string; + netBalance: number; + breakdown?: { groupId: string; groupName: string; balance: number }[]; + } + + const transformedFriends = friendsData.map((friend: FriendBalanceData) => ({ + id: friend.userId, + userId: friend.userId, + userName: friend.userName, + userImageUrl: friend.userImageUrl, + netBalance: friend.netBalance, + breakdown: (friend.breakdown || []).map((group: { groupId: string; groupName: string; balance: number }) => ({ + groupId: group.groupId, + groupName: group.groupName, + balance: group.balance, + imageUrl: gMap.get(group.groupId)?.imageUrl + })) + })); + + setFriends(transformedFriends); + setError(null); } catch (err) { - console.error(err); + console.error('Failed to fetch friends balance data:', err); + setError('Unable to load friends data. Please try again.'); + } finally { + setLoading(false); } }; - fetchFriends(); + fetchData(); }, []); + const toggleExpand = (id: string) => { + setExpandedId(expandedId === id ? null : id); + }; + + const filteredFriends = friends.filter(f => + f.userName.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const totalOwedToYou = friends.reduce((acc, curr) => curr.netBalance > 0 ? acc + curr.netBalance : acc, 0); + const totalYouOwe = friends.reduce((acc, curr) => curr.netBalance < 0 ? acc + Math.abs(curr.netBalance) : acc, 0); + + const formatCurrency = (amount: number) => { + return `$${Math.abs(amount).toFixed(2)}`; + }; + + const getAvatarContent = (imageUrl: string | undefined, name: string, size: 'sm' | 'lg' = 'lg') => { + const sizeClass = size === 'lg' ? 'w-14 h-14 text-xl' : 'w-10 h-10 text-sm'; + const isNeo = style === THEMES.NEOBRUTALISM; + + if (imageUrl && /^(https?:|data:image)/.test(imageUrl)) { + return ( + {name} + ); + } + return ( +
+ {name.charAt(0)} +
+ ); + }; + + const isNeo = style === THEMES.NEOBRUTALISM; + return ( -
-

Friends

- -
- {friends.map((friend, idx) => ( - -
-
- -
-
-

{friend.userName}

-

= 0 ? 'text-emerald-500' : 'text-red-500'}`}> - {friend.netBalance >= 0 ? 'owes you' : 'you owe'} ${Math.abs(friend.netBalance).toFixed(2)} -

-
-
- - {friend.breakdown.length > 0 && ( -
-

Groups

- {friend.breakdown.map((b: any, i: number) => ( -
- {b.groupName} - - {b.owesYou ? '+' : '-'}${Math.abs(b.balance).toFixed(2)} - -
- ))} -
- )} -
- ))} - - {friends.length === 0 && ( -
- You don't have any outstanding balances with friends yet. +
+ {/* Immersive Header */} + +
+
+ +
+
+
+ + Dashboard +
- )} +

+ Friends +

+
+ +
+ + setSearchTerm(e.target.value)} + className={`pl-12 pr-4 py-4 outline-none transition-all w-full md:w-80 font-bold ${isNeo + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' + : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-2xl text-white placeholder:text-white/40' + }`} + /> +
+
+ + + {/* Summary Cards */} +
+ +
+

Total Owed to You

+

{formatCurrency(totalOwedToYou)}

+
+
+ +
+
+ + +
+

Total You Owe

+

{formatCurrency(totalYouOwe)}

+
+
+ +
+
+
+ + {/* Error State */} + {error && ( + +

{error}

+ +
+ )} + + {/* Friends Grid */} +
+ + {filteredFriends.length === 0 && !error ? ( + + +

No friends found

+
+ ) : ( + filteredFriends.map((friend, index) => ( + + + + + {expandedId === friend.id && ( + +
+

Group Breakdown

+ {friend.breakdown.map(g => ( +
+
+ {getAvatarContent(g.imageUrl, g.groupName, 'sm')} + {g.groupName} +
+ 0 ? 'text-emerald-500' : g.balance < 0 ? 'text-orange-500' : 'opacity-50'}`}> + {g.balance > 0 ? '+' : g.balance < 0 ? '-' : ''}{formatCurrency(g.balance)} + +
+ ))} + {friend.breakdown.length === 0 && ( +

No active groups

+ )} + +
+
+ )} +
+
+ )) + )} +
); diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index c17b8b0..8b8b073 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -1,712 +1,927 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - getGroupDetails, getExpenses, getGroupMembers, createExpense, - getOptimizedSettlements, updateExpense, deleteExpense, - createSettlement, updateGroup, deleteGroup -} from '../services/api'; -import { Group, Expense, GroupMember, SplitType } from '../types'; -import { Card } from '../components/ui/Card'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, PieChart, Plus, Receipt, Settings, Share2, Trash2, UserMinus } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; -import { Skeleton } from '../components/ui/Skeleton'; import { Modal } from '../components/ui/Modal'; +import { Skeleton } from '../components/ui/Skeleton'; +import { THEMES } from '../constants'; import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; -import { THEMES } from '../constants'; -import { Plus, Receipt, Copy, Check, Users, DollarSign, PieChart, Hash, Layers, ArrowRight, Settings, Pencil, Trash2, Banknote } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { + createExpense, + createSettlement, + deleteExpense, + deleteGroup, + getExpenses, + getGroupDetails, + getGroupMembers, + getOptimizedSettlements, + leaveGroup, removeMember, + updateExpense, + updateGroup +} from '../services/api'; +import { Expense, Group, GroupMember, SplitType } from '../types'; type UnequalMode = 'amount' | 'percentage' | 'shares'; +interface Settlement { + fromUserId: string; + fromUserName: string; + toUserId: string; + toUserName: string; + amount: number; +} + export const GroupDetails = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const { user } = useAuth(); - const { style, mode } = useTheme(); - - const [group, setGroup] = useState(null); - const [expenses, setExpenses] = useState([]); - const [members, setMembers] = useState([]); - const [settlements, setSettlements] = useState([]); - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'expenses' | 'settlements'>('expenses'); - - // Modals - const [isExpenseModalOpen, setIsExpenseModalOpen] = useState(false); - const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); - const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - - // Expense Form State - const [editingExpenseId, setEditingExpenseId] = useState(null); - const [description, setDescription] = useState(''); - const [amount, setAmount] = useState(''); - const [currency, setCurrency] = useState('USD'); - const [splitType, setSplitType] = useState(SplitType.EQUAL); - const [unequalMode, setUnequalMode] = useState('amount'); - const [selectedUsers, setSelectedUsers] = useState>(new Set()); - const [splitValues, setSplitValues] = useState<{[key: string]: string}>({}); - const [payerId, setPayerId] = useState(''); - - // Payment Form State - const [paymentPayerId, setPaymentPayerId] = useState(''); - const [paymentPayeeId, setPaymentPayeeId] = useState(''); - const [paymentAmount, setPaymentAmount] = useState(''); - - // Group Settings State - const [editGroupName, setEditGroupName] = useState(''); - - useEffect(() => { - if (id) fetchData(); - }, [id]); - - useEffect(() => { - if (members.length > 0) { - if (!editingExpenseId) { - setSelectedUsers(new Set(members.map(m => m.userId))); - if (group?.currency) setCurrency(group.currency); - if (user && !payerId) setPayerId(user._id); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + const { style } = useTheme(); + + const [group, setGroup] = useState(null); + const [expenses, setExpenses] = useState([]); + const [members, setMembers] = useState([]); + const [settlements, setSettlements] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'expenses' | 'settlements'>('expenses'); + + // Modals + const [isExpenseModalOpen, setIsExpenseModalOpen] = useState(false); + const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + + // Expense Form State + const [editingExpenseId, setEditingExpenseId] = useState(null); + const [description, setDescription] = useState(''); + const [amount, setAmount] = useState(''); + const [currency, setCurrency] = useState('USD'); + const [splitType, setSplitType] = useState(SplitType.EQUAL); + const [unequalMode, setUnequalMode] = useState('amount'); + const [selectedUsers, setSelectedUsers] = useState>(new Set()); + const [splitValues, setSplitValues] = useState<{ [key: string]: string }>({}); + const [payerId, setPayerId] = useState(''); + + // Payment Form State + const [paymentPayerId, setPaymentPayerId] = useState(''); + const [paymentPayeeId, setPaymentPayeeId] = useState(''); + const [paymentAmount, setPaymentAmount] = useState(''); + + // Group Settings State + const [editGroupName, setEditGroupName] = useState(''); + const [settingsTab, setSettingsTab] = useState<'info' | 'members' | 'danger'>('info'); + const [copied, setCopied] = useState(false); + + // Check if current user is admin + const isAdmin = useMemo(() => { + const me = members.find(m => m.userId === user?._id); + return me?.role === 'admin'; + }, [members, user?._id]); + + useEffect(() => { + if (id) fetchData(); + }, [id]); + + useEffect(() => { + if (members.length > 0) { + if (!editingExpenseId) { + setSelectedUsers(new Set(members.map(m => m.userId))); + if (group?.currency) setCurrency(group.currency); + if (user && !payerId) setPayerId(user._id); + } + + // Defaults for payment modal + if (user && !paymentPayerId) setPaymentPayerId(user._id); + if (members.length > 1 && !paymentPayeeId) { + const other = members.find(m => m.userId !== user?._id); + if (other) setPaymentPayeeId(other.userId); + } } - - // Defaults for payment modal - if (user && !paymentPayerId) setPaymentPayerId(user._id); - if (members.length > 1 && !paymentPayeeId) { - const other = members.find(m => m.userId !== user?._id); - if (other) setPaymentPayeeId(other.userId); + }, [members, group, user, editingExpenseId]); + + const fetchData = async () => { + if (!id) return; + setLoading(true); + try { + const [groupRes, expRes, memRes, setRes] = await Promise.all([ + getGroupDetails(id), + getExpenses(id), + getGroupMembers(id), + getOptimizedSettlements(id) + ]); + setGroup(groupRes.data); + setExpenses(expRes.data.expenses); + setMembers(memRes.data); + setSettlements(setRes.data.optimizedSettlements); + setEditGroupName(groupRes.data.name); + } catch (err) { + console.error(err); + } finally { + setLoading(false); } - } - }, [members, group, user, editingExpenseId]); - - const fetchData = async () => { - if (!id) return; - setLoading(true); - try { - const [groupRes, expRes, memRes, setRes] = await Promise.all([ - getGroupDetails(id), - getExpenses(id), - getGroupMembers(id), - getOptimizedSettlements(id) - ]); - setGroup(groupRes.data); - setExpenses(expRes.data.expenses); - setMembers(memRes.data); - setSettlements(setRes.data.optimizedSettlements); - setEditGroupName(groupRes.data.name); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; - - const copyToClipboard = () => { - if (group?.joinCode) navigator.clipboard.writeText(group.joinCode); - }; - - const remainingAmount = useMemo(() => { - const total = parseFloat(amount) || 0; - if (splitType === SplitType.EQUAL) return 0; - - if (unequalMode === 'amount') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); - return total - sum; - } - if (unequalMode === 'percentage') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); - return 100 - sum; - } - return 0; - }, [amount, splitType, unequalMode, splitValues]); - - // --- Handlers --- - - const openAddExpense = () => { - setEditingExpenseId(null); - resetExpenseForm(); - setIsExpenseModalOpen(true); - }; - - const openEditExpense = (expense: Expense) => { - setEditingExpenseId(expense._id); - setDescription(expense.description); - setAmount(expense.amount.toString()); - setPayerId(expense.paidBy); - setSplitType(expense.splitType); - - // Reconstruction logic - if (expense.splitType === SplitType.EQUAL) { - setSelectedUsers(new Set(expense.splits.map(s => s.userId))); - } else { - // For unequal, populate amounts. Can't easily restore percentage/shares source of truth without extra data. - setUnequalMode('amount'); - const vals: any = {}; - expense.splits.forEach(s => vals[s.userId] = s.amount.toString()); - setSplitValues(vals); - } - setIsExpenseModalOpen(true); - }; - - const handleExpenseSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!id) return; - - const numAmount = parseFloat(amount); - let requestSplits = []; - - if (splitType === SplitType.EQUAL) { - const involvedMembers = members.filter(m => selectedUsers.has(m.userId)); - if (involvedMembers.length === 0) return alert("Select at least one person."); - const splitAmount = numAmount / involvedMembers.length; - requestSplits = involvedMembers.map(m => ({ userId: m.userId, amount: splitAmount })); - } else { - // Handle Unequal + }; + + const copyToClipboard = () => { + if (group?.joinCode) { + navigator.clipboard.writeText(group.joinCode) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + alert('Failed to copy to clipboard'); + }); + } + }; + + const shareInvite = async () => { + if (!group?.joinCode) return; + const text = `Join my group on Splitwiser! Use code ${group.joinCode}`; + + if (navigator.share) { + try { + await navigator.share({ + title: 'Join my Splitwiser group', + text, + }); + } catch (err) { + navigator.clipboard.writeText(text); + alert('Invite copied to clipboard!'); + } + } else { + navigator.clipboard.writeText(text); + alert('Invite copied to clipboard!'); + } + }; + + const remainingAmount = useMemo(() => { + const total = parseFloat(amount) || 0; + if (splitType === SplitType.EQUAL) return 0; + + const values = Object.values(splitValues) as string[]; + if (unequalMode === 'amount') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); - if (Math.abs(sum - numAmount) > 0.01) return alert(`Amounts must match total.`); - requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: parseFloat(val) || 0 })); - } else if (unequalMode === 'percentage') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); - if (Math.abs(sum - 100) > 0.1) return alert(`Percentages must equal 100%.`); - requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val) || 0)) / 100 })); - } else if (unequalMode === 'shares') { - const totalShares = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); - if (totalShares === 0) return alert("Total shares cannot be zero."); - requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val) || 0)) / totalShares })); + const sum = values.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + return total - sum; + } + if (unequalMode === 'percentage') { + const sum = values.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + return 100 - sum; } - } + return 0; + }, [amount, splitType, unequalMode, splitValues]); - // Filter out 0 amounts - requestSplits = requestSplits.filter(s => s.amount > 0); + // --- Handlers --- - const payload = { - description, - amount: numAmount, - paidBy: payerId, - splitType, - splits: requestSplits, + const openAddExpense = () => { + setEditingExpenseId(null); + resetExpenseForm(); + setIsExpenseModalOpen(true); }; - try { - if (editingExpenseId) { - await updateExpense(id, editingExpenseId, payload); - } else { - await createExpense(id, payload); - } - setIsExpenseModalOpen(false); - fetchData(); - } catch (err) { - console.error(err); - alert('Error saving expense'); - } - }; - - const handleDeleteExpense = async () => { - if (!editingExpenseId || !id) return; - if (window.confirm("Are you sure you want to delete this expense?")) { - try { - await deleteExpense(id, editingExpenseId); - setIsExpenseModalOpen(false); - fetchData(); - } catch (err) { - alert("Failed to delete expense"); - } - } - }; - - const handleRecordPayment = async (e: React.FormEvent) => { - e.preventDefault(); - if (!id) return; - try { - await createSettlement(id, { - payer_id: paymentPayerId, - payee_id: paymentPayeeId, - amount: parseFloat(paymentAmount) - }); - setIsPaymentModalOpen(false); - setPaymentAmount(''); - fetchData(); - } catch (err) { - alert("Failed to record payment"); - } - }; - - const handleUpdateGroup = async (e: React.FormEvent) => { - e.preventDefault(); - if (!id) return; - try { - await updateGroup(id, { name: editGroupName }); - setIsSettingsModalOpen(false); - fetchData(); - } catch (err) { - alert("Failed to update group"); - } - }; - - const handleDeleteGroup = async () => { - if (!id) return; - if (window.confirm("Are you sure? This cannot be undone.")) { - try { - await deleteGroup(id); - navigate('/groups'); - } catch (err) { - alert("Failed to delete group"); - } - } - }; - - const resetExpenseForm = () => { - setDescription(''); - setAmount(''); - setSplitValues({}); - setSplitType(SplitType.EQUAL); - setUnequalMode('amount'); - setSelectedUsers(new Set(members.map(m => m.userId))); - if (user) setPayerId(user._id); - }; - - if (loading && !group) return
; - if (!group) return
Group not found
; - - return ( -
- {/* Header */} - - -
-
-

{group.name}

-
- Code: {group.joinCode} - -
-
-
- -
- {members.slice(0, 5).map(m => ( -
- {m.user?.name?.charAt(0)} + const openEditExpense = (expense: Expense) => { + setEditingExpenseId(expense._id); + setDescription(expense.description); + setAmount(expense.amount.toString()); + setPayerId(expense.paidBy); + setSplitType(expense.splitType); + + if (expense.splitType === SplitType.EQUAL) { + setSelectedUsers(new Set(expense.splits.map(s => s.userId))); + } else { + setUnequalMode('amount'); + const vals: Record = {}; + expense.splits.forEach(s => vals[s.userId] = s.amount.toString()); + setSplitValues(vals); + } + setIsExpenseModalOpen(true); + }; + + const handleExpenseSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!id) return; + + const numAmount = parseFloat(amount); + let requestSplits: { userId: string; amount: number }[] = []; + + if (splitType === SplitType.EQUAL) { + const involvedMembers = members.filter(m => selectedUsers.has(m.userId)); + if (involvedMembers.length === 0) return alert("Select at least one person."); + const splitAmount = numAmount / involvedMembers.length; + requestSplits = involvedMembers.map(m => ({ userId: m.userId, amount: splitAmount })); + } else { + const splitVals = Object.values(splitValues) as string[]; + if (unequalMode === 'amount') { + const sum = splitVals.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + if (Math.abs(sum - numAmount) > 0.01) return alert(`Amounts must match total.`); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: parseFloat(val as string) || 0 })); + } else if (unequalMode === 'percentage') { + const sum = splitVals.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + if (Math.abs(sum - 100) > 0.1) return alert(`Percentages must equal 100%.`); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val as string) || 0)) / 100 })); + } else if (unequalMode === 'shares') { + const totalShares = splitVals.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + if (totalShares === 0) return alert("Total shares cannot be zero."); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val as string) || 0)) / totalShares })); + } + } + + requestSplits = requestSplits.filter(s => s.amount > 0); + + const payload = { + description, + amount: numAmount, + paidBy: payerId, + splitType, + splits: requestSplits, + }; + + try { + if (editingExpenseId) { + await updateExpense(id, editingExpenseId, payload); + } else { + await createExpense(id, payload); + } + setIsExpenseModalOpen(false); + fetchData(); + } catch (err) { + console.error(err); + alert('Error saving expense'); + } + }; + + const handleDeleteExpense = async () => { + if (!editingExpenseId || !id) return; + if (window.confirm("Are you sure you want to delete this expense?")) { + try { + await deleteExpense(id, editingExpenseId); + setIsExpenseModalOpen(false); + fetchData(); + } catch (err) { + alert("Failed to delete expense"); + } + } + }; + + const handleRecordPayment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!id) return; + + const numAmount = parseFloat(paymentAmount); + if (paymentPayerId === paymentPayeeId) { + alert('Payer and payee cannot be the same'); + return; + } + if (!numAmount || numAmount <= 0) { + alert('Please enter a valid amount'); + return; + } + + try { + await createSettlement(id, { + payer_id: paymentPayerId, + payee_id: paymentPayeeId, + amount: numAmount + }); + setIsPaymentModalOpen(false); + setPaymentAmount(''); + fetchData(); + } catch (err) { + alert("Failed to record payment"); + } + }; + + const handleUpdateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!id) return; + try { + await updateGroup(id, { name: editGroupName }); + setIsSettingsModalOpen(false); + fetchData(); + } catch (err) { + alert("Failed to update group"); + } + }; + + const handleDeleteGroup = async () => { + if (!id) return; + if (window.confirm("Are you sure? This cannot be undone.")) { + try { + await deleteGroup(id); + navigate('/groups'); + } catch (err) { + alert("Failed to delete group"); + } + } + }; + + const handleLeaveGroup = async () => { + if (!id) return; + if (window.confirm("You can only leave when your balances are settled. Continue?")) { + try { + await leaveGroup(id); + alert('You have left the group'); + navigate('/groups'); + } catch (err: any) { + alert(err.response?.data?.detail || "Cannot leave - please settle balances first"); + } + } + }; + + const handleKickMember = async (memberId: string, memberName: string) => { + if (!id || !isAdmin) return; + if (memberId === user?._id) return; + + if (window.confirm(`Are you sure you want to remove ${memberName} from the group?`)) { + try { + const hasUnsettled = settlements.some( + s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0 + ); + if (hasUnsettled) { + alert('Cannot remove: This member has unsettled balances in the group.'); + return; + } + await removeMember(id, memberId); + fetchData(); + } catch (err: any) { + alert(err.response?.data?.detail || "Failed to remove member"); + } + } + }; + + const resetExpenseForm = () => { + setDescription(''); + setAmount(''); + setSplitValues({}); + setSplitType(SplitType.EQUAL); + setUnequalMode('amount'); + setSelectedUsers(new Set(members.map(m => m.userId))); + if (user) setPayerId(user._id); + }; + + if (loading && !group) return
; + if (!group) return
Group not found
; + + return ( +
+ {/* Immersive Header */} + +
+
+ +
+
+
+ + Group + +
- ))} - {members.length > 5 && ( -
- +{members.length - 5} +

+ {group.name} +

+
+ +
+
+ {members.slice(0, 5).map((m, i) => ( +
+ {m.user?.name?.charAt(0)} +
+ ))} + {members.length > 5 && ( +
+ +{members.length - 5} +
+ )} +
- )} +
+ + +
+
+
+ + + {/* Navigation Pills */} +
+
+ +
-
- - - - {/* Tabs & Actions */} -
-
- - -
-
- - -
-
- - {/* Content */} - - {activeTab === 'expenses' ? ( - - {loading ? Array(3).fill(0).map((_, i) => ) : - expenses.map((expense, idx) => ( - openEditExpense(expense)} - className={`p-4 flex items-center justify-between group cursor-pointer - ${style === THEMES.NEOBRUTALISM - ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-all' - : 'bg-white/5 border border-white/10 rounded-xl backdrop-blur-sm hover:bg-white/10 transition-colors'} - `} + + {/* Content Area */} + + {activeTab === 'expenses' ? ( + -
-
- -
-
-

- {expense.description} - -

-

- {members.find(m => m.userId === expense.paidBy)?.user?.name || 'Unknown'} paid {group.currency} {expense.amount.toFixed(2)} -

-
-
-
-

{new Date(expense.createdAt).toLocaleDateString()}

-
- {expense.splitType} + {loading ? Array(3).fill(0).map((_, i) => ) : + expenses.map((expense, idx) => ( + openEditExpense(expense)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditExpense(expense); } }} + tabIndex={0} + role="button" + aria-label={`Expense: ${expense.description}, ${group.currency} ${expense.amount.toFixed(2)}`} + className={`p-5 flex items-center gap-5 cursor-pointer group relative overflow-hidden ${style === THEMES.NEOBRUTALISM + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none' + : 'bg-white/5 border border-white/10 rounded-2xl backdrop-blur-sm hover:bg-white/10 transition-all' + }`} + > + {/* Date Box */} +
+ {new Date(expense.createdAt).toLocaleString('default', { month: 'short' })} + {new Date(expense.createdAt).getDate()} +
+ +
+

{expense.description}

+
+
+ {members.find(m => m.userId === expense.paidBy)?.user?.name?.charAt(0)} +
+

+ {members.find(m => m.userId === expense.paidBy)?.user?.name || 'Unknown'} paid {group.currency} {expense.amount.toFixed(2)} +

+
+
+ +
+ + {expense.splitType} + +
+
+ ))} + {!loading && expenses.length === 0 && ( +
+
+ +
+

No expenses yet

+

Add your first expense to get started!

-
+ )} - ))} - {!loading && expenses.length === 0 && ( -
- -

No expenses yet. Add one to get started!

-
- )} - - ) : ( - - -
- {loading ? : settlements.map((s, idx) => ( - + {loading ? : settlements.map((s, idx) => ( + -
-
- {s.fromUserName.charAt(0)} -
-
- {s.fromUserName} - owes +
+
+
+ {s.fromUserName.charAt(0)} +
+ {s.fromUserName}
- -
- {s.toUserName} - receives + +
+ Pays +
+
+
+ {group.currency} {s.amount.toFixed(2)}
-
- {s.toUserName.charAt(0)} + +
+
+ {s.toUserName.charAt(0)} +
+ {s.toUserName}
-
- {group.currency} {s.amount.toFixed(2)} -
))} {!loading && settlements.length === 0 && ( -
- -

All settled up!

+
+
+ +
+

All Settled Up!

+

No outstanding balances in this group.

)} + + )} + + + {/* --- MODALS --- */} + + setIsExpenseModalOpen(false)} + title={editingExpenseId ? 'Edit Expense' : 'Add Expense'} + footer={ +
+ {editingExpenseId ? ( + + ) :
} +
+ + +
- - - )} - - - {/* --- MODALS --- */} - - {/* Expense Modal */} - setIsExpenseModalOpen(false)} - title={editingExpenseId ? 'Edit Expense' : 'Add Expense'} - footer={ -
- {editingExpenseId ? ( - - ) :
} -
- - -
-
- } - > -
-
- setDescription(e.target.value)} - placeholder="e.g. Dinner at Mario's" - required - autoFocus - /> -
-
- setAmount(e.target.value)} - placeholder="0.00" - required + } + > + +
+ setDescription(e.target.value)} + placeholder="e.g. Dinner at Mario's" + required + autoFocus /> -
-
- -
- {currency} +
+
+ setAmount(e.target.value)} + placeholder="0.00" + required + /> +
+
+ +
+ {currency} +
+
-
-
- -
- -
- {members.map(m => ( - - ))} -
-
-
-
- - + ))}
- Split Unequally - -
+
- {splitType === SplitType.EQUAL ? ( -
-
-

Who is involved?

- +
-
- {members.map(m => { - const isSelected = selectedUsers.has(m.userId); - return ( -
+
+

Who is involved?

+ +
+
+ {members.map(m => { + const isSelected = selectedUsers.has(m.userId); + return ( +
{ + const newSet = new Set(selectedUsers); + if (isSelected) newSet.delete(m.userId); + else newSet.add(m.userId); + setSelectedUsers(newSet); + }} + className={`cursor-pointer flex items-center gap-2 p-2 border transition-all ${isSelected + ? (style === THEMES.NEOBRUTALISM ? 'bg-white border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none' : 'bg-blue-500/20 border-blue-500/50 text-white rounded') + : (style === THEMES.NEOBRUTALISM ? 'bg-transparent border-gray-400 opacity-60 rounded-none' : 'bg-transparent border-gray-700 opacity-50 rounded') + }`} + > +
+ {isSelected && } +
+ {m.user?.name} +
+ ); + })} +
+
+ ) : ( +
+
+ {[ + { id: 'amount', label: 'Amount', icon: DollarSign }, + { id: 'percentage', label: 'Percentage', icon: PieChart }, + { id: 'shares', label: 'Shares', icon: Hash }, + ].map(mode => ( + + ))} +
+ +
+ {members.map(m => ( +
+ {m.user?.name} +
+ setSplitValues({ ...splitValues, [m.userId]: e.target.value })} + /> + + {unequalMode === 'percentage' ? '%' : (unequalMode === 'shares' ? 'shares' : currency)} + +
- {m.user?.name} + ))} +
+ + {(unequalMode === 'amount' || unequalMode === 'percentage') && ( +
+ {Math.abs(remainingAmount) < 0.01 ? ( + Perfectly distributed + ) : ( + {remainingAmount > 0 ? `${remainingAmount.toFixed(2)} remaining` : `${Math.abs(remainingAmount).toFixed(2)} over limit`} + )}
- ); - })} -
+ )} +
+ )}
- ) : ( -
-
- {[ - { id: 'amount', label: 'Amount', icon: DollarSign }, - { id: 'percentage', label: 'Percentage', icon: PieChart }, - { id: 'shares', label: 'Shares', icon: Hash }, - ].map(mode => ( - - ))} + + + + setIsPaymentModalOpen(false)} + title="Record Payment" + footer={ + <> + + + + } + > +
+
+ + +
+
+ +
+
+ + +
+ setPaymentAmount(e.target.value)} + required + /> +
+
+ + { setIsSettingsModalOpen(false); setSettingsTab('info'); }} + title="Group Settings" + > +
+
+ + + +
+ + {settingsTab === 'info' && ( +
+
+ setEditGroupName(e.target.value)} + disabled={!isAdmin} + required + /> + {isAdmin && } +
+
+

Invite Code

+
+ + {group.joinCode} + + + +
+
- -
+ )} + + {settingsTab === 'members' && ( +
{members.map(m => ( -
- {m.user?.name} -
- setSplitValues({...splitValues, [m.userId]: e.target.value})} - /> - - {unequalMode === 'percentage' ? '%' : (unequalMode === 'shares' ? 'shares' : currency)} - +
+
+
+ {m.user?.name?.charAt(0)} +
+
+

{m.user?.name}

+

{m.role}

+
+ {isAdmin && m.userId !== user?._id && ( + + )}
))}
- - {(unequalMode === 'amount' || unequalMode === 'percentage') && ( -
- {Math.abs(remainingAmount) < 0.01 ? ( - Perfectly distributed - ) : ( - {remainingAmount > 0 ? `${remainingAmount.toFixed(2)} remaining` : `${Math.abs(remainingAmount).toFixed(2)} over limit`} - )} + )} + + {settingsTab === 'danger' && ( +
+
+

Leave Group

+

You can only leave if you have no outstanding balances.

+
- )} -
- )} -
- - - - {/* Payment Modal */} - setIsPaymentModalOpen(false)} - title="Record Payment" - footer={ - <> - - - - } - > -
-
- - -
-
- -
-
- - -
- setPaymentAmount(e.target.value)} - required - /> -
-
- - {/* Settings Modal */} - setIsSettingsModalOpen(false)} - title="Group Settings" - > -
-
- setEditGroupName(e.target.value)} - required - /> -
- -
-
- -
-

Danger Zone

-

Deleting this group is permanent and cannot be undone.

- -
-
-
-
- ); + + {isAdmin && ( +
+

Delete Group

+

Permanently delete this group and all expenses. This cannot be undone.

+ +
+ )} +
+ )} +
+ +
+ ); }; + +const ScaleIcon = () => ( + + Balance scale icon + + + + + + +); diff --git a/web/pages/Groups.tsx b/web/pages/Groups.tsx index 67aa2d6..7418bde 100644 --- a/web/pages/Groups.tsx +++ b/web/pages/Groups.tsx @@ -1,6 +1,6 @@ -import { motion, Variants } from 'framer-motion'; -import { ArrowRight, Plus, TrendingDown, TrendingUp, Users } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { AnimatePresence, motion, Variants } from 'framer-motion'; +import { ArrowRight, Plus, Search, TrendingDown, TrendingUp, Users } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; @@ -19,9 +19,11 @@ export const Groups = () => { const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(''); const [joinCode, setJoinCode] = useState(''); - + const [searchTerm, setSearchTerm] = useState(''); + const navigate = useNavigate(); const { style, mode } = useTheme(); + const isNeo = style === THEMES.NEOBRUTALISM; useEffect(() => { loadData(); @@ -71,6 +73,11 @@ export const Groups = () => { } }; + const filteredGroups = useMemo(() => + groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase())), + [groups, searchTerm] + ); + const containerVariants: Variants = { hidden: { opacity: 0 }, show: { @@ -87,91 +94,131 @@ export const Groups = () => { }; return ( -
-
- -

Groups

-

Manage shared expenses with your squads.

-
- - - - -
- - + {/* Immersive Header */} + +
+
+ +
+
+
+ + Dashboard + +
+

+ Groups +

+
+ +
+
+ + +
+
+ + setSearchTerm(e.target.value)} + className={`pl-12 pr-4 py-3 outline-none transition-all w-full font-bold ${isNeo + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' + : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-xl text-white placeholder:text-white/40' + }`} + /> +
+
+
+ + + - {loading ? ( - Array(3).fill(0).map((_, i) => ( - - )) - ) : ( - groups.map((group) => { - const groupBalance = getGroupBalance(group._id); - const balanceAmount = groupBalance?.amount || 0; - - return ( - navigate(`/groups/${group._id}`)} - className={`group cursor-pointer transition-all duration-300 relative overflow-hidden flex flex-col h-full - ${style === THEMES.NEOBRUTALISM - ? `border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] ${mode === 'dark' ? 'bg-zinc-800' : 'bg-white'}` - : `rounded-2xl border shadow-lg backdrop-blur-md ${mode === 'dark' ? 'border-white/20 bg-white/10 hover:bg-white/15' : 'border-black/10 bg-white/60 hover:bg-white/80'}`} - `} - > -
+ + {loading ? ( + Array(3).fill(0).map((_, i) => ( + + )) + ) : ( + filteredGroups.map((group) => { + const groupBalance = getGroupBalance(group._id); + const balanceAmount = groupBalance?.amount || 0; + + return ( + navigate(`/groups/${group._id}`)} + className={`group cursor-pointer transition-all duration-300 relative overflow-hidden flex flex-col h-full + ${isNeo + ? `bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none` + : `rounded-3xl border shadow-lg backdrop-blur-md ${mode === 'dark' ? 'border-white/20 bg-white/5 hover:bg-white/10' : 'border-black/5 bg-white/60 hover:bg-white/80'}`} + `} + > +
+
- - {balanceAmount !== 0 && ( -
0 - ? (style === THEMES.NEOBRUTALISM ? 'bg-white text-green-600 border-2 border-black' : 'bg-green-500/20 text-green-300 border border-green-500/30') - : (style === THEMES.NEOBRUTALISM ? 'bg-white text-red-600 border-2 border-black' : 'bg-red-500/20 text-red-300 border border-red-500/30') - }`}> - {balanceAmount > 0 ? : } - {balanceAmount > 0 ? 'Owed' : 'Owe'} ${Math.abs(balanceAmount).toFixed(2)} -
- )} -
-
- -
-

{group.name}

-

Currency: {group.currency}

- -
- Created: {new Date(group.createdAt).toLocaleDateString()} -
- +
+ {group.name.charAt(0)} +
+ {balanceAmount !== 0 && ( +
0 + ? (isNeo ? 'bg-emerald-200 text-black border-2 border-black rounded-none' : 'bg-emerald-500/20 text-emerald-500 border border-emerald-500/30 rounded-full') + : (isNeo ? 'bg-red-200 text-black border-2 border-black rounded-none' : 'bg-red-500/20 text-red-500 border border-red-500/30 rounded-full') + }`}> + {balanceAmount > 0 ? : } + {balanceAmount > 0 ? 'Owed' : 'Owe'} {group.currency} {Math.abs(balanceAmount).toFixed(2)}
+ )}
-
- - ); - }) - )} +
- {!loading && groups.length === 0 && ( -
-

No groups found

-

Create one or join an existing group to get started.

-
+
+

{group.name}

+

Currency: {group.currency}

+ +
+ Created {new Date(group.createdAt).toLocaleDateString()} +
+ +
+
+
+ + ); + }) + )} + + + {!loading && filteredGroups.length === 0 && ( +
+ +

No groups found

+

Create one or join an existing group to get started.

+
)} - setIsCreateModalOpen(false)} + setIsCreateModalOpen(false)} title="Create Group" footer={ <> @@ -181,20 +228,21 @@ export const Groups = () => { } >
- setNewGroupName(e.target.value)} + label="Group Name" + value={newGroupName} + onChange={(e) => setNewGroupName(e.target.value)} placeholder="e.g. Hawaii Trip 2024" required + className={isNeo ? 'rounded-none' : ''} />
- setIsJoinModalOpen(false)} + setIsJoinModalOpen(false)} title="Join Group" footer={ <> @@ -204,13 +252,14 @@ export const Groups = () => { } >
- setJoinCode(e.target.value)} + label="Invite Code" + value={joinCode} + onChange={(e) => setJoinCode(e.target.value)} placeholder="Paste code here" required + className={isNeo ? 'rounded-none' : ''} />
diff --git a/web/pages/Profile.tsx b/web/pages/Profile.tsx new file mode 100644 index 0000000..472739b --- /dev/null +++ b/web/pages/Profile.tsx @@ -0,0 +1,310 @@ +import { motion } from 'framer-motion'; +import { Camera, ChevronRight, CreditCard, LogOut, Mail, MessageSquare, Settings, Shield, User } from 'lucide-react'; +import React, { useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; +import { Modal } from '../components/ui/Modal'; +import { THEMES } from '../constants'; +import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContext'; +import { updateProfile } from '../services/api'; + +export const Profile = () => { + const { user, logout, updateUserInContext } = useAuth(); + const { style } = useTheme(); + const navigate = useNavigate(); + const fileInputRef = useRef(null); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editName, setEditName] = useState(user?.name || ''); + const [pickedImage, setPickedImage] = useState<{ url: string; base64: string } | null>(null); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const handleImagePick = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + setSaveError('Image must be less than 5MB'); + return; + } + + if (!file.type.startsWith('image/')) { + setSaveError('Please select a valid image file'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + setPickedImage({ url: result, base64: result }); + }; + reader.readAsDataURL(file); + }; + + const handleSaveProfile = async () => { + if (!editName.trim()) { + setSaveError('Name cannot be empty'); + return; + } + + setSaveError(null); + setIsSaving(true); + try { + const updates: { name?: string; imageUrl?: string } = {}; + if (editName !== user?.name) { + updates.name = editName; + } + if (pickedImage?.base64) { + updates.imageUrl = pickedImage.base64; + } + + if (Object.keys(updates).length > 0) { + const response = await updateProfile(updates); + const updatedUser = { ...user!, ...updates, ...(response.data || {}) }; + updateUserInContext(updatedUser); + } + setIsEditModalOpen(false); + } catch (error) { + console.error('Failed to update profile:', error); + setSaveError('Failed to update profile. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + const openEditModal = () => { + setEditName(user?.name || ''); + setPickedImage(null); + setIsEditModalOpen(true); + }; + + const handleComingSoon = () => { + alert('This feature is coming soon!'); + }; + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const avatarUrl = pickedImage?.url || user?.imageUrl; + const isValidImageUrl = avatarUrl && /^(https?:|data:image)/.test(avatarUrl); + const isNeo = style === THEMES.NEOBRUTALISM; + + const menuSections = [ + { + title: 'Account', + items: [ + { label: 'Edit Profile', icon: User, onClick: openEditModal, desc: 'Update your personal info' }, + { label: 'Email Settings', icon: Mail, onClick: handleComingSoon, desc: 'Manage email preferences' }, + { label: 'Security', icon: Shield, onClick: handleComingSoon, desc: 'Password and 2FA' }, + ] + }, + { + title: 'App', + items: [ + { label: 'Appearance', icon: Settings, onClick: handleComingSoon, desc: 'Theme and display settings' }, + { label: 'Send Feedback', icon: MessageSquare, onClick: handleComingSoon, desc: 'Help us improve' }, + ] + } + ]; + + return ( +
+ {/* Hero Header */} +
+
+ {/* Abstract shapes */} +
+
+ +
+

PROFILE

+
+
+ +
+ {/* Profile Card */} + +
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditModal(); } }} + tabIndex={0} + role="button" + aria-label="Edit profile picture" + > +
+ {isValidImageUrl ? ( + {user?.name} + ) : ( +
+ {user?.name?.charAt(0) || 'A'} +
+ )} +
+
+
+ +
+
+
+ +
+

{user?.name}

+

{user?.email}

+
+
+ + Pro Member +
+
+ + Verified +
+
+
+
+
+ + {/* Menu Sections */} +
+ {menuSections.map((section, idx) => ( + +

{section.title}

+
+ {section.items.map((item, itemIdx) => ( + + ))} +
+
+ ))} + + + + Log Out + +
+
+ + {/* Edit Profile Modal */} + setIsEditModalOpen(false)} + title="Edit Profile" + footer={ + <> + + + + } + > +
+ {saveError && ( +
+ {saveError} +
+ )} +
+
fileInputRef.current?.click()} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInputRef.current?.click(); } }} + tabIndex={0} + role="button" + aria-label="Change profile photo" + > + {pickedImage?.url || (user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl)) ? ( + Profile + ) : ( +
+ {editName?.charAt(0) || user?.name?.charAt(0) || 'A'} +
+ )} +
+ +
+
+ +

Click to change photo

+
+ + setEditName(e.target.value)} + placeholder="Enter your name" + required + className={isNeo ? 'rounded-none' : ''} + /> +
+
+
+ ); +}; diff --git a/web/services/api.ts b/web/services/api.ts index 397333d..8efb4dc 100644 --- a/web/services/api.ts +++ b/web/services/api.ts @@ -20,6 +20,7 @@ api.interceptors.request.use((config) => { // Auth export const login = async (data: any) => api.post('/auth/login/email', data); export const signup = async (data: any) => api.post('/auth/signup/email', data); +export const loginWithGoogle = async (idToken: string) => api.post('/auth/login/google', { id_token: idToken }); export const getProfile = async () => api.get('/users/me'); // Groups @@ -46,5 +47,10 @@ export const markSettlementPaid = async (groupId: string, settlementId: string) // Users export const getBalanceSummary = async () => api.get('/users/me/balance-summary'); export const getFriendsBalance = async () => api.get('/users/me/friends-balance'); +export const updateProfile = async (data: { name?: string; imageUrl?: string }) => api.patch('/users/me', data); + +// Group Management +export const leaveGroup = async (groupId: string) => api.post(`/groups/${groupId}/leave`); +export const removeMember = async (groupId: string, userId: string) => api.delete(`/groups/${groupId}/members/${userId}`); export default api; \ No newline at end of file diff --git a/web/services/firebase.ts b/web/services/firebase.ts new file mode 100644 index 0000000..7a918c9 --- /dev/null +++ b/web/services/firebase.ts @@ -0,0 +1,29 @@ +import { initializeApp } from "firebase/app"; +import { getAuth, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; + +// Your web app's Firebase configuration +const firebaseConfig = { + apiKey: "AIzaSyC4Ny4BSh3q4fNEVBGyw2u_FvLaxXukB8U", + authDomain: "splitwiser-25e34.firebaseapp.com", + projectId: "splitwiser-25e34", + storageBucket: "splitwiser-25e34.firebasestorage.app", + messagingSenderId: "323312632683", + appId: "1:323312632683:web:eef9ca7acc5c5a89ce422e", + measurementId: "G-SDY9ZRV9V4" +}; + +// Initialize Firebase +const app = initializeApp(firebaseConfig); +const auth = getAuth(app); +const googleProvider = new GoogleAuthProvider(); + +// Sign in with Google popup +export const signInWithGoogle = async (): Promise => { + const result = await signInWithPopup(auth, googleProvider); + // Get the ID token to send to your backend + const idToken = await result.user.getIdToken(); + return idToken; +}; + +export { auth, googleProvider }; +