From 5902100bc882f16c04f8243c2d2ba3c4f826c1f6 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:18:51 +0530 Subject: [PATCH 1/6] feat: enhance Friends page with group breakdown and loading states - Refactored Friends component to include group breakdown for each friend. - Added loading skeletons while fetching friends data. - Implemented expandable friend rows to show detailed group balances. - Improved error handling and user feedback during data fetching. feat: improve GroupDetails with member management and settings tabs - Added functionality to leave groups and kick members with confirmation prompts. - Introduced settings tabs for group information, members, and danger actions. - Enhanced UI for inviting members and managing group settings. feat: create Profile page for user account management - Implemented profile editing functionality with image upload and name change. - Added modal for editing profile details and handling image selection. - Integrated logout functionality and menu items for account settings. feat: update API service with new endpoints for profile and group management - Added API calls for updating user profiles and managing group memberships. - Included Google login functionality in the authentication service. chore: add Firebase service for Google authentication - Set up Firebase configuration and authentication methods for Google sign-in. --- web/App.tsx | 13 +- web/components/layout/Sidebar.tsx | 17 +- web/contexts/AuthContext.tsx | 11 +- web/package-lock.json | 915 +++++++++++++++++++++++++++++- web/package.json | 1 + web/pages/Auth.tsx | 179 ++++-- web/pages/Friends.tsx | 320 +++++++++-- web/pages/GroupDetails.tsx | 284 ++++++++-- web/pages/Profile.tsx | 224 ++++++++ web/services/api.ts | 6 + web/services/firebase.ts | 29 + 11 files changed, 1840 insertions(+), 159 deletions(-) create mode 100644 web/pages/Profile.tsx create mode 100644 web/services/firebase.ts diff --git a/web/App.tsx b/web/App.tsx index c2bb0a5a..2d99d827 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 4549b1f5..c1ed305f 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/contexts/AuthContext.tsx b/web/contexts/AuthContext.tsx index edcdba8f..881741f8 100644 --- a/web/contexts/AuthContext.tsx +++ b/web/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { User } from '../types'; +import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; import { getProfile } from '../services/api'; +import { User } from '../types'; interface AuthContextType { user: User | null; @@ -8,6 +8,7 @@ interface AuthContextType { isLoading: boolean; login: (token: string, user: User) => void; logout: () => void; + updateUserInContext: (userData: User) => void; } const AuthContext = createContext(undefined); @@ -47,8 +48,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setUser(null); }; + const updateUserInContext = (userData: User) => { + setUser(userData); + }; + return ( - + {children} ); diff --git a/web/package-lock.json b/web/package-lock.json index af1addc9..0a9206c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "axios": "^1.13.2", + "firebase": "^12.6.0", "framer-motion": "^11.13.1", "lucide-react": "^0.554.0", "react": "^19.2.0", @@ -852,6 +853,599 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase/ai": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.6.0.tgz", + "integrity": "sha512-NGyE7NQDFznOv683Xk4+WoUv39iipa9lEfrwvvPz33ChzVbCCiB69FJQTK2BI/11pRtzYGbHo1/xMz7gxWWhJw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.19", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.19.tgz", + "integrity": "sha512-3wU676fh60gaiVYQEEXsbGS4HbF2XsiBphyvvqDbtC1U4/dO4coshbYktcCHq+HFaGIK07iHOh4pME0hEq1fcg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.25.tgz", + "integrity": "sha512-fdzoaG0BEKbqksRDhmf4JoyZf16Wosrl0Y7tbZtJyVDOOwziE0vrFjmZuTdviL0yhak+Nco6rMsUUbkbD+qb6Q==", + "dependencies": { + "@firebase/analytics": "0.10.19", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==" + }, + "node_modules/@firebase/app": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.6.tgz", + "integrity": "sha512-4uyt8BOrBsSq6i4yiOV/gG6BnnrvTeyymlNcaN/dKvyU1GoolxAafvIvaNP1RCGPlNab3OuE4MKUQuv2lH+PLQ==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.0.tgz", + "integrity": "sha512-XAvALQayUMBJo58U/rxW02IhsesaxxfWVmVkauZvGEz3vOAjMEQnzFlyblqkc2iAaO82uJ2ZVyZv9XzPfxjJ6w==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.0.tgz", + "integrity": "sha512-UfK2Q8RJNjYM/8MFORltZRG9lJj11k0nW84rrffiKvcJxLf1jf6IEjCIkCamykHE73C6BwqhVfhIBs69GXQV0g==", + "dependencies": { + "@firebase/app-check": "0.11.0", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.6.tgz", + "integrity": "sha512-YYGARbutghQY4zZUWMYia0ib0Y/rb52y72/N0z3vglRHL7ii/AaK9SA7S/dzScVOlCdnbHXz+sc5Dq+r8fwFAg==", + "dependencies": { + "@firebase/app": "0.14.6", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" + }, + "node_modules/@firebase/auth": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.11.1.tgz", + "integrity": "sha512-Mea0G/BwC1D0voSG+60Ylu3KZchXAFilXQ/hJXWCw3gebAu+RDINZA0dJMNeym7HFxBaBaByX8jSa7ys5+F2VA==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.1.tgz", + "integrity": "sha512-I0o2ZiZMnMTOQfqT22ur+zcGDVSAfdNZBHo26/Tfi8EllfR1BO7aTVo2rt/ts8o/FWsK8pOALLeVBGhZt8w/vg==", + "dependencies": { + "@firebase/auth": "1.11.1", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.12.tgz", + "integrity": "sha512-baPddcoNLj/+vYo+HSJidJUdr5W4OkhT109c5qhR8T1dJoZcyJpkv/dFpYlw/VJ3dV66vI8GHQFrmAZw/xUS4g==", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.9.2.tgz", + "integrity": "sha512-iuA5+nVr/IV/Thm0Luoqf2mERUvK9g791FZpUJV1ZGXO6RL2/i/WFJUj5ZTVXy5pRjpWYO+ZzPcReNrlilmztA==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.2.tgz", + "integrity": "sha512-cy7ov6SpFBx+PHwFdOOjbI7kH00uNKmIFurAn560WiPCZXy9EMnil1SOG7VF4hHZKdenC+AHtL4r3fNpirpm0w==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/firestore": "4.9.2", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.1.tgz", + "integrity": "sha512-sUeWSb0rw5T+6wuV2o9XNmh9yHxjFI9zVGFnjFi+n7drTEWpl7ZTz1nROgGrSu472r+LAaj+2YaSicD4R8wfbw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.1.tgz", + "integrity": "sha512-AxxUBXKuPrWaVNQ8o1cG1GaCAtXT8a0eaTDfqgS5VsRYLAR0ALcfqDLwo/QyijZj1w8Qf8n3Qrfy/+Im245hOQ==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/functions": "0.13.1", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==" + }, + "node_modules/@firebase/installations": { + "version": "0.6.19", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.19.tgz", + "integrity": "sha512-nGDmiwKLI1lerhwfwSHvMR9RZuIH5/8E3kgUWnVRqqL7kGVSktjLTWEMva7oh5yxQ3zXfIlIwJwMcaM5bK5j8Q==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.19", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.19.tgz", + "integrity": "sha512-khfzIY3EI5LePePo7vT19/VEIH1E3iYsHknI/6ek9T8QCozAZshWT9CjlwOzZrKvTHMeNcbpo/VSOSIWDSjWdQ==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.23.tgz", + "integrity": "sha512-cfuzv47XxqW4HH/OcR5rM+AlQd1xL/VhuaeW/wzMW1LFrsFcTn0GND/hak1vkQc2th8UisBcrkVcQAnOnKwYxg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.23.tgz", + "integrity": "sha512-SN857v/kBUvlQ9X/UjAqBoQ2FEaL1ZozpnmL1ByTe57iXkmnVVFm9KqAsTfmf+OEwWI4kJJe9NObtN/w22lUgg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/messaging": "0.12.23", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==" + }, + "node_modules/@firebase/performance": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.9.tgz", + "integrity": "sha512-UzybENl1EdM2I1sjYm74xGt/0JzRnU/0VmfMAKo2LSpHJzaj77FCLZXmYQ4oOuE+Pxtt8Wy2BVJEENiZkaZAzQ==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.22.tgz", + "integrity": "sha512-xLKxaSAl/FVi10wDX/CHIYEUP13jXUjinL+UaNXT9ByIvxII5Ne5150mx6IgM8G6Q3V+sPiw9C8/kygkyHUVxg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.9", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==" + }, + "node_modules/@firebase/remote-config": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.7.0.tgz", + "integrity": "sha512-dX95X6WlW7QlgNd7aaGdjAIZUiQkgWgNS+aKNu4Wv92H1T8Ue/NDUjZHd9xb8fHxLXIHNZeco9/qbZzr500MjQ==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.20.tgz", + "integrity": "sha512-P/ULS9vU35EL9maG7xp66uljkZgcPMQOxLj3Zx2F289baTKSInE6+YIkgHEi1TwHoddC/AFePXPpshPlEFkbgg==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.7.0", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==" + }, + "node_modules/@firebase/storage": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.0.tgz", + "integrity": "sha512-xWWbb15o6/pWEw8H01UQ1dC5U3rf8QTAzOChYyCpafV6Xki7KVp3Yaw2nSklUwHEziSWE9KoZJS7iYeyqWnYFA==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.0.tgz", + "integrity": "sha512-vDzhgGczr1OfcOy285YAPur5pWDEvD67w4thyeCUh6Ys0izN9fNYtA1MJERmNBfqjqu0lg0FM5GLbw0Il21M+g==", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/storage": "0.14.0", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -945,6 +1539,60 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", @@ -1647,7 +2295,6 @@ "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1714,11 +2361,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1902,6 +2556,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1914,7 +2581,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1925,8 +2591,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "peer": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2150,6 +2815,11 @@ "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", "dev": true }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -2258,7 +2928,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -2455,6 +3124,17 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "peer": true }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2500,6 +3180,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.6.0.tgz", + "integrity": "sha512-8ZD1Gcv916Qp8/nsFH2+QMIrfX/76ti6cJwxQUENLXXnKlOX/IJZaU2Y3bdYf5r1mbownrQKfnWtrt+MVgdwLA==", + "dependencies": { + "@firebase/ai": "2.6.0", + "@firebase/analytics": "0.10.19", + "@firebase/analytics-compat": "0.2.25", + "@firebase/app": "0.14.6", + "@firebase/app-check": "0.11.0", + "@firebase/app-check-compat": "0.4.0", + "@firebase/app-compat": "0.5.6", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.11.1", + "@firebase/auth-compat": "0.6.1", + "@firebase/data-connect": "0.3.12", + "@firebase/database": "1.1.0", + "@firebase/database-compat": "2.1.0", + "@firebase/firestore": "4.9.2", + "@firebase/firestore-compat": "0.4.2", + "@firebase/functions": "0.13.1", + "@firebase/functions-compat": "0.4.1", + "@firebase/installations": "0.6.19", + "@firebase/installations-compat": "0.2.19", + "@firebase/messaging": "0.12.23", + "@firebase/messaging-compat": "0.2.23", + "@firebase/performance": "0.7.9", + "@firebase/performance-compat": "0.2.22", + "@firebase/remote-config": "0.7.0", + "@firebase/remote-config-compat": "0.2.20", + "@firebase/storage": "0.14.0", + "@firebase/storage-compat": "0.4.0", + "@firebase/util": "1.13.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2623,6 +3338,14 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2744,6 +3467,16 @@ "node": ">= 0.4" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2804,6 +3537,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3177,12 +3918,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "peer": true }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3443,6 +4194,29 @@ "node": ">= 0.8.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3592,6 +4366,14 @@ "redux": "^5.0.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -3647,6 +4429,25 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3696,6 +4497,30 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3793,8 +4618,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/update-browserslist-db": { "version": "1.1.4", @@ -3938,6 +4762,32 @@ } } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3962,12 +4812,61 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 39188985..efae12aa 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.13.2", + "firebase": "^12.6.0", "framer-motion": "^11.13.1", "lucide-react": "^0.554.0", "react": "^19.2.0", diff --git a/web/pages/Auth.tsx b/web/pages/Auth.tsx index 588074fb..f676c155 100644 --- a/web/pages/Auth.tsx +++ b/web/pages/Auth.tsx @@ -1,13 +1,18 @@ +import { CreditCard, Sparkles } from 'lucide-react'; import React, { useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { useTheme } from '../contexts/ThemeContext'; -import { login as apiLogin, signup as apiSignup } from '../services/api'; +import { useNavigate } from 'react-router-dom'; import { Button } from '../components/ui/Button'; -import { Input } from '../components/ui/Input'; import { Card } from '../components/ui/Card'; +import { Input } from '../components/ui/Input'; import { THEMES } from '../constants'; -import { CreditCard, Sparkles } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContext'; +import { + login as apiLogin, + signup as apiSignup, + loginWithGoogle, +} from '../services/api'; +import { signInWithGoogle } from '../services/firebase'; export const Auth = () => { const [isLogin, setIsLogin] = useState(true); @@ -15,12 +20,43 @@ export const Auth = () => { const [password, setPassword] = useState(''); const [name, setName] = useState(''); const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [error, setError] = useState(''); - + const { login } = useAuth(); const { style, toggleStyle } = useTheme(); const navigate = useNavigate(); + // Handle Google Sign-In with Firebase + const handleGoogleSignIn = async () => { + setError(''); + setGoogleLoading(true); + + try { + // Get ID token from Firebase Google Sign-In + const idToken = await signInWithGoogle(); + // Send token to backend for verification + const res = await loginWithGoogle(idToken); + const { access_token, user } = res.data; + login(access_token, user); + navigate('/dashboard'); + } catch (err: any) { + console.error('Google login error:', err); + if (err.code === 'auth/popup-closed-by-user') { + // User closed the popup, not an error + setError(''); + } else if (err.response) { + setError( + err.response.data?.detail || 'Google authentication failed' + ); + } else { + setError(err.message || 'Google authentication failed. Please try again.'); + } + } finally { + setGoogleLoading(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -33,16 +69,18 @@ export const Auth = () => { } else { res = await apiSignup({ email, password, name }); } - + const { access_token, user } = res.data; login(access_token, user); navigate('/dashboard'); } catch (err: any) { - if (err.response) { - setError(err.response.data?.detail?.[0]?.msg || "Authentication failed"); - } else { - setError("Something went wrong"); - } + if (err.response) { + setError( + err.response.data?.detail?.[0]?.msg || 'Authentication failed' + ); + } else { + setError('Something went wrong'); + } } finally { setLoading(false); } @@ -52,64 +90,115 @@ export const Auth = () => {
-

- - Splitwiser -

+

+ + Splitwiser +

- +
-

{isLogin ? 'Welcome Back' : 'Create Account'}

-

Manage your expenses with style.

+

+ {isLogin ? 'Welcome Back' : 'Create Account'} +

+

+ Manage your expenses with style. +

+
+ + {/* Google Sign-In Button */} + + + {/* Divider */} +
+
+ or +
{!isLogin && ( - setName(e.target.value)} - required + setName(e.target.value)} + required /> )} - setEmail(e.target.value)} - required + setEmail(e.target.value)} + required /> - setPassword(e.target.value)} - required + setPassword(e.target.value)} + required /> - {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
-
- +
diff --git a/web/pages/Friends.tsx b/web/pages/Friends.tsx index 79f815c3..74fbe451 100644 --- a/web/pages/Friends.tsx +++ b/web/pages/Friends.tsx @@ -1,64 +1,290 @@ -import React, { useEffect, useState } from 'react'; -import { getFriendsBalance } from '../services/api'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ChevronDown, ChevronUp, Info, Users, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { Card } from '../components/ui/Card'; -import { User } from 'lucide-react'; +import { Skeleton } from '../components/ui/Skeleton'; +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 [expandedFriends, setExpandedFriends] = useState>(new Set()); + const [showTooltip, setShowTooltip] = useState(true); + 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 || []; + + // Create groups map for icons + const gMap = new Map( + groups.map((g: any) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) + ); + + // Transform friends data + const transformedFriends = friendsData.map((friend: any) => ({ + id: friend.userId, + userId: friend.userId, + userName: friend.userName, + userImageUrl: friend.userImageUrl, + netBalance: friend.netBalance, + breakdown: (friend.breakdown || []).map((group: any) => ({ + groupId: group.groupId, + groupName: group.groupName, + balance: group.balance, + imageUrl: gMap.get(group.groupId)?.imageUrl + })) + })); + + setFriends(transformedFriends); } catch (err) { - console.error(err); + console.error('Failed to fetch friends balance data:', err); + } finally { + setLoading(false); } }; - fetchFriends(); + fetchData(); }, []); + const toggleExpand = (friendId: string) => { + setExpandedFriends(prev => { + const newSet = new Set(prev); + if (newSet.has(friendId)) { + newSet.delete(friendId); + } else { + newSet.add(friendId); + } + return newSet; + }); + }; + + 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-12 h-12 text-lg' : 'w-9 h-9 text-sm'; + + if (imageUrl && /^(https?:|data:image)/.test(imageUrl)) { + return ( + {name} + ); + } + + // Check for base64 without prefix + if (imageUrl && /^[A-Za-z0-9+/=]+$/.test(imageUrl.substring(0, 50))) { + return ( + {name} + ); + } + + return ( +
+ {(name || '?').charAt(0)} +
+ ); + }; + + // Skeleton loading component + const SkeletonRow = () => ( +
+ +
+ + +
+
+ ); + + if (loading) { + return ( +
+

Friends

+ + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + +
+ ); + } + 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

