diff --git a/src/app/api/groups/[id]/leaderboard/route.ts b/src/app/api/groups/[id]/leaderboard/route.ts index 5291fda6..cff63a56 100644 --- a/src/app/api/groups/[id]/leaderboard/route.ts +++ b/src/app/api/groups/[id]/leaderboard/route.ts @@ -34,16 +34,23 @@ export async function GET( const invoices = await getGroupInvoices(groupId); // Calculate member stats - const memberStats = new Map(); + const memberStats = new Map< + string, + { owed: bigint; received: bigint; invoiceCount: number; streak: number } + >(); for (const invoice of invoices) { for (const recipient of invoice.recipients) { if (!memberStats.has(recipient.address)) { - memberStats.set(recipient.address, { owed: 0n, received: 0n }); + memberStats.set(recipient.address, { owed: 0n, received: 0n, invoiceCount: 0, streak: 0 }); } const stats = memberStats.get(recipient.address)!; stats.owed += recipient.amount; + stats.invoiceCount += 1; + if (invoice.status === 'paid') { + stats.streak += 1; + } // Calculate received amount based on payment ratio if (invoice.funded > 0n) { @@ -71,6 +78,9 @@ export async function GET( percentComplete, rank: index + 1, optedOut, + totalPaid: stats.received, + invoiceCount: stats.invoiceCount, + streak: stats.streak, }; } ); diff --git a/src/hooks/useEmailValidation.ts b/src/hooks/useEmailValidation.ts index 7985f82d..760ad625 100644 --- a/src/hooks/useEmailValidation.ts +++ b/src/hooks/useEmailValidation.ts @@ -16,7 +16,15 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const domainCache = new Map(); -export function useEmailValidation(email: string, debounceMs = 500) { +export interface UseEmailValidationOptions { + debounceMs?: number; + checkMx?: boolean; +} + +export function useEmailValidation( + email: string, + { debounceMs = 500, checkMx = true }: UseEmailValidationOptions = {} +) { const [isValidFormat, setIsValidFormat] = useState(false); const [isCheckingMX, setIsCheckingMX] = useState(false); const [mxValid, setMxValid] = useState(null); @@ -32,7 +40,7 @@ export function useEmailValidation(email: string, debounceMs = 500) { const formatValid = EMAIL_PATTERN.test(email); setIsValidFormat(formatValid); - if (!formatValid) { + if (!formatValid || !checkMx) { setMxValid(null); return; } @@ -81,7 +89,7 @@ export function useEmailValidation(email: string, debounceMs = 500) { clearTimeout(debounceTimerRef.current); } }; - }, [email, debounceMs, checkEmail]); + }, [email, debounceMs, checkEmail, checkMx]); return { isValidFormat, diff --git a/src/hooks/useFilterPresets.ts b/src/hooks/useFilterPresets.ts index 7894e31c..88dfb2d5 100644 --- a/src/hooks/useFilterPresets.ts +++ b/src/hooks/useFilterPresets.ts @@ -36,6 +36,20 @@ export interface UseFilterPresetsResult { savePreset: (name: string, filters: Record) => string | null; renamePreset: (id: string, name: string) => string | null; deletePreset: (id: string) => void; + exportPresets: () => string; + importPresets: (json: string) => void; +} + +function isFilterPreset(value: unknown): value is FilterPreset { + if (typeof value !== "object" || value === null) return false; + const p = value as Record; + return ( + typeof p.id === "string" && + typeof p.name === "string" && + typeof p.createdAt === "string" && + typeof p.filters === "object" && + p.filters !== null + ); } export function useFilterPresets(): UseFilterPresetsResult { @@ -102,5 +116,32 @@ export function useFilterPresets(): UseFilterPresetsResult { [presets] ); - return { presets, savePreset, renamePreset, deletePreset }; + const exportPresets = useCallback((): string => { + return JSON.stringify(presets); + }, [presets]); + + const importPresets = useCallback( + (json: string) => { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error("Invalid JSON"); + } + if (!Array.isArray(parsed) || !parsed.every(isFilterPreset)) { + throw new Error("Invalid preset data"); + } + + const existingNames = new Set(presets.map((p) => normalizeName(p.name))); + const toAdd = parsed.filter((p) => !existingNames.has(normalizeName(p.name))); + if (toAdd.length === 0) return; + + const next = [...presets, ...toAdd]; + setPresets(next); + writePresets(next); + }, + [presets] + ); + + return { presets, savePreset, renamePreset, deletePreset, exportPresets, importPresets }; } diff --git a/src/hooks/useGroupLeaderboard.ts b/src/hooks/useGroupLeaderboard.ts index 20286eb8..7a11e571 100644 --- a/src/hooks/useGroupLeaderboard.ts +++ b/src/hooks/useGroupLeaderboard.ts @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; -import type { GroupLeaderboardData } from '@/types/groupLeaderboard'; +import { useEffect, useState, useRef, useMemo } from 'react'; +import type { GroupLeaderboardData, LeaderboardSortBy } from '@/types/groupLeaderboard'; -export function useGroupLeaderboard(groupId: string) { +export function useGroupLeaderboard(groupId: string, sortBy: LeaderboardSortBy = 'totalPaid') { const [leaderboard, setLeaderboard] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -49,6 +49,15 @@ export function useGroupLeaderboard(groupId: string) { const mutate = fetchLeaderboard; + const sortedLeaderboard = useMemo(() => { + if (!leaderboard) return leaderboard; + const members = [...leaderboard.members].sort((a, b) => { + const diff = Number(b[sortBy]) - Number(a[sortBy]); + return diff !== 0 ? diff : a.rank - b.rank; + }); + return { ...leaderboard, members }; + }, [leaderboard, sortBy]); + useEffect(() => { mountedRef.current = true; @@ -71,7 +80,7 @@ export function useGroupLeaderboard(groupId: string) { }, [groupId]); return { - leaderboard, + leaderboard: sortedLeaderboard, isLoading, error, updateOptOut, diff --git a/src/hooks/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts index ba0f5efa..46046c81 100644 --- a/src/hooks/useSplitCalculator.ts +++ b/src/hooks/useSplitCalculator.ts @@ -54,8 +54,12 @@ export interface RoundingResolution { const STROOP_SCALE = 1e7; -function roundToStroops(value: number): number { - return Math.round(value * STROOP_SCALE) / STROOP_SCALE; +export type RoundingMode = 'floor' | 'ceil' | 'nearest'; + +function roundToStroops(value: number, mode: RoundingMode = 'nearest'): number { + const scaled = value * STROOP_SCALE; + const rounded = mode === 'floor' ? Math.floor(scaled) : mode === 'ceil' ? Math.ceil(scaled) : Math.round(scaled); + return rounded / STROOP_SCALE; } export function validateShares(recipients: RecipientLine[]): SplitValidation { @@ -103,13 +107,27 @@ export function validateShares(recipients: RecipientLine[]): SplitValidation { export function calculateSplit( totalAmount: number, recipients: RecipientLine[], - _assetCode: 'XLM' | 'USDC' = 'USDC' + _assetCode: 'XLM' | 'USDC' = 'USDC', + roundingMode: RoundingMode = 'nearest' ): SplitCalculatorResult { const totalNum = totalAmount || 0; const validation = validateShares(recipients); - const derivedLines: DerivedRecipientLine[] = recipients.map((r) => { - const grossAmount = roundToStroops((totalNum * (r.sharePercent || 0)) / 100); + const grossAmounts = recipients.map((r) => + roundToStroops((totalNum * (r.sharePercent || 0)) / 100, roundingMode) + ); + + if (recipients.length > 0) { + const totalStroops = Math.round(totalNum * STROOP_SCALE); + const sumStroops = grossAmounts.reduce((s, v) => s + Math.round(v * STROOP_SCALE), 0); + const remainderStroops = totalStroops - sumStroops; + if (remainderStroops !== 0) { + grossAmounts[0] = (Math.round(grossAmounts[0] * STROOP_SCALE) + remainderStroops) / STROOP_SCALE; + } + } + + const derivedLines: DerivedRecipientLine[] = recipients.map((r, i) => { + const grossAmount = grossAmounts[i]; const preFeeAmount = grossAmount; const effectiveTaxAmount = roundToStroops( (preFeeAmount * (r.taxRatePercent || 0)) / 100 @@ -153,11 +171,12 @@ export function calculateSplit( export function useSplitCalculator( totalAmount: number, recipients: RecipientLine[], - assetCode: 'XLM' | 'USDC' = 'USDC' + assetCode: 'XLM' | 'USDC' = 'USDC', + roundingMode: RoundingMode = 'nearest' ): SplitCalculatorResult { return useMemo( - () => calculateSplit(totalAmount, recipients, assetCode), - [totalAmount, recipients, assetCode] + () => calculateSplit(totalAmount, recipients, assetCode, roundingMode), + [totalAmount, recipients, assetCode, roundingMode] ); } diff --git a/src/types/groupLeaderboard.ts b/src/types/groupLeaderboard.ts index 52256ab6..aae5fb35 100644 --- a/src/types/groupLeaderboard.ts +++ b/src/types/groupLeaderboard.ts @@ -6,8 +6,13 @@ export interface GroupMember { percentComplete: number; rank: number; optedOut: boolean; + totalPaid: bigint; + invoiceCount: number; + streak: number; } +export type LeaderboardSortBy = 'totalPaid' | 'invoiceCount' | 'streak'; + export interface GroupLeaderboardData { groupId: string; members: GroupMember[];