Skip to content
Open
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
57 changes: 56 additions & 1 deletion src/app/groups/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -38,30 +40,83 @@ 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<number[]>([]);
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 (
<>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Savings Groups</h1>
{compareIds.length >= 2 && (
<button
onClick={() => setShowCompare(true)}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
>
Compare {compareIds.length} Groups
</button>
)}
</div>

{compareIds.length > 0 && compareIds.length < 2 && (
<p className="mb-4 text-sm text-gray-500">
Select at least 2 groups to compare (max {MAX_COMPARE}).
</p>
)}

{groups.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No groups found. Create the first one!
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{groups.map((group) => (
<GroupCard key={group.id} group={group} />
<GroupCard
key={group.id}
group={group}
compareSelected={compareIds.includes(group.id)}
compareDisabled={compareIds.length >= MAX_COMPARE}
onCompareToggle={handleCompareToggle}
/>
))}
</div>
)}
</main>

{showCompare && (
<GroupCompare
groups={compareGroups}
onClose={() => setShowCompare(false)}
onRemove={handleRemoveFromCompare}
/>
)}
</>
);
}
93 changes: 62 additions & 31 deletions src/components/GroupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -15,42 +18,70 @@ const statusColors: Record<string, string> = {
Paused: "bg-yellow-100 text-yellow-800",
};

export function GroupCard({ group }: GroupCardProps) {
export function GroupCard({
group,
compareSelected = false,
compareDisabled = false,
onCompareToggle,
}: GroupCardProps) {
return (
<Link href={`/groups/${group.id}`}>
<div className="bg-white rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow cursor-pointer">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold text-gray-900">{group.name}</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
statusColors[group.status] || "bg-gray-100 text-gray-800"
}`}
>
{getStatusLabel(group.status)}
</span>
</div>
<div className="relative">
{onCompareToggle && (
<label
className="absolute top-3 right-3 z-10 flex items-center gap-1 cursor-pointer select-none"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={compareSelected}
disabled={compareDisabled && !compareSelected}
onChange={() => onCompareToggle(group)}
className="h-4 w-4 accent-primary-600 cursor-pointer disabled:cursor-not-allowed"
aria-label={`Select ${group.name} for comparison`}
/>
<span className="text-xs text-gray-500">Compare</span>
</label>
)}

<div className="space-y-2 text-sm text-gray-600">
<div className="flex justify-between">
<span>Contribution</span>
<span className="font-medium text-gray-900">
{formatAmount(group.contributionAmount)} tokens
</span>
</div>
<div className="flex justify-between">
<span>Members</span>
<span className="font-medium text-gray-900">
{group.members.length} / {group.maxMembers}
<Link href={`/groups/${group.id}`}>
<div
className={`bg-white rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow cursor-pointer ${
compareSelected ? "ring-2 ring-primary-400" : ""
}`}
>
<div className="flex justify-between items-start mb-4 pr-20">
<h3 className="text-lg font-semibold text-gray-900">{group.name}</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
statusColors[group.status] || "bg-gray-100 text-gray-800"
}`}
>
{getStatusLabel(group.status)}
</span>
</div>
<div className="flex justify-between">
<span>Round</span>
<span className="font-medium text-gray-900">
{group.currentRound} / {group.totalRounds || group.maxMembers}
</span>

<div className="space-y-2 text-sm text-gray-600">
<div className="flex justify-between">
<span>Contribution</span>
<span className="font-medium text-gray-900">
{formatAmount(group.contributionAmount)} tokens
</span>
</div>
<div className="flex justify-between">
<span>Members</span>
<span className="font-medium text-gray-900">
{group.members.length} / {group.maxMembers}
</span>
</div>
<div className="flex justify-between">
<span>Round</span>
<span className="font-medium text-gray-900">
{group.currentRound} / {group.totalRounds || group.maxMembers}
</span>
</div>
</div>
</div>
</div>
</Link>
</Link>
</div>
);
}
141 changes: 141 additions & 0 deletions src/components/GroupCompare.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
Compare Groups ({groups.length}/3)
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
aria-label="Close"
>
</button>
</div>

<div className="overflow-auto flex-1">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="px-4 py-3 text-left text-gray-500 font-medium w-32">Field</th>
{groups.map((g) => (
<th key={g.id} className="px-4 py-3 text-left">
<div className="flex items-center justify-between gap-2">
<Link
href={`/groups/${g.id}`}
className="font-semibold text-gray-900 hover:text-primary-700 hover:underline"
>
{g.name}
</Link>
<button
onClick={() => onRemove(g.id)}
className="text-gray-300 hover:text-red-400 text-xs flex-shrink-0"
aria-label={`Remove ${g.name} from comparison`}
>
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{FIELDS.map((field, i) => (
<tr key={field.label} className="border-b last:border-0">
<td className="px-4 py-3 text-gray-500 font-medium">{field.label}</td>
{groups.map((g) => (
<td
key={g.id}
className={`px-4 py-3 font-medium ${
diffs[i] ? "text-primary-700" : "text-gray-900"
}`}
>
{field.render(g)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

{diffs.some(Boolean) && (
<p className="px-6 py-3 text-xs text-primary-600 border-t bg-primary-50">
Highlighted values differ between groups
</p>
)}
</div>
</div>
);
}