+

Your balances across all shared groups

+
+ + {/* Tooltip/Explanation Banner */} + + {showTooltip && ( + + +

+ 💡 These amounts show your direct balance with each friend across all shared groups. + Click on a friend to see the breakdown by group. Check individual group details for + optimized settlement suggestions. +

+ +
+ )} +
+ + {/* Friends List */} + + + {friends.length === 0 ? ( +
+ +

No balances with friends yet.

+

Join or create a group and add expenses to get started!

+
+ ) : ( +
+ {friends.map((friend, index) => { + const isExpanded = expandedFriends.has(friend.id); + const balanceColor = friend.netBalance < 0 ? 'text-red-500' : 'text-emerald-500'; + const balanceText = friend.netBalance < 0 + ? `You owe ${formatCurrency(friend.netBalance)}` + : friend.netBalance > 0 + ? `Owes you ${formatCurrency(friend.netBalance)}` + : 'Settled up'; + + return ( + + {/* Friend Row */} + + + {/* Expanded Group Breakdown */} + + {isExpanded && friend.breakdown.length > 0 && ( + +
+ {friend.breakdown.map((group) => { + const groupBalanceColor = group.balance < 0 ? 'text-red-500' : 'text-emerald-500'; + const groupBalanceText = group.balance < 0 + ? `You owe ${formatCurrency(group.balance)}` + : `Owes you ${formatCurrency(group.balance)}`; + + return ( +
+
+ {getAvatarContent(group.imageUrl, group.groupName, 'sm')} + {group.groupName} +
+ + {groupBalanceText} + +
+ ); + })} +
+
+ )} +
+
+ ); + })}
- )} -
+ )} + +
); }; diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index c17b8b0f..0259b827 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -1,21 +1,29 @@ -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, Pencil, 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 { Card } from '../components/ui/Card'; 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'; @@ -55,6 +63,14 @@ export const GroupDetails = () => { // 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(); @@ -100,19 +116,46 @@ export const GroupDetails = () => { }; const copyToClipboard = () => { - if (group?.joinCode) navigator.clipboard.writeText(group.joinCode); + if (group?.joinCode) { + navigator.clipboard.writeText(group.joinCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + 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) { + // User cancelled or share failed, fallback to clipboard + 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); + const sum = values.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); + const sum = values.reduce((acc, val) => acc + (parseFloat(val) || 0), 0); return 100 - sum; } return 0; @@ -151,7 +194,7 @@ export const GroupDetails = () => { if (!id) return; const numAmount = parseFloat(amount); - let requestSplits = []; + let requestSplits: { userId: string; amount: number }[] = []; if (splitType === SplitType.EQUAL) { const involvedMembers = members.filter(m => selectedUsers.has(m.userId)); @@ -160,18 +203,19 @@ export const GroupDetails = () => { requestSplits = involvedMembers.map(m => ({ userId: m.userId, amount: splitAmount })); } else { // Handle Unequal + const splitVals = Object.values(splitValues) as string[]; if (unequalMode === 'amount') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + 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) || 0 })); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: parseFloat(val as string) || 0 })); } else if (unequalMode === 'percentage') { - const sum = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + 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) || 0)) / 100 })); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val as string) || 0)) / 100 })); } else if (unequalMode === 'shares') { - const totalShares = Object.values(splitValues).reduce((acc, val) => acc + (parseFloat(val) || 0), 0); + 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) || 0)) / totalShares })); + requestSplits = Object.entries(splitValues).map(([uid, val]) => ({ userId: uid, amount: (numAmount * (parseFloat(val as string) || 0)) / totalShares })); } } @@ -254,6 +298,41 @@ export const GroupDetails = () => { } }; + 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; // Can't kick yourself + + if (window.confirm(`Are you sure you want to remove ${memberName} from the group?`)) { + try { + // Check if member has unsettled balances + 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(''); @@ -682,29 +761,148 @@ export const GroupDetails = () => { {/* Settings Modal */} setIsSettingsModalOpen(false)} + onClose={() => { setIsSettingsModalOpen(false); setSettingsTab('info'); }} title="Group Settings" > -
-
- setEditGroupName(e.target.value)} - required - /> -
- -
-
- -
-

Danger Zone

-

Deleting this group is permanent and cannot be undone.

- +
+ {/* Tabs */} +
+ + +
+ + {/* Info Tab */} + {settingsTab === 'info' && ( +
+
+ setEditGroupName(e.target.value)} + disabled={!isAdmin} + required + /> + {isAdmin && ( +
+ +
+ )} +
+ + {/* Invite Section */} +
+

Invite Others

+
+ Join Code: + + {group.joinCode} + + +
+ +
+
+ )} + + {/* Members Tab */} + {settingsTab === 'members' && ( +
+ {members.map(m => { + const isSelf = m.userId === user?._id; + const memberImageUrl = m.user?.imageUrl; + const isValidImage = memberImageUrl && /^(https?:|data:image)/.test(memberImageUrl); + + return ( +
+
+ {isValidImage ? ( + {m.user?.name} + ) : ( +
+ {(m.user?.name || '?').charAt(0)} +
+ )} +
+

{m.user?.name} {isSelf && (You)}

+ {m.role === 'admin' && ( + + Admin + + )} +
+
+ {isAdmin && !isSelf && ( + + )} +
+ ); + })} +
+ )} + + {/* Danger Tab */} + {settingsTab === 'danger' && ( +
+
+

Leave Group

+

+ You can leave this group only when your balances are settled. +

+ +
+ + {isAdmin && ( +
+

Delete Group

+

+ This action is permanent and cannot be undone. Remove all members first. +

+ +
+ )} +
+ )}
diff --git a/web/pages/Profile.tsx b/web/pages/Profile.tsx new file mode 100644 index 00000000..8e0cfa63 --- /dev/null +++ b/web/pages/Profile.tsx @@ -0,0 +1,224 @@ +import { motion } from 'framer-motion'; +import { Camera, ChevronRight, LogOut, Mail, MessageSquare, User, X } from 'lucide-react'; +import React, { useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../components/ui/Button'; +import { Card } from '../components/ui/Card'; +import { Input } from '../components/ui/Input'; +import { Modal } from '../components/ui/Modal'; +import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContext'; +import { updateProfile } from '../services/api'; + +export const Profile = () => { + const { user, logout } = useAuth(); + const { style, mode } = 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 handleImagePick = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // result is already in data:image/...;base64,... format + setPickedImage({ url: result, base64: result }); + }; + reader.readAsDataURL(file); + }; + + const handleSaveProfile = async () => { + if (!editName.trim()) { + alert('Name cannot be empty'); + return; + } + + 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) { + await updateProfile(updates); + // Reload page to refresh user data + window.location.reload(); + } + setIsEditModalOpen(false); + } catch (error) { + console.error('Failed to update profile:', error); + alert('Failed to update profile'); + } 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'); + }; + + // Determine avatar display + const avatarUrl = pickedImage?.url || user?.imageUrl; + const isValidImageUrl = avatarUrl && /^(https?:|data:image)/.test(avatarUrl); + + const menuItems = [ + { label: 'Edit Profile', icon: User, onClick: openEditModal }, + { label: 'Email Settings', icon: Mail, onClick: handleComingSoon }, + { label: 'Send Feedback', icon: MessageSquare, onClick: handleComingSoon }, + { label: 'Logout', icon: LogOut, onClick: handleLogout, danger: true }, + ]; + + return ( +
+ +

Account

+
+ + {/* Profile Header */} + + +
+
+ {isValidImageUrl ? ( + {user?.name} + ) : ( +
+ {user?.name?.charAt(0) || 'A'} +
+ )} +
+

{user?.name}

+

{user?.email}

+
+
+
+ + {/* Menu Items */} + + +
+ {menuItems.map((item, index) => ( + + ))} +
+
+
+ + {/* Edit Profile Modal */} + setIsEditModalOpen(false)} + title="Edit Profile" + footer={ + <> + + + + } + > +
+ {/* Avatar Section */} +
+
+ {pickedImage?.url || (user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl)) ? ( + Profile + ) : ( +
+ {editName?.charAt(0) || user?.name?.charAt(0) || 'A'} +
+ )} + {pickedImage && ( + + )} +
+ + +
+ + {/* Name Input */} + setEditName(e.target.value)} + placeholder="Enter your name" + required + /> +
+
+
+ ); +}; diff --git a/web/services/api.ts b/web/services/api.ts index 397333d6..8efb4dc6 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 00000000..7a918c9c --- /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 }; + From c3d90ec58c47dc33cfbc1c282411182cbb512747 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:49:16 +0530 Subject: [PATCH 2/6] feat: Implement core application pages and reusable UI components. --- web/components/ui/Button.tsx | 24 +- web/components/ui/Modal.tsx | 26 +- web/pages/Auth.tsx | 261 +++--- web/pages/Dashboard.tsx | 54 +- web/pages/Friends.tsx | 369 ++++---- web/pages/GroupDetails.tsx | 1641 +++++++++++++++++----------------- web/pages/Groups.tsx | 222 +++-- web/pages/Profile.tsx | 461 +++++----- 8 files changed, 1591 insertions(+), 1467 deletions(-) diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx index 2580b1ad..cfccd8e8 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 Account'} +
+
+

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

