From ba588a21a6442771b5e5b512fa44280de5281900 Mon Sep 17 00:00:00 2001 From: victorjzq <118996546+victorjzq@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:26:07 +0700 Subject: [PATCH] feat: add group comparison modal with side-by-side diff highlighting --- src/app/groups/page.tsx | 57 ++++++++++++- src/components/GroupCard.tsx | 93 ++++++++++++++------- src/components/GroupCompare.tsx | 141 ++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 src/components/GroupCompare.tsx diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index 7592365..4ba3847 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -1,7 +1,9 @@ "use client"; +import { useState } from "react"; import { Navbar } from "@/components/Navbar"; import { GroupCard } from "@/components/GroupCard"; +import { GroupCompare } from "@/components/GroupCompare"; import { SavingsGroup, GroupStatus } from "@sorosave/sdk"; // Placeholder data for development — will be replaced with contract queries @@ -38,18 +40,57 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ }, ]; +const MAX_COMPARE = 3; + export default function GroupsPage() { // TODO: Replace with actual contract queries const groups = PLACEHOLDER_GROUPS; + const [compareIds, setCompareIds] = useState([]); + const [showCompare, setShowCompare] = useState(false); + + function handleCompareToggle(group: SavingsGroup) { + setCompareIds((prev) => + prev.includes(group.id) + ? prev.filter((id) => id !== group.id) + : prev.length < MAX_COMPARE + ? [...prev, group.id] + : prev + ); + } + + function handleRemoveFromCompare(id: number) { + setCompareIds((prev) => { + const next = prev.filter((x) => x !== id); + if (next.length === 0) setShowCompare(false); + return next; + }); + } + + const compareGroups = groups.filter((g) => compareIds.includes(g.id)); + return ( <>

Savings Groups

+ {compareIds.length >= 2 && ( + + )}
+ {compareIds.length > 0 && compareIds.length < 2 && ( +

+ Select at least 2 groups to compare (max {MAX_COMPARE}). +

+ )} + {groups.length === 0 ? (
No groups found. Create the first one! @@ -57,11 +98,25 @@ export default function GroupsPage() { ) : (
{groups.map((group) => ( - + = MAX_COMPARE} + onCompareToggle={handleCompareToggle} + /> ))}
)}
+ + {showCompare && ( + setShowCompare(false)} + onRemove={handleRemoveFromCompare} + /> + )} ); } diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index 34e0616..09e6322 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -5,6 +5,9 @@ import { SavingsGroup, formatAmount, getStatusLabel } from "@sorosave/sdk"; interface GroupCardProps { group: SavingsGroup; + compareSelected?: boolean; + compareDisabled?: boolean; + onCompareToggle?: (group: SavingsGroup) => void; } const statusColors: Record = { @@ -15,42 +18,70 @@ const statusColors: Record = { Paused: "bg-yellow-100 text-yellow-800", }; -export function GroupCard({ group }: GroupCardProps) { +export function GroupCard({ + group, + compareSelected = false, + compareDisabled = false, + onCompareToggle, +}: GroupCardProps) { return ( - -
-
-

{group.name}

- - {getStatusLabel(group.status)} - -
+
+ {onCompareToggle && ( + + )} -
-
- Contribution - - {formatAmount(group.contributionAmount)} tokens - -
-
- Members - - {group.members.length} / {group.maxMembers} + +
+
+

{group.name}

+ + {getStatusLabel(group.status)}
-
- Round - - {group.currentRound} / {group.totalRounds || group.maxMembers} - + +
+
+ Contribution + + {formatAmount(group.contributionAmount)} tokens + +
+
+ Members + + {group.members.length} / {group.maxMembers} + +
+
+ Round + + {group.currentRound} / {group.totalRounds || group.maxMembers} + +
-
- + +
); } diff --git a/src/components/GroupCompare.tsx b/src/components/GroupCompare.tsx new file mode 100644 index 0000000..e92af0c --- /dev/null +++ b/src/components/GroupCompare.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { SavingsGroup, formatAmount, getStatusLabel } from "@sorosave/sdk"; +import Link from "next/link"; + +interface GroupCompareProps { + groups: SavingsGroup[]; + onClose: () => void; + onRemove: (id: number) => void; +} + +function cycleLabel(seconds: number): string { + const days = Math.round(seconds / 86400); + if (days >= 28) return `${Math.round(days / 7)} weeks`; + return `${days} day${days !== 1 ? "s" : ""}`; +} + +const FIELDS = [ + { + label: "Contribution", + render: (g: SavingsGroup) => `${formatAmount(g.contributionAmount)} tokens`, + }, + { + label: "Cycle Length", + render: (g: SavingsGroup) => cycleLabel(g.cycleLength), + }, + { + label: "Members", + render: (g: SavingsGroup) => `${g.members.length} / ${g.maxMembers}`, + }, + { + label: "Status", + render: (g: SavingsGroup) => getStatusLabel(g.status), + }, + { + label: "Current Round", + render: (g: SavingsGroup) => + `${g.currentRound} / ${g.totalRounds || g.maxMembers}`, + }, +]; + +export function GroupCompare({ groups, onClose, onRemove }: GroupCompareProps) { + if (groups.length === 0) return null; + + // For each field, find min/max across groups to highlight differences + const contributionAmounts = groups.map((g) => Number(g.contributionAmount)); + const minContribution = Math.min(...contributionAmounts); + const maxContribution = Math.max(...contributionAmounts); + const hasDiffContribution = minContribution !== maxContribution; + + const cycleLengths = groups.map((g) => g.cycleLength); + const minCycle = Math.min(...cycleLengths); + const maxCycle = Math.max(...cycleLengths); + const hasDiffCycle = minCycle !== maxCycle; + + const memberCounts = groups.map((g) => g.members.length); + const minMembers = Math.min(...memberCounts); + const maxMembers = Math.max(...memberCounts); + const hasDiffMembers = minMembers !== maxMembers; + + const statuses = groups.map((g) => g.status); + const hasDiffStatus = new Set(statuses).size > 1; + + const rounds = groups.map((g) => g.currentRound); + const minRound = Math.min(...rounds); + const maxRound = Math.max(...rounds); + const hasDiffRound = minRound !== maxRound; + + const diffs = [hasDiffContribution, hasDiffCycle, hasDiffMembers, hasDiffStatus, hasDiffRound]; + + return ( +
+
+
+

+ Compare Groups ({groups.length}/3) +

+ +
+ +
+ + + + + {groups.map((g) => ( + + ))} + + + + {FIELDS.map((field, i) => ( + + + {groups.map((g) => ( + + ))} + + ))} + +
Field +
+ + {g.name} + + +
+
{field.label} + {field.render(g)} +
+
+ + {diffs.some(Boolean) && ( +

+ Highlighted values differ between groups +

+ )} +
+
+ ); +}