diff --git a/pr_body.md b/pr_body.md
index 3f1cc4b..a763940 100644
--- a/pr_body.md
+++ b/pr_body.md
@@ -1,7 +1,9 @@
## 🚀 Overview
+
This Pull Request implements the high-fidelity User Dashboard screen for the Kolo MVP exactly as defined in the Figma design system. It establishes the foundational layout and introduces reusable, responsive widgets that form the core of the dashboard experience.
## ✨ Key Features & Deliverables
+
- **Dashboard Layout**: Implemented a responsive dashboard structure in `src/app/(dashboard)/dashboard/page.tsx` utilizing CSS Grid. The layout gracefully collapses from a multi-column desktop view into a single-column scrollable mobile view.
- **Reusable Widgets**:
- `BalanceCard`: Displays total wallet balance, trend percentage, and actionable Send/Deposit buttons with Lucide icons.
@@ -11,6 +13,7 @@ This Pull Request implements the high-fidelity User Dashboard screen for the Kol
- **Component Testing**: Added comprehensive unit tests for each dashboard widget and the main dashboard page using Vitest and React Testing Library (with mocked API data) to verify rendering across different states.
## 🛠Relevant Files Modified
+
- `src/app/(dashboard)/dashboard/page.tsx`
- `src/components/dashboard/BalanceCard/BalanceCard.tsx`
- `src/components/dashboard/SavingsCircles/SavingsCircles.tsx`
@@ -19,6 +22,7 @@ This Pull Request implements the high-fidelity User Dashboard screen for the Kol
- `src/__tests__/dashboard/*.test.tsx`
## ✅ Acceptance Criteria Met
+
- [x] Dashboard Layout matches Figma design exactly.
- [x] Reusable widgets built.
- [x] Wired to hooks for data integration.
diff --git a/src/__tests__/dashboard/BalanceCard.test.tsx b/src/__tests__/dashboard/BalanceCard.test.tsx
index 87f687f..db5614b 100644
--- a/src/__tests__/dashboard/BalanceCard.test.tsx
+++ b/src/__tests__/dashboard/BalanceCard.test.tsx
@@ -1,28 +1,44 @@
-import { render, screen } from '@testing-library/react';
-import { describe, it, expect } from 'vitest';
-import { BalanceCard } from '@/components/dashboard/BalanceCard';
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { BalanceCard } from "@/components/dashboard/BalanceCard";
-describe('BalanceCard', () => {
- it('renders loading state correctly', () => {
+describe("BalanceCard", () => {
+ it("renders loading state correctly", () => {
const { container } = render();
- expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
});
- it('renders balance and trend data correctly', () => {
- render();
-
- expect(screen.getByText('Total Balance')).toBeInTheDocument();
- expect(screen.getByText('12,450.00')).toBeInTheDocument();
- expect(screen.getByText('USDC')).toBeInTheDocument();
- expect(screen.getByText('+2.4%')).toBeInTheDocument();
-
+ it("renders balance and trend data correctly", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Total Balance")).toBeInTheDocument();
+ expect(screen.getByText("12,450.00")).toBeInTheDocument();
+ expect(screen.getByText("USDC")).toBeInTheDocument();
+ expect(screen.getByText("+2.4%")).toBeInTheDocument();
+
// Check for action buttons
- expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /deposit/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /send/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /deposit/i }),
+ ).toBeInTheDocument();
});
- it('handles negative trends correctly', () => {
- render();
- expect(screen.getByText('-1.5%')).toBeInTheDocument();
+ it("handles negative trends correctly", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("-1.5%")).toBeInTheDocument();
});
});
diff --git a/src/__tests__/dashboard/DashboardPage.test.tsx b/src/__tests__/dashboard/DashboardPage.test.tsx
index 5780b1a..2d3dddb 100644
--- a/src/__tests__/dashboard/DashboardPage.test.tsx
+++ b/src/__tests__/dashboard/DashboardPage.test.tsx
@@ -1,71 +1,118 @@
-import { render, screen } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import Dashboard from '@/app/(dashboard)/dashboard/page';
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import Dashboard from "@/app/(dashboard)/dashboard/page";
// Mock the hooks
-vi.mock('@/hooks/useWallet', () => ({
- useWallet: vi.fn()
+vi.mock("@/hooks/useWallet", () => ({
+ useWallet: vi.fn(),
}));
-vi.mock('@/hooks/useGroups', () => ({
- useGroups: vi.fn()
+vi.mock("@/hooks/useGroups", () => ({
+ useGroups: vi.fn(),
}));
-vi.mock('@/hooks/usePayments', () => ({
- usePayments: vi.fn()
+vi.mock("@/hooks/usePayments", () => ({
+ usePayments: vi.fn(),
}));
-import { useWallet } from '@/hooks/useWallet';
-import { useGroups } from '@/hooks/useGroups';
-import { usePayments } from '@/hooks/usePayments';
+import { useWallet } from "@/hooks/useWallet";
+import { useGroups } from "@/hooks/useGroups";
+import { usePayments } from "@/hooks/usePayments";
-describe('Dashboard Page', () => {
+describe("Dashboard Page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
- it('renders loading states for all widgets', () => {
- vi.mocked(useWallet).mockReturnValue({ isLoading: true, data: null, error: null });
- vi.mocked(useGroups).mockReturnValue({ isLoading: true, data: null, error: null });
- vi.mocked(usePayments).mockReturnValue({ isLoading: true, data: null, error: null });
+ it("renders loading states for all widgets", () => {
+ vi.mocked(useWallet).mockReturnValue({
+ isLoading: true,
+ data: null,
+ error: null,
+ });
+ vi.mocked(useGroups).mockReturnValue({
+ isLoading: true,
+ data: null,
+ error: null,
+ });
+ vi.mocked(usePayments).mockReturnValue({
+ isLoading: true,
+ data: null,
+ error: null,
+ });
const { container } = render();
// Since each widget uses animate-pulse when loading
- const pulses = container.querySelectorAll('.animate-pulse');
+ const pulses = container.querySelectorAll(".animate-pulse");
expect(pulses.length).toBeGreaterThan(0);
});
- it('renders widgets with data correctly', () => {
+ it("renders widgets with data correctly", () => {
vi.mocked(useWallet).mockReturnValue({
- data: { balance: 1000, currency: 'USDC', trendPercentage: 1 },
+ data: { balance: 1000, currency: "USDC", trendPercentage: 1 },
isLoading: false,
- error: null
+ error: null,
});
vi.mocked(useGroups).mockReturnValue({
- data: [{ id: '1', name: 'Mock Group', tag: 'TAG', saved: 10, target: 100, nextDate: 'Date', avatars: [] }],
+ data: [
+ {
+ id: "1",
+ name: "Mock Group",
+ tag: "TAG",
+ saved: 10,
+ target: 100,
+ nextDate: "Date",
+ avatars: [],
+ },
+ ],
isLoading: false,
- error: null
+ error: null,
});
vi.mocked(usePayments).mockReturnValue({
- data: [{ id: '1', type: 'deposit', title: 'Mock Tx', date: 'Date', time: 'Time', amount: 50, currency: 'USDC', status: 'OK' }],
+ data: [
+ {
+ id: "1",
+ type: "deposit",
+ title: "Mock Tx",
+ date: "Date",
+ time: "Time",
+ amount: 50,
+ currency: "USDC",
+ status: "OK",
+ },
+ ],
isLoading: false,
- error: null
+ error: null,
});
render();
-
- expect(screen.getByText('1,000.00')).toBeInTheDocument();
- expect(screen.getByText('Mock Group')).toBeInTheDocument();
- expect(screen.getByText('Mock Tx')).toBeInTheDocument();
+
+ expect(screen.getByText("1,000.00")).toBeInTheDocument();
+ expect(screen.getByText("Mock Group")).toBeInTheDocument();
+ expect(screen.getByText("Mock Tx")).toBeInTheDocument();
});
- it('renders error boundaries', () => {
- vi.mocked(useWallet).mockReturnValue({ error: new Error('Error'), isLoading: false, data: null });
- vi.mocked(useGroups).mockReturnValue({ error: new Error('Error'), isLoading: false, data: null });
- vi.mocked(usePayments).mockReturnValue({ error: new Error('Error'), isLoading: false, data: null });
+ it("renders error boundaries", () => {
+ vi.mocked(useWallet).mockReturnValue({
+ error: new Error("Error"),
+ isLoading: false,
+ data: null,
+ });
+ vi.mocked(useGroups).mockReturnValue({
+ error: new Error("Error"),
+ isLoading: false,
+ data: null,
+ });
+ vi.mocked(usePayments).mockReturnValue({
+ error: new Error("Error"),
+ isLoading: false,
+ data: null,
+ });
render();
-
- expect(screen.getByText('Error loading wallet data')).toBeInTheDocument();
- expect(screen.getByText('Error loading savings groups')).toBeInTheDocument();
- expect(screen.getByText('Error loading transactions')).toBeInTheDocument();
+
+ expect(screen.getByText("Error loading wallet data")).toBeInTheDocument();
+ expect(
+ screen.getByText("Error loading savings groups"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Error loading transactions")).toBeInTheDocument();
});
});
diff --git a/src/__tests__/dashboard/ProgressRing.test.tsx b/src/__tests__/dashboard/ProgressRing.test.tsx
new file mode 100644
index 0000000..7b9cf7a
--- /dev/null
+++ b/src/__tests__/dashboard/ProgressRing.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { ProgressRing } from "@/components/dashboard/ProgressRing";
+
+describe("ProgressRing", () => {
+ it("renders an SVG with two circles (track + progress)", () => {
+ const { container } = render();
+ const circles = container.querySelectorAll("circle");
+ expect(circles).toHaveLength(2);
+ });
+
+ it("has correct aria-label reflecting the progress value", () => {
+ render();
+ expect(
+ screen.getByRole("img", { name: "Savings progress: 82%" }),
+ ).toBeInTheDocument();
+ });
+
+ it("clamps progress above 100 to 100%", () => {
+ render();
+ expect(
+ screen.getByRole("img", { name: "Savings progress: 100%" }),
+ ).toBeInTheDocument();
+ });
+
+ it("clamps progress below 0 to 0%", () => {
+ render();
+ expect(
+ screen.getByRole("img", { name: "Savings progress: 0%" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders children inside the ring", () => {
+ render(
+
+ 50%
+ ,
+ );
+ expect(screen.getByText("50%")).toBeInTheDocument();
+ });
+
+ it("uses emerald green (#10B981) for the progress arc", () => {
+ const { container } = render();
+ const circles = container.querySelectorAll("circle");
+ // Second circle is the progress arc
+ expect(circles[1].getAttribute("stroke")).toBe("#10B981");
+ });
+
+ it("uses a muted gray (#E5E7EB) for the track", () => {
+ const { container } = render();
+ const circles = container.querySelectorAll("circle");
+ // First circle is the track
+ expect(circles[0].getAttribute("stroke")).toBe("#E5E7EB");
+ });
+
+ it("sets stroke-dasharray on the progress arc", () => {
+ const { container } = render();
+ const progressCircle = container.querySelectorAll("circle")[1];
+ expect(progressCircle.getAttribute("stroke-dasharray")).toBeTruthy();
+ });
+
+ it("uses non-scaling-stroke vector-effect for responsive scaling", () => {
+ const { container } = render();
+ const circles = container.querySelectorAll("circle");
+ circles.forEach((circle) => {
+ expect(circle.getAttribute("vector-effect")).toBe("non-scaling-stroke");
+ });
+ });
+
+ it("applies a custom className to the wrapper", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveClass("w-32", "h-32");
+ });
+
+ it("renders loading skeleton with animate-pulse when isLoading", () => {
+ // ProgressRing itself has no loading state — verify SavingsCircles skeleton
+ // still uses animate-pulse (tested in SavingsCircles.test.tsx).
+ // This test is a placeholder confirming ProgressRing renders without error at 0%.
+ const { container } = render();
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/dashboard/SavingsCircles.test.tsx b/src/__tests__/dashboard/SavingsCircles.test.tsx
index 0aa45ad..e2d49b6 100644
--- a/src/__tests__/dashboard/SavingsCircles.test.tsx
+++ b/src/__tests__/dashboard/SavingsCircles.test.tsx
@@ -1,36 +1,38 @@
-import { render, screen } from '@testing-library/react';
-import { describe, it, expect } from 'vitest';
-import { SavingsCircles } from '@/components/dashboard/SavingsCircles';
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { SavingsCircles } from "@/components/dashboard/SavingsCircles";
-describe('SavingsCircles', () => {
- it('renders loading state correctly', () => {
- const { container } = render();
- expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
+describe("SavingsCircles", () => {
+ it("renders loading state correctly", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
});
- it('renders groups correctly', () => {
+ it("renders groups correctly", () => {
const mockGroups = [
{
- id: '1',
- name: 'Home Fund',
- tag: 'FAMILY HOME',
+ id: "1",
+ name: "Home Fund",
+ tag: "FAMILY HOME",
saved: 8200,
target: 10000,
- nextDate: 'Oct 24',
- avatars: ['https://example.com/av1.jpg', '+3']
- }
+ nextDate: "Oct 24",
+ avatars: ["https://example.com/av1.jpg", "+3"],
+ },
];
render();
-
- expect(screen.getByText('My Savings Circles')).toBeInTheDocument();
- expect(screen.getByText('Home Fund')).toBeInTheDocument();
- expect(screen.getByText('FAMILY HOME')).toBeInTheDocument();
- expect(screen.getByText('$8,200 saved')).toBeInTheDocument();
- expect(screen.getByText('82%')).toBeInTheDocument();
- expect(screen.getByText('Next: Oct 24')).toBeInTheDocument();
-
+
+ expect(screen.getByText("My Savings Circles")).toBeInTheDocument();
+ expect(screen.getByText("Home Fund")).toBeInTheDocument();
+ expect(screen.getByText("FAMILY HOME")).toBeInTheDocument();
+ expect(screen.getByText("$8,200 saved")).toBeInTheDocument();
+ expect(screen.getByText("82%")).toBeInTheDocument();
+ expect(screen.getByText("Next: Oct 24")).toBeInTheDocument();
+
// Check for the new circle button
- expect(screen.getByText('New Circle')).toBeInTheDocument();
+ expect(screen.getByText("New Circle")).toBeInTheDocument();
});
});
diff --git a/src/__tests__/dashboard/TransactionList.test.tsx b/src/__tests__/dashboard/TransactionList.test.tsx
index cc61b12..11c757a 100644
--- a/src/__tests__/dashboard/TransactionList.test.tsx
+++ b/src/__tests__/dashboard/TransactionList.test.tsx
@@ -1,46 +1,50 @@
-import { render, screen } from '@testing-library/react';
-import { describe, it, expect } from 'vitest';
-import { TransactionList } from '@/components/dashboard/TransactionList';
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { TransactionList } from "@/components/dashboard/TransactionList";
-describe('TransactionList', () => {
- it('renders loading state correctly', () => {
- const { container } = render();
- expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
+describe("TransactionList", () => {
+ it("renders loading state correctly", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
});
- it('renders transactions correctly', () => {
+ it("renders transactions correctly", () => {
const mockTransactions = [
{
- id: '1',
- type: 'deposit' as const,
- title: 'Deposit from WhatsApp Pay',
- date: 'Oct 21, 2023',
- time: '2:30 PM',
+ id: "1",
+ type: "deposit" as const,
+ title: "Deposit from WhatsApp Pay",
+ date: "Oct 21, 2023",
+ time: "2:30 PM",
amount: 250,
- currency: 'USDC',
- status: 'Completed',
+ currency: "USDC",
+ status: "Completed",
},
{
- id: '2',
- type: 'contribution' as const,
- title: 'Home Fund Contribution',
- date: 'Oct 19, 2023',
- time: '9:15 AM',
+ id: "2",
+ type: "contribution" as const,
+ title: "Home Fund Contribution",
+ date: "Oct 19, 2023",
+ time: "9:15 AM",
amount: -100,
- currency: 'USDC',
- status: 'Completed',
- }
+ currency: "USDC",
+ status: "Completed",
+ },
];
- render();
-
- expect(screen.getByText('Recent Activity')).toBeInTheDocument();
- expect(screen.getByText('Deposit from WhatsApp Pay')).toBeInTheDocument();
- expect(screen.getByText('+250.00 USDC')).toBeInTheDocument();
-
- expect(screen.getByText('Home Fund Contribution')).toBeInTheDocument();
- expect(screen.getByText('-100.00 USDC')).toBeInTheDocument();
-
- expect(screen.getByText('Download Statement')).toBeInTheDocument();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Recent Activity")).toBeInTheDocument();
+ expect(screen.getByText("Deposit from WhatsApp Pay")).toBeInTheDocument();
+ expect(screen.getByText("+250.00 USDC")).toBeInTheDocument();
+
+ expect(screen.getByText("Home Fund Contribution")).toBeInTheDocument();
+ expect(screen.getByText("-100.00 USDC")).toBeInTheDocument();
+
+ expect(screen.getByText("Download Statement")).toBeInTheDocument();
});
});
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
index 0a95689..d78a894 100644
--- a/src/app/(dashboard)/dashboard/page.tsx
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -1,17 +1,29 @@
-'use client';
+"use client";
-import React from 'react';
-import { BalanceCard } from '@/components/dashboard/BalanceCard';
-import { SavingsCircles } from '@/components/dashboard/SavingsCircles';
-import { TransactionList } from '@/components/dashboard/TransactionList';
-import { useWallet } from '@/hooks/useWallet';
-import { useGroups } from '@/hooks/useGroups';
-import { usePayments } from '@/hooks/usePayments';
+import React from "react";
+import { BalanceCard } from "@/components/dashboard/BalanceCard";
+import { SavingsCircles } from "@/components/dashboard/SavingsCircles";
+import { TransactionList } from "@/components/dashboard/TransactionList";
+import { useWallet } from "@/hooks/useWallet";
+import { useGroups } from "@/hooks/useGroups";
+import { usePayments } from "@/hooks/usePayments";
export default function Dashboard() {
- const { data: walletData, isLoading: isWalletLoading, error: walletError } = useWallet();
- const { data: groupsData, isLoading: isGroupsLoading, error: groupsError } = useGroups();
- const { data: paymentsData, isLoading: isPaymentsLoading, error: paymentsError } = usePayments();
+ const {
+ data: walletData,
+ isLoading: isWalletLoading,
+ error: walletError,
+ } = useWallet();
+ const {
+ data: groupsData,
+ isLoading: isGroupsLoading,
+ error: groupsError,
+ } = useGroups();
+ const {
+ data: paymentsData,
+ isLoading: isPaymentsLoading,
+ error: paymentsError,
+ } = usePayments();
return (
@@ -25,13 +37,15 @@ export default function Dashboard() {
{/* Balance Card Section */}
{walletError ? (
- Error loading wallet data
+
+ Error loading wallet data
+
) : (
-
)}
@@ -39,11 +53,13 @@ export default function Dashboard() {
{/* Savings Circles Section */}
{groupsError ? (
- Error loading savings groups
+
+ Error loading savings groups
+
) : (
-
)}
@@ -51,11 +67,13 @@ export default function Dashboard() {
{/* Recent Activity Section */}
{paymentsError ? (
- Error loading transactions
+
+ Error loading transactions
+
) : (
-
)}
diff --git a/src/components/dashboard/BalanceCard/BalanceCard.tsx b/src/components/dashboard/BalanceCard/BalanceCard.tsx
index 6fa7d98..41a1d90 100644
--- a/src/components/dashboard/BalanceCard/BalanceCard.tsx
+++ b/src/components/dashboard/BalanceCard/BalanceCard.tsx
@@ -1,7 +1,7 @@
-'use client';
+"use client";
-import React from 'react';
-import { Send, Plus, TrendingUp, TrendingDown } from 'lucide-react';
+import React from "react";
+import { Send, Plus, TrendingUp, TrendingDown } from "lucide-react";
interface BalanceCardProps {
balance?: number;
@@ -10,7 +10,12 @@ interface BalanceCardProps {
isLoading?: boolean;
}
-export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage = 0, isLoading = false }: BalanceCardProps) {
+export function BalanceCard({
+ balance = 0,
+ currency = "USDC",
+ trendPercentage = 0,
+ isLoading = false,
+}: BalanceCardProps) {
if (isLoading) {
return (
@@ -27,7 +32,7 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage =
);
}
- const formattedBalance = new Intl.NumberFormat('en-US', {
+ const formattedBalance = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(balance);
@@ -37,10 +42,16 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage =
return (
-
Total Balance
+
+ Total Balance
+
-
{formattedBalance}
- {currency}
+
+ {formattedBalance}
+
+
+ {currency}
+
{isPositiveTrend ? (
@@ -48,8 +59,11 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage =
) : (
)}
-
- {isPositiveTrend ? '+' : ''}{trendPercentage}%
+
+ {isPositiveTrend ? "+" : ""}
+ {trendPercentage}%
from last month
diff --git a/src/components/dashboard/ProgressRing/ProgressRing.tsx b/src/components/dashboard/ProgressRing/ProgressRing.tsx
new file mode 100644
index 0000000..396e18d
--- /dev/null
+++ b/src/components/dashboard/ProgressRing/ProgressRing.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+interface ProgressRingProps {
+ /** Completion percentage from 0 to 100. */
+ progress: number;
+ /** Logical size of the SVG viewBox (pixels). Defaults to 120. */
+ size?: number;
+ /** Stroke width in viewBox units. Defaults to 10. */
+ strokeWidth?: number;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+const TRACK_COLOR = "#E5E7EB";
+const FILL_COLOR = "#10B981";
+
+export function ProgressRing({
+ progress,
+ size = 120,
+ strokeWidth = 10,
+ className = "",
+ children,
+}: ProgressRingProps) {
+ const clamped = Math.min(100, Math.max(0, progress));
+ const center = size / 2;
+ const radius = center - strokeWidth / 2;
+ const circumference = 2 * Math.PI * radius;
+ const targetOffset = circumference * (1 - clamped / 100);
+
+ // Start fully empty so CSS transition animates fill-up on mount.
+ const [offset, setOffset] = useState(circumference);
+
+ useEffect(() => {
+ // Double rAF ensures the initial empty state is painted before the
+ // transition to the real offset begins.
+ let inner: number;
+ const outer = requestAnimationFrame(() => {
+ inner = requestAnimationFrame(() => setOffset(targetOffset));
+ });
+ return () => {
+ cancelAnimationFrame(outer);
+ cancelAnimationFrame(inner);
+ };
+ }, [targetOffset]);
+
+ return (
+
+
+ {children !== undefined && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/src/components/dashboard/ProgressRing/index.ts b/src/components/dashboard/ProgressRing/index.ts
new file mode 100644
index 0000000..6e6d23f
--- /dev/null
+++ b/src/components/dashboard/ProgressRing/index.ts
@@ -0,0 +1 @@
+export { ProgressRing } from "./ProgressRing";
diff --git a/src/components/dashboard/SavingsCircles/SavingsCircles.tsx b/src/components/dashboard/SavingsCircles/SavingsCircles.tsx
index e15ac47..ac11f03 100644
--- a/src/components/dashboard/SavingsCircles/SavingsCircles.tsx
+++ b/src/components/dashboard/SavingsCircles/SavingsCircles.tsx
@@ -1,15 +1,19 @@
-'use client';
+"use client";
-import React from 'react';
-import { Home, Plane, Plus, ChevronRight } from 'lucide-react';
-import type { SavingsGroup } from '@/hooks/useGroups';
+import React from "react";
+import { Home, Plane, Plus, ChevronRight } from "lucide-react";
+import type { SavingsGroup } from "@/hooks/useGroups";
+import { ProgressRing } from "@/components/dashboard/ProgressRing";
interface SavingsCirclesProps {
groups: SavingsGroup[];
isLoading?: boolean;
}
-export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProps) {
+export function SavingsCircles({
+ groups,
+ isLoading = false,
+}: SavingsCirclesProps) {
if (isLoading) {
return (
@@ -18,15 +22,21 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
{[1, 2, 3].map((i) => (
-
-
-
+
+
-
-
-
-
+
+
+
+
@@ -42,7 +52,9 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
return (
-
My Savings Circles
+
+ My Savings Circles
+
@@ -51,53 +63,70 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
{groups.map((group) => {
const progress = Math.min((group.saved / group.target) * 100, 100);
-
+
// Helper to get icon based on tag
const getIcon = () => {
- if (group.tag.includes('HOME')) return
;
- if (group.tag.includes('TRAVEL')) return
;
+ if (group.tag.includes("HOME"))
+ return
;
+ if (group.tag.includes("TRAVEL"))
+ return
;
return
;
};
return (
-
-
-
-
- {getIcon()}
-
-
- {group.tag}
-
-
-
-
{group.name}
-
-
${group.saved.toLocaleString()} saved
-
{Math.round(progress)}%
+
+
+
+ {getIcon()}
-
- {/* Progress bar */}
-
+
+
+
+
+ {Math.round(progress)}%
+
+
+ of goal
+
+
+
+
+
+ {group.name}
+
+
+ ${group.saved.toLocaleString()} saved
+
-
+
Next: {group.nextDate}
- {group.avatars.map((avatar, idx) => (
- avatar.startsWith('+') ? (
-
+ {group.avatars.map((avatar, idx) =>
+ avatar.startsWith("+") ? (
+
{avatar}
) : (
-

- )
- ))}
+

+ ),
+ )}
@@ -110,7 +139,9 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
New Circle
-
Start a community savings plan with friends.
+
+ Start a community savings plan with friends.
+
diff --git a/src/components/dashboard/SavingsCircles/index.ts b/src/components/dashboard/SavingsCircles/index.ts
index 5a731bb..22e50b9 100644
--- a/src/components/dashboard/SavingsCircles/index.ts
+++ b/src/components/dashboard/SavingsCircles/index.ts
@@ -1 +1 @@
-export * from './SavingsCircles';
+export * from "./SavingsCircles";
diff --git a/src/components/dashboard/TransactionList/TransactionList.tsx b/src/components/dashboard/TransactionList/TransactionList.tsx
index fbe5989..7686e71 100644
--- a/src/components/dashboard/TransactionList/TransactionList.tsx
+++ b/src/components/dashboard/TransactionList/TransactionList.tsx
@@ -1,22 +1,28 @@
-'use client';
+"use client";
-import React from 'react';
-import { ArrowDown, ArrowUp, Users } from 'lucide-react';
-import type { Transaction } from '@/hooks/usePayments';
+import React from "react";
+import { ArrowDown, ArrowUp, Users } from "lucide-react";
+import type { Transaction } from "@/hooks/usePayments";
interface TransactionListProps {
transactions: Transaction[];
isLoading?: boolean;
}
-export function TransactionList({ transactions, isLoading = false }: TransactionListProps) {
+export function TransactionList({
+ transactions,
+ isLoading = false,
+}: TransactionListProps) {
if (isLoading) {
return (
{[1, 2, 3].map((i) => (
-
+
@@ -31,7 +37,7 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
))}
@@ -40,11 +46,11 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
const getIcon = (type: string) => {
switch (type) {
- case 'deposit':
+ case "deposit":
return
;
- case 'contribution':
+ case "contribution":
return
;
- case 'send':
+ case "send":
return
;
default:
return
;
@@ -53,19 +59,23 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
return (
-
Recent Activity
-
+
+ Recent Activity
+
+
{transactions.map((tx, idx) => {
const isPositive = tx.amount > 0;
- const formattedAmount = `${isPositive ? '+' : ''}${tx.amount.toFixed(2)} ${tx.currency}`;
-
+ const formattedAmount = `${isPositive ? "+" : ""}${tx.amount.toFixed(2)} ${tx.currency}`;
+
return (
-
@@ -73,14 +83,18 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
{getIcon(tx.type)}
-
{tx.title}
+
+ {tx.title}
+
{tx.date} • {tx.time}
-
+
{formattedAmount}
@@ -91,7 +105,7 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
);
})}
-
+