-

- Manage your expenses with style. +

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

- {/* Google Sign-In Button */} - +
+ - {/* Divider */} -
-
- or -
-
+
+
+ Or continue with email +
+
+ +
+ + {!isLogin && ( + + setName(e.target.value)} + required + className={isNeo ? 'rounded-none' : ''} + /> + + )} + - - {!isLogin && ( setName(e.target.value)} + type="email" + placeholder="Email Address" + value={email} + onChange={(e) => setEmail(e.target.value)} required + className={isNeo ? 'rounded-none' : ''} + /> + setPassword(e.target.value)} + required + className={isNeo ? 'rounded-none' : ''} /> - )} - setEmail(e.target.value)} - required - /> - setPassword(e.target.value)} - required - /> - - {error && ( -
- {error} -
- )} - -
+ {error && ( + + {error} + + )} -
- -
- + + -
- +
+ +
+
diff --git a/web/pages/Dashboard.tsx b/web/pages/Dashboard.tsx index a5f9b614..621b1d77 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,31 +37,31 @@ export const Dashboard = () => {
-
- +
+

Owed to You

-

+

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

-
- +
+

You Owe

-

+

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

-
- +
+

Net Balance

-

= 0 ? 'text-emerald-500' : 'text-red-500'}`}> +

= 0 ? (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500') : (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-red-500')}`}> ${summary?.netBalance.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 74fbe451..c8e3a8a6 100644 --- a/web/pages/Friends.tsx +++ b/web/pages/Friends.tsx @@ -1,8 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion'; -import { ChevronDown, ChevronUp, Info, Users, X } from 'lucide-react'; +import { ArrowRight, Search, TrendingDown, TrendingUp, Users } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { Card } from '../components/ui/Card'; -import { Skeleton } from '../components/ui/Skeleton'; import { THEMES } from '../constants'; import { useTheme } from '../contexts/ThemeContext'; import { getFriendsBalance, getGroups } from '../services/api'; @@ -26,8 +24,8 @@ interface Friend { export const Friends = () => { const [friends, setFriends] = useState([]); const [loading, setLoading] = useState(true); - const [expandedFriends, setExpandedFriends] = useState>(new Set()); - const [showTooltip, setShowTooltip] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); const { style } = useTheme(); useEffect(() => { @@ -38,16 +36,14 @@ export const Friends = () => { getFriendsBalance(), getGroups() ]); - + const friendsData = friendsRes.data.friendsBalance || []; const groups = groupsRes.data.groups || []; - - // Create groups map for icons + const gMap = new Map( groups.map((g: any) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) ); - // Transform friends data const transformedFriends = friendsData.map((friend: any) => ({ id: friend.userId, userId: friend.userId, @@ -72,219 +68,212 @@ export const Friends = () => { fetchData(); }, []); - const toggleExpand = (friendId: string) => { - setExpandedFriends(prev => { - const newSet = new Set(prev); - if (newSet.has(friendId)) { - newSet.delete(friendId); - } else { - newSet.add(friendId); - } - return newSet; - }); + 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-12 h-12 text-lg' : 'w-9 h-9 text-sm'; - + 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} ); } - - // Check for base64 without prefix - if (imageUrl && /^[A-Za-z0-9+/=]+$/.test(imageUrl.substring(0, 50))) { - return ( - {name} - ); - } - return ( -
- {(name || '?').charAt(0)} +
+ {name.charAt(0)}
); }; - // Skeleton loading component - const SkeletonRow = () => ( -
- -
- - -
-
- ); - - if (loading) { - return ( -
-

Friends

- - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - -
- ); - } + const isNeo = style === THEMES.NEOBRUTALISM; return ( -
- -

Friends

-

Your balances across all shared groups

+
+ {/* 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' + }`} + /> +
+
- {/* Tooltip/Explanation Banner */} - - {showTooltip && ( - + - -

- 💡 These amounts show your direct balance with each friend across all shared groups. - Click on a friend to see the breakdown by group. Check individual group details for - optimized settlement suggestions. -

- -
- )} -
+ > +
+

Total Owed to You

+

{formatCurrency(totalOwedToYou)}

+
+
+ +
+ - {/* Friends List */} - - - {friends.length === 0 ? ( -
- -

No balances with friends yet.

-

Join or create a group and add expenses to get started!

-
+ +
+

Total You Owe

+

{formatCurrency(totalYouOwe)}

+
+
+ +
+
+
+ + {/* Friends Grid */} +
+ + {filteredFriends.length === 0 ? ( + + +

No friends found

+
) : ( -
- {friends.map((friend, index) => { - const isExpanded = expandedFriends.has(friend.id); - const balanceColor = friend.netBalance < 0 ? 'text-red-500' : 'text-emerald-500'; - const balanceText = friend.netBalance < 0 - ? `You owe ${formatCurrency(friend.netBalance)}` - : friend.netBalance > 0 - ? `Owes you ${formatCurrency(friend.netBalance)}` - : 'Settled up'; + filteredFriends.map((friend, index) => ( + toggleExpand(friend.id)} + className={`cursor-pointer group relative overflow-hidden flex flex-col transition-all duration-300 ${isNeo + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none' + : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl' + }`} + > +
+
+ {getAvatarContent(friend.userImageUrl, friend.userName, 'lg')} +
0 + ? (isNeo ? 'bg-emerald-200 text-black border border-black' : 'bg-emerald-500/20 text-emerald-400') + : friend.netBalance < 0 + ? (isNeo ? 'bg-orange-200 text-black border border-black' : 'bg-orange-500/20 text-orange-400') + : (isNeo ? 'bg-gray-200 text-black border border-black' : 'bg-white/10 text-white/60') + } ${isNeo ? 'rounded-none' : 'rounded-full'}`}> + {friend.netBalance > 0 ? 'Owes You' : friend.netBalance < 0 ? 'You Owe' : 'Settled'} +
+
- return ( - - {/* Friend Row */} -
+ + + {expandedId === friend.id && ( + -
- {getAvatarContent(friend.userImageUrl, friend.userName)} -
-

{friend.userName}

-

- {balanceText} -

-
-
-
- {friend.breakdown.length > 0 && ( - - {friend.breakdown.length} group{friend.breakdown.length !== 1 ? 's' : ''} - +
+

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 ? '+' : ''}{formatCurrency(g.balance)} + +
+ ))} + {friend.breakdown.length === 0 && ( +

No active groups

)} - {isExpanded ? : } -
- - - {/* Expanded Group Breakdown */} - - {isExpanded && friend.breakdown.length > 0 && ( - -
- {friend.breakdown.map((group) => { - const groupBalanceColor = group.balance < 0 ? 'text-red-500' : 'text-emerald-500'; - const groupBalanceText = group.balance < 0 - ? `You owe ${formatCurrency(group.balance)}` - : `Owes you ${formatCurrency(group.balance)}`; - - return ( -
-
- {getAvatarContent(group.imageUrl, group.groupName, 'sm')} - {group.groupName} -
- - {groupBalanceText} - -
- ); - })} -
-
- )} -
- - ); - })} -
+ View Details + +
+ + )} +
+ + )) )} - - + +
); }; diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index 0259b827..a005ce41 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -1,9 +1,8 @@ import { AnimatePresence, motion } from 'framer-motion'; -import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, Pencil, PieChart, Plus, Receipt, Settings, Share2, Trash2, UserMinus } from 'lucide-react'; +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 { Card } from '../components/ui/Card'; import { Input } from '../components/ui/Input'; import { Modal } from '../components/ui/Modal'; import { Skeleton } from '../components/ui/Skeleton'; @@ -28,883 +27,865 @@ import { Expense, Group, GroupMember, SplitType } from '../types'; type UnequalMode = 'amount' | 'percentage' | 'shares'; 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(''); - 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); + 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); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - 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) { - // User cancelled or share failed, fallback to clipboard - 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 = 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]); - - // --- 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: { 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 { - // Handle Unequal - const splitVals = Object.values(splitValues) as string[]; + }; + + const copyToClipboard = () => { + if (group?.joinCode) { + navigator.clipboard.writeText(group.joinCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + 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 = 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 })); + 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 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; // Can't kick yourself - - if (window.confirm(`Are you sure you want to remove ${memberName} from the group?`)) { - try { - // Check if member has unsettled balances - 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 ( -
- {/* 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: 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: { 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; + 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 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)} + 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); setSettingsTab('info'); }} - title="Group Settings" - > -
- {/* Tabs */} -
- - - -
- - {/* Info Tab */} - {settingsTab === 'info' && ( -
-
- setEditGroupName(e.target.value)} - disabled={!isAdmin} - required - /> - {isAdmin && ( -
- -
- )} -
- - {/* Invite Section */} -
-

Invite Others

-
- Join Code: - - {group.joinCode} - - -
- -
-
- )} - - {/* Members Tab */} - {settingsTab === 'members' && ( -
- {members.map(m => { - const isSelf = m.userId === user?._id; - const memberImageUrl = m.user?.imageUrl; - const isValidImage = memberImageUrl && /^(https?:|data:image)/.test(memberImageUrl); - - return ( -
-
- {isValidImage ? ( - {m.user?.name} - ) : ( -
- {(m.user?.name || '?').charAt(0)} -
- )} -
-

{m.user?.name} {isSelf && (You)}

- {m.role === 'admin' && ( - - Admin - - )} -
-
- {isAdmin && !isSelf && ( - - )} -
- ); - })} -
- )} - - {/* Danger Tab */} - {settingsTab === 'danger' && ( -
-
-

Leave Group

-

- You can leave this group only when your balances are settled. -

- -
- - {isAdmin && ( -
-

Delete Group

-

- This action is permanent and cannot be undone. Remove all members first. -

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

Delete Group

+

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

+ +
+ )} +
+ )} +
+ +
+ ); }; + +const ScaleIcon = () => ( + + + + + + + +); diff --git a/web/pages/Groups.tsx b/web/pages/Groups.tsx index 67aa2d6a..21f42aca 100644 --- a/web/pages/Groups.tsx +++ b/web/pages/Groups.tsx @@ -1,5 +1,5 @@ -import { motion, Variants } from 'framer-motion'; -import { ArrowRight, Plus, TrendingDown, TrendingUp, Users } from 'lucide-react'; +import { AnimatePresence, motion, Variants } from 'framer-motion'; +import { ArrowRight, Plus, Search, TrendingDown, TrendingUp, Users } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../components/ui/Button'; @@ -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,10 @@ export const Groups = () => { } }; + const filteredGroups = groups.filter(g => + g.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const containerVariants: Variants = { hidden: { opacity: 0 }, show: { @@ -87,91 +93,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'} ${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 +227,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 +251,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 index 8e0cfa63..9574ddfa 100644 --- a/web/pages/Profile.tsx +++ b/web/pages/Profile.tsx @@ -1,224 +1,275 @@ import { motion } from 'framer-motion'; -import { Camera, ChevronRight, LogOut, Mail, MessageSquare, User, X } from 'lucide-react'; +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 { Card } from '../components/ui/Card'; 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 } = useAuth(); - const { style, mode } = 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 handleImagePick = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - // result is already in data:image/...;base64,... format - setPickedImage({ url: result, base64: result }); + const { user, logout } = 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 handleImagePick = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + setPickedImage({ url: result, base64: result }); + }; + reader.readAsDataURL(file); }; - reader.readAsDataURL(file); - }; - - const handleSaveProfile = async () => { - if (!editName.trim()) { - alert('Name cannot be empty'); - return; - } - - 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) { - await updateProfile(updates); - // Reload page to refresh user data - window.location.reload(); - } - setIsEditModalOpen(false); - } catch (error) { - console.error('Failed to update profile:', error); - alert('Failed to update profile'); - } 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'); - }; - - // Determine avatar display - const avatarUrl = pickedImage?.url || user?.imageUrl; - const isValidImageUrl = avatarUrl && /^(https?:|data:image)/.test(avatarUrl); - - const menuItems = [ - { label: 'Edit Profile', icon: User, onClick: openEditModal }, - { label: 'Email Settings', icon: Mail, onClick: handleComingSoon }, - { label: 'Send Feedback', icon: MessageSquare, onClick: handleComingSoon }, - { label: 'Logout', icon: LogOut, onClick: handleLogout, danger: true }, - ]; - - return ( -
- -

Account

-
- - {/* Profile Header */} - - -
-
- {isValidImageUrl ? ( - {user?.name} - ) : ( -
- {user?.name?.charAt(0) || 'A'} -
- )} -
-

{user?.name}

-

{user?.email}

-
-
-
- - {/* Menu Items */} - - -
- {menuItems.map((item, index) => ( - - ))} -
-
-
- - {/* Edit Profile Modal */} - setIsEditModalOpen(false)} - title="Edit Profile" - footer={ - <> - - - + + const handleSaveProfile = async () => { + if (!editName.trim()) { + alert('Name cannot be empty'); + return; + } + + 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) { + await updateProfile(updates); + window.location.reload(); + } + setIsEditModalOpen(false); + } catch (error) { + console.error('Failed to update profile:', error); + alert('Failed to update profile'); + } 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' }, + ] } - > -
- {/* Avatar Section */} -
-
- {pickedImage?.url || (user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl)) ? ( - Profile - ) : ( -
- {editName?.charAt(0) || user?.name?.charAt(0) || 'A'} + ]; + + return ( +
+ {/* Hero Header */} +
+
+ {/* Abstract shapes */} +
+
+ +
+

PROFILE

- )} - {pickedImage && ( -
+ +
+ {/* Profile Card */} + - - - )} +
+
+
+ {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 + +
- - + + + } > - - {pickedImage ? 'Change Photo' : 'Add Photo'} - -
- - {/* Name Input */} - setEditName(e.target.value)} - placeholder="Enter your name" - required - /> +
+
+
fileInputRef.current?.click()}> + {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' : ''} + /> +
+
- -
- ); + ); }; From 964cb22aa3cc38b52623c2c553afecb4341d3a23 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:53:56 +0530 Subject: [PATCH 3/6] feat: Enhance UI accessibility and improve error handling in profile management --- web/pages/Auth.tsx | 9 +++++---- web/pages/Dashboard.tsx | 6 +++--- web/pages/Friends.tsx | 18 +++++++++++++----- web/pages/GroupDetails.tsx | 12 ++++++++++-- web/pages/Groups.tsx | 9 +++++---- web/pages/Profile.tsx | 38 +++++++++++++++++++++++++++++++------- 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/web/pages/Auth.tsx b/web/pages/Auth.tsx index 2df872ce..901f2042 100644 --- a/web/pages/Auth.tsx +++ b/web/pages/Auth.tsx @@ -8,9 +8,9 @@ import { THEMES } from '../constants'; import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; import { - login as apiLogin, - signup as apiSignup, - loginWithGoogle, + login as apiLogin, + signup as apiSignup, + loginWithGoogle, } from '../services/api'; import { signInWithGoogle } from '../services/firebase'; @@ -163,7 +163,8 @@ export const Auth = () => { {googleLoading ? (
) : ( - + + Google logo {

Owed to You

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

@@ -52,7 +52,7 @@ export const Dashboard = () => {

You Owe

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

@@ -62,7 +62,7 @@ export const Dashboard = () => {

Net Balance

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

diff --git a/web/pages/Friends.tsx b/web/pages/Friends.tsx index c8e3a8a6..ccbb3ce9 100644 --- a/web/pages/Friends.tsx +++ b/web/pages/Friends.tsx @@ -41,16 +41,24 @@ export const Friends = () => { const groups = groupsRes.data.groups || []; const gMap = new Map( - groups.map((g: any) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) + groups.map((g: { _id: string; name: string; imageUrl?: string }) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) ); - const transformedFriends = friendsData.map((friend: any) => ({ + 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: any) => ({ + breakdown: (friend.breakdown || []).map((group: { groupId: string; groupName: string; balance: number }) => ({ groupId: group.groupId, groupName: group.groupName, balance: group.balance, @@ -252,14 +260,14 @@ export const Friends = () => { {g.groupName}
0 ? 'text-emerald-500' : g.balance < 0 ? 'text-orange-500' : 'opacity-50'}`}> - {g.balance > 0 ? '+' : ''}{formatCurrency(g.balance)} + {g.balance > 0 ? '+' : g.balance < 0 ? '-' : ''}{formatCurrency(g.balance)}
))} {friend.breakdown.length === 0 && (

No active groups

)} -
@@ -378,6 +378,7 @@ export const GroupDetails = () => {
)}
{isAdmin && m.userId !== user?._id && (
)}
diff --git a/web/pages/Profile.tsx b/web/pages/Profile.tsx index 9574ddfa..39f19d3f 100644 --- a/web/pages/Profile.tsx +++ b/web/pages/Profile.tsx @@ -11,7 +11,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { updateProfile } from '../services/api'; export const Profile = () => { - const { user, logout } = useAuth(); + const { user, logout, updateUserInContext } = useAuth(); const { style } = useTheme(); const navigate = useNavigate(); const fileInputRef = useRef(null); @@ -20,6 +20,7 @@ export const Profile = () => { 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]; @@ -35,10 +36,11 @@ export const Profile = () => { const handleSaveProfile = async () => { if (!editName.trim()) { - alert('Name cannot be empty'); + setSaveError('Name cannot be empty'); return; } + setSaveError(null); setIsSaving(true); try { const updates: { name?: string; imageUrl?: string } = {}; @@ -50,13 +52,14 @@ export const Profile = () => { } if (Object.keys(updates).length > 0) { - await updateProfile(updates); - window.location.reload(); + const response = await updateProfile(updates); + const updatedUser = { ...user!, ...updates, ...(response.data || {}) }; + updateUserInContext(updatedUser); } setIsEditModalOpen(false); } catch (error) { console.error('Failed to update profile:', error); - alert('Failed to update profile'); + setSaveError('Failed to update profile. Please try again.'); } finally { setIsSaving(false); } @@ -124,7 +127,14 @@ export const Profile = () => { }`} >
-
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditModal(); } }} + tabIndex={0} + role="button" + aria-label="Edit profile picture" + >
{isValidImageUrl ? ( { }`}> {section.items.map((item, itemIdx) => ( + + )} + {/* Friends Grid */}
- {filteredFriends.length === 0 ? ( + {filteredFriends.length === 0 && !error ? ( { animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ delay: index * 0.05 }} - onClick={() => toggleExpand(friend.id)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(friend.id); } }} - tabIndex={0} - role="button" - aria-expanded={expandedId === friend.id} - aria-label={`${friend.userName}, ${friend.netBalance > 0 ? 'owes you' : friend.netBalance < 0 ? 'you owe' : 'settled'} ${formatCurrency(friend.netBalance)}`} - className={`cursor-pointer group relative overflow-hidden flex flex-col transition-all duration-300 ${isNeo + className={`group relative overflow-hidden flex flex-col transition-all duration-300 ${isNeo ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none' : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl' }`} > -
+ {expandedId === friend.id && ( diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index 022df396..8b8b073f 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -124,9 +124,14 @@ export const GroupDetails = () => { const copyToClipboard = () => { if (group?.joinCode) { - navigator.clipboard.writeText(group.joinCode); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + navigator.clipboard.writeText(group.joinCode) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + alert('Failed to copy to clipboard'); + }); } }; @@ -262,11 +267,22 @@ export const GroupDetails = () => { 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: parseFloat(paymentAmount) + amount: numAmount }); setIsPaymentModalOpen(false); setPaymentAmount(''); @@ -501,7 +517,7 @@ export const GroupDetails = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.1 }} - key={idx} + key={`${s.fromUserId}-${s.toUserId}`} className={`p-6 flex flex-col items-center justify-center text-center gap-4 relative overflow-hidden ${style === THEMES.NEOBRUTALISM ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' : 'bg-white/5 border border-white/10 rounded-3xl backdrop-blur-sm'