Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Added
- Dashboard skeleton loading state (`DashboardSkeleton`) to improve perceived performance during data fetch.
- Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users.

### Planned
- See `todo.md` for queued tasks
Expand Down
17 changes: 7 additions & 10 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
- Size: ~50 lines
- Added: 2026-01-01

- [ ] **[ux]** Comprehensive empty states with illustrations
- Files: `web/pages/Groups.tsx`, `web/pages/Friends.tsx`, `web/pages/Dashboard.tsx`
- [x] **[ux]** Comprehensive empty states with illustrations
- Files: `web/pages/Groups.tsx`, `web/pages/Friends.tsx`
- Context: Create illustrated empty states with CTAs (not just text)
- Impact: Guides new users, makes app feel polished
- Size: ~70 lines
Expand Down Expand Up @@ -150,12 +150,9 @@

## ✅ Completed Tasks

_No tasks completed yet. Move tasks here after completion._
- [x] **[ux]** Comprehensive empty states with illustrations
- Completed: 2026-01-01
- Files modified: `web/components/ui/EmptyState.tsx`, `web/pages/Groups.tsx`, `web/pages/Friends.tsx`
- Impact: Users now see a polished, illustrated empty state with clear CTAs when they have no groups or friends, instead of plain text.

<!--
Format:
- [x] **[type]** Task description
- Completed: YYYY-MM-DD
- Files modified: list
- Impact: What users noticed
-->
_No tasks completed yet. Move tasks here after completion._
69 changes: 69 additions & 0 deletions web/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { motion } from 'framer-motion';
import { useTheme } from '../../contexts/ThemeContext';
import { THEMES } from '../../constants';
import { Button } from './Button';

interface EmptyStateProps {
icon: React.ReactNode;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}

export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
className = ''
}) => {
const { style, mode } = useTheme();
const isNeo = style === THEMES.NEOBRUTALISM;

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 100 }}
className={`
flex flex-col items-center justify-center text-center p-12 w-full
${isNeo
? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
: `backdrop-blur-md border rounded-3xl ${mode === 'dark' ? 'bg-white/5 border-white/10' : 'bg-white/60 border-black/5'}`
}
${className}
`}
>
<div
aria-hidden="true"
className={`
mb-6 p-4 text-4xl
${isNeo
? 'bg-neo-second border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
: 'bg-gradient-to-br from-blue-500/20 to-purple-600/20 text-blue-500 rounded-2xl'
}
`}>
{icon}
</div>

<h3 className={`text-2xl font-black mb-2 ${isNeo ? 'text-black uppercase' : (mode === 'dark' ? 'text-white' : 'text-gray-900')}`}>
{title}
</h3>

<p className={`text-lg mb-8 max-w-md ${isNeo ? 'font-mono text-black/70' : (mode === 'dark' ? 'text-white/60' : 'text-gray-600')}`}>
{description}
</p>

{action && (
<Button onClick={action.onClick} variant="primary" size="lg">
{action.label}
</Button>
)}
</motion.div>
);
};
18 changes: 10 additions & 8 deletions web/pages/Friends.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowRight, Search, TrendingDown, TrendingUp, Users } from 'lucide-react';
import { useEffect, useState } from 'react';
import { EmptyState } from '../components/ui/EmptyState';
import { THEMES } from '../constants';
import { useTheme } from '../contexts/ThemeContext';
import { getFriendsBalance, getGroups } from '../services/api';
Expand Down Expand Up @@ -223,14 +224,15 @@ export const Friends = () => {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
<AnimatePresence mode='popLayout'>
{filteredFriends.length === 0 && !error ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="col-span-full text-center py-20 opacity-50"
>
<Users size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-xl font-bold">No friends found</p>
</motion.div>
<div className="col-span-full">
<EmptyState
icon={<Users size={32} aria-hidden="true" />}
title="No Friends Found"
description={searchTerm
? `No friends match "${searchTerm}".`
: "You don't have any friends with active balances. Friends appear here once you share expenses in a group."}
/>
</div>
) : (
filteredFriends.map((friend, index) => (
<motion.div
Expand Down
17 changes: 13 additions & 4 deletions web/pages/Groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ArrowRight, Plus, Search, TrendingDown, TrendingUp, Users } from 'lucid
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../components/ui/Button';
import { EmptyState } from '../components/ui/EmptyState';
import { Input } from '../components/ui/Input';
import { Modal } from '../components/ui/Modal';
import { Skeleton } from '../components/ui/Skeleton';
Expand Down Expand Up @@ -208,10 +209,18 @@ export const Groups = () => {
</AnimatePresence>

{!loading && filteredGroups.length === 0 && (
<div className={`col-span-full py-20 text-center opacity-50 flex flex-col items-center justify-center border-2 border-dashed ${isNeo ? 'border-black rounded-none' : 'border-gray-500/30 rounded-3xl'}`}>
<Users size={48} className="mb-4 opacity-50" />
<h3 className="text-2xl font-bold">No groups found</h3>
<p>Create one or join an existing group to get started.</p>
<div className="col-span-full">
<EmptyState
icon={<Users size={32} aria-hidden="true" />}
title="No Groups Found"
description={searchTerm
? `No groups match "${searchTerm}". Try a different search term.`
: "You haven't joined any groups yet. Create a new one or join with a code to start splitting expenses!"}
action={searchTerm ? undefined : {
label: "Create New Group",
onClick: () => setIsCreateModalOpen(true)
}}
/>
</div>
)}
</motion.div>
Expand Down
Loading