diff --git a/frontend/components/dashboard/my-groups.tsx b/frontend/components/dashboard/my-groups.tsx index fab96ae..e1ab30c 100644 --- a/frontend/components/dashboard/my-groups.tsx +++ b/frontend/components/dashboard/my-groups.tsx @@ -1,4 +1,9 @@ -import { useRouter, useSearchParams } from "next/navigation" +"use client" + +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" import { Pagination, PaginationContent, @@ -6,6 +11,228 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination" +import { Users, TrendingUp, Calendar, ArrowRight } from "lucide-react" +import Link from "next/link" +import { motion } from "framer-motion" +import { useState, useEffect, useCallback } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { useStellar } from "@/components/web3-provider" +import { usePoolData } from "@/lib/data-layer/PoolDataProvider" +import { + stroopsToXlm, + RotationalPoolState, + TargetPoolState, + FlexiblePoolState, +} from "@/hooks/useJointSaveContracts" +import { EmptyState } from "@/components/dashboard/empty-state" +import { FirstPoolTooltip } from "@/components/dashboard/first-pool-tooltip" + +const PAGE_SIZE = 6 + +interface Pool { + id: string + name: string + type: "rotational" | "target" | "flexible" + status: "active" | "completed" | "paused" + members_count: number + total_saved: number + progress: number + frequency?: string + next_payout?: string + contract_address: string + target_amount: number | null + contribution_amount: number | null + minimum_deposit: number | null +} + +interface MyGroupsProps { + onCreateClick?: () => void +} + +const container = { + hidden: { opacity: 0 }, + show: { opacity: 1, transition: { staggerChildren: 0.1 } }, +} +const item = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0 } } + +// ── Skeleton for a single pool card ────────────────────────────────────────── +function PoolCardSkeleton() { + return ( + + ) +} + +// ── Per-pool card that hydrates live balances from the unified cache ────────── +function PoolCard({ pool }: { pool: Pool }) { + const cacheKey = + pool.contract_address && pool.contract_address !== "pending_deployment" + ? pool.contract_address + : pool.id + const { data, isLoading } = usePoolData(cacheKey) + + const getLiveStats = (): { + totalSaved: number + progress: number + progressLabel: string + } => { + const onchain = data?.onchain ?? null + if (pool.type === "rotational" && onchain) { + const s = onchain as RotationalPoolState + const totalMembers = s.members.length || pool.members_count || 1 + const progress = Math.min( + 100, + Math.round((s.currentRound / totalMembers) * 100) + ) + const perRound = (pool.contribution_amount || 0) * totalMembers + const totalSaved = s.currentRound * perRound + return { + totalSaved, + progress, + progressLabel: `Round ${s.currentRound + 1} of ${totalMembers}`, + } + } + if (pool.type === "target" && onchain) { + const s = onchain as TargetPoolState + const saved = stroopsToXlm(s.totalDeposited) + const target = pool.target_amount || stroopsToXlm(s.targetAmount) || 1 + const progress = Math.min(100, Math.round((saved / target) * 100)) + return { + totalSaved: saved, + progress, + progressLabel: `${saved.toFixed(2)} / ${target.toFixed(2)} XLM`, + } + } + if (pool.type === "flexible" && onchain) { + const s = onchain as FlexiblePoolState + const totalSaved = stroopsToXlm(s.totalBalance) + const softGoal = (pool.minimum_deposit || 0) * (pool.members_count || 1) + const progress = + softGoal > 0 + ? Math.min(100, Math.round((totalSaved / softGoal) * 100)) + : s.isActive + ? 50 + : 100 + return { + totalSaved, + progress, + progressLabel: + softGoal > 0 + ? `${totalSaved.toFixed(2)} / ${softGoal.toFixed(2)} XLM` + : `${totalSaved.toFixed(2)} XLM saved`, + } + } + return { + totalSaved: pool.total_saved ?? 0, + progress: pool.progress ?? 0, + progressLabel: "", + } + } + + const { totalSaved, progress, progressLabel } = getLiveStats() + const formatXlm = (n: number) => `${n.toFixed(2)} XLM` + + return ( + + +
+
+

{pool.name}

+ + {pool.type.charAt(0).toUpperCase() + pool.type.slice(1)} + +
+ + {pool.status} + +
+
+
+ + + Members + + {pool.members_count} +
+
+ + + Total Saved + + + {isLoading && !data?.onchain ? ( + + ) : ( + formatXlm(totalSaved) + )} + +
+
+ + + {pool.type === "rotational" ? "Frequency" : "Status"} + + {pool.frequency || pool.status} +
+
+
+
+ Progress + {progress.toFixed(1)}% +
+
+ +
+ {progressLabel && ( +

{progressLabel}

+ )} +
+ +
+
+ ) +} + +// ── Main MyGroups component ─────────────────────────────────────────────────── export function MyGroups({ onCreateClick }: MyGroupsProps) { const { address } = useStellar() const router = useRouter() @@ -19,11 +246,14 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { const page = Math.max(0, parseInt(searchParams.get("page") || "0", 10)) const totalPages = Math.ceil(total / PAGE_SIZE) - const setPage = useCallback((p: number) => { - const params = new URLSearchParams(searchParams.toString()) - params.set("page", String(p)) - router.push(`?${params.toString()}`, { scroll: false }) - }, [router, searchParams]) + const setPage = useCallback( + (p: number) => { + const params = new URLSearchParams(searchParams.toString()) + params.set("page", String(p)) + router.push(`?${params.toString()}`, { scroll: false }) + }, + [router, searchParams] + ) useEffect(() => { if (!address) { @@ -37,16 +267,12 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { try { setLoading(true) setError("") - const res = await fetch( `/api/pools?creator=${address?.toLowerCase()}&page=${currentPage}` ) - if (!res.ok) throw new Error("Failed to fetch pools") - const json = await res.json() const data: Pool[] = Array.isArray(json) ? json : (json.data ?? []) - setPools(data) setTotal(json.total ?? data.length) } catch (err) { @@ -57,19 +283,26 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { } } - if (loading) + if (loading) { return (

My Groups

+
-
- +
+ {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + ))}
) + } - if (error) + if (error) { return (
@@ -80,6 +313,7 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) {
) + } return (
@@ -122,7 +356,6 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { Showing {page * PAGE_SIZE + 1}– {Math.min((page + 1) * PAGE_SIZE, total)} of {total} pools

- @@ -136,7 +369,6 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { } /> - setPage(page + 1)} @@ -156,4 +388,4 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { )}
) -} \ No newline at end of file +} diff --git a/frontend/components/dashboard/profile.tsx b/frontend/components/dashboard/profile.tsx index 706eb81..2702f47 100644 --- a/frontend/components/dashboard/profile.tsx +++ b/frontend/components/dashboard/profile.tsx @@ -2,8 +2,9 @@ import { Card } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" import { useStellar } from "@/components/web3-provider" -import { Wallet, Award, TrendingUp, Users, Loader2 } from "lucide-react" +import { Wallet, Award, TrendingUp, Users } from "lucide-react" import { useState, useEffect } from "react" import { supabase } from "@/lib/supabase" import { @@ -136,7 +137,7 @@ export function Profile() {
Reputation Score {loading ? ( - + ) : ( {stats?.reputation ?? 50}% )} @@ -161,8 +162,16 @@ export function Profile() {

Savings Statistics

{loading ? ( -
- +
+ {[0, 1, 2].map((i) => ( +
+
+ + +
+ +
+ ))}
) : (
@@ -203,8 +212,13 @@ export function Profile() {

Reputation Breakdown

{loading ? ( -
- +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))}
) : (
diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 0a9fa89..85e0a99 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; import { Loader2, ArrowUpRight, @@ -435,6 +436,34 @@ export function GroupActions({ Quick Actions {renderPendingBadge()} + {/* Skeleton while pool metadata is loading */} + {!poolData && !isPending && ( +
+ {/* deposit field + button */} +
+ + + + +
+ {/* withdraw field + button */} +
+ + + + +
+ {/* admin controls */} +
+ +
+ + +
+
+
+ )} + {error && (
@@ -449,6 +478,8 @@ export function GroupActions({
)} + {/* Actual form — shown once pool metadata resolves (or contract is pending) */} + {(poolData || isPending) && (
{/* Deposit / Contribute */}
@@ -690,6 +721,7 @@ export function GroupActions({

+ )} diff --git a/frontend/components/group/group-details.tsx b/frontend/components/group/group-details.tsx index 7b5ccb4..68be0c9 100644 --- a/frontend/components/group/group-details.tsx +++ b/frontend/components/group/group-details.tsx @@ -4,7 +4,8 @@ import { useState } from "react" import { Card } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" -import { Calendar, TrendingUp, Users, Clock, Loader2, RefreshCw, AlertTriangle, Copy, Check } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { Calendar, TrendingUp, Users, Clock, RefreshCw, AlertTriangle, Copy, Check } from "lucide-react" import { Button } from "@/components/ui/button" import { motion } from "framer-motion" import { @@ -60,9 +61,40 @@ export function GroupDetails({ groupId, contractAddress }: GroupDetailsProps) { if (isLoading && !group) { return ( - -
- + + {/* header */} +
+
+ +
+ + +
+
+ +
+ + {/* description */} + + + {/* stat tiles */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ + {/* progress bar area */} +
+
+ + +
+ +
); diff --git a/frontend/components/group/group-members.tsx b/frontend/components/group/group-members.tsx index 88e3e54..fe07c43 100644 --- a/frontend/components/group/group-members.tsx +++ b/frontend/components/group/group-members.tsx @@ -3,11 +3,11 @@ import { Card } from "@/components/ui/card"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { CheckCircle2, Clock, XCircle, - Loader2, AlertCircle, Award, } from "lucide-react"; @@ -87,9 +87,23 @@ export function GroupMembers({ if (isLoading && members.length === 0) { return ( - -
- + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ {/* avatar */} + +
+ + +
+
+ {/* status icon */} + +
+ ))}
); @@ -154,8 +168,6 @@ export function GroupMembers({ )} )} -
-
{reputations[member.member_address] && (