diff --git a/frontend/coverage/ContributionFlow.tsx.html b/frontend/coverage/ContributionFlow.tsx.html new file mode 100644 index 00000000..48d6c8e2 --- /dev/null +++ b/frontend/coverage/ContributionFlow.tsx.html @@ -0,0 +1,1099 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 | 1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +23x +23x +23x +23x +23x +23x +16x +16x + + + +13x + +13x +13x + +7x +13x +7x +7x + + + +1x +1x +1x +1x +1x +1x +1x +1x + +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x + +118x +118x +118x +118x +118x +118x +118x +118x + +118x + + + + + + + + + + + + + +1x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x +118x + +118x +118x + +118x +23x +23x +23x +16x +16x +23x + +118x +13x +13x +13x +13x +13x +13x +13x +7x +7x +7x +7x +7x +13x +13x +6x +6x +6x +6x +6x +13x + +118x +2x +2x +2x +2x + +118x +2x +2x +2x +2x +2x +2x +2x + +118x + +118x +118x + +118x +2x + +2x + + + +118x +33x +33x +33x +33x +33x +6x +27x +7x +20x + + +33x +33x +33x +7x +7x +7x +7x +7x +7x + +7x + +33x +33x + + + +118x +111x +111x +111x +111x +111x +111x +111x +111x +111x +111x +111x +111x +111x +111x + + +111x +80x +80x +240x +240x +240x +240x +240x +240x +240x +240x +240x +240x +80x +80x + + +111x +111x +111x +111x +111x + +111x +20x +20x + +20x +91x +111x +111x +6x + +111x +111x +111x + + + +118x +7x +7x +7x +7x +7x +7x +7x + + + +118x +118x +118x +118x +118x +118x +118x + + +118x +118x +118x +118x +118x +118x +118x +118x + +118x + +1x + | import { useState } from 'react';
+import {
+ Box,
+ Stack,
+ Typography,
+ TextField,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ CircularProgress,
+ Divider,
+ Chip,
+} from '@mui/material';
+import { Button } from './Button';
+import { ContributionSuccessModal } from './ContributionSuccessModal';
+import type { TransactionStatus } from '../types/contribution';
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+export interface ContributionFlowProps {
+ /** Minimum allowed contribution in XLM */
+ minAmount?: number;
+ /** Maximum allowed contribution in XLM */
+ maxAmount?: number;
+ /** Pre-filled amount (e.g. from group settings) */
+ defaultAmount?: number;
+ /** Current cycle number */
+ cycleId: number;
+ /** Connected wallet address */
+ walletAddress?: string;
+ /** Called with tx hash on success */
+ onSuccess?: (txHash: string, amount: number) => void;
+ /** Called on terminal error */
+ onError?: (error: Error) => void;
+ /** Disable the trigger button */
+ disabled?: boolean;
+}
+
+// ── Validation ───────────────────────────────────────────────────────────────
+
+function validateAmount(raw: string, min: number, max: number): string | null {
+ const value = parseFloat(raw);
+ if (!raw.trim() || isNaN(value)) return 'Please enter a valid amount.';
+ if (value <= 0) return 'Amount must be greater than 0.';
+ if (value < min) return `Minimum contribution is ${min} XLM.`;
+ if (value > max) return `Maximum contribution is ${max} XLM.`;
+ return null;
+}
+
+// ── Mock wallet transaction ──────────────────────────────────────────────────
+
+async function signAndSubmit(amount: number): Promise<string> {
+ // Step 1: wallet signing (simulated)
+ await new Promise((r) => setTimeout(r, 1200));
+ if (Math.random() < 0.08) throw new Error('User rejected the transaction in wallet.');
+ // Step 2: network submission
+ await new Promise((r) => setTimeout(r, 900));
+ if (Math.random() < 0.05) throw new Error('Network error: transaction timed out.');
+ return `tx_${amount}_${Math.random().toString(36).slice(2, 14)}`;
+}
+
+// ── Status display config ────────────────────────────────────────────────────
+
+const STATUS_LABEL: Record<TransactionStatus, string> = {
+ idle: '',
+ confirming: 'Waiting for your confirmation...',
+ pending: 'Signing transaction in wallet...',
+ submitting: 'Submitting to Stellar network...',
+ success: 'Contribution confirmed!',
+ error: 'Transaction failed.',
+};
+
+const STATUS_SEVERITY: Partial<Record<TransactionStatus, 'info' | 'success' | 'error' | 'warning'>> = {
+ confirming: 'info',
+ pending: 'info',
+ submitting: 'info',
+ success: 'success',
+ error: 'error',
+};
+
+// ── Confirmation Dialog ──────────────────────────────────────────────────────
+
+interface ConfirmDialogProps {
+ open: boolean;
+ amount: number;
+ cycleId: number;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+function ConfirmDialog({ open, amount, cycleId, onConfirm, onCancel }: ConfirmDialogProps) {
+ return (
+ <Dialog open={open} onClose={onCancel} maxWidth="xs" fullWidth>
+ <DialogTitle>Confirm Contribution</DialogTitle>
+ <DialogContent>
+ <Stack spacing={2} sx={{ pt: 1 }}>
+ <Typography variant="body2" color="text.secondary">
+ Cycle #{cycleId}
+ </Typography>
+ <Box sx={{ bgcolor: 'action.hover', borderRadius: 2, p: 2 }}>
+ <Stack spacing={1}>
+ <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
+ <Typography variant="body2" color="text.secondary">Amount</Typography>
+ <Typography variant="body2" fontWeight={700}>{amount} XLM</Typography>
+ </Box>
+ <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
+ <Typography variant="body2" color="text.secondary">Network fee</Typography>
+ <Typography variant="body2" color="text.secondary">~0.00001 XLM</Typography>
+ </Box>
+ <Divider />
+ <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
+ <Typography variant="body2" fontWeight={600}>Total</Typography>
+ <Typography variant="body2" fontWeight={700} color="primary">{amount} XLM</Typography>
+ </Box>
+ </Stack>
+ </Box>
+ <Typography variant="caption" color="text.secondary" textAlign="center">
+ Your wallet will prompt you to approve this transaction.
+ </Typography>
+ </Stack>
+ </DialogContent>
+ <DialogActions>
+ <Button variant="secondary" onClick={onCancel}>Cancel</Button>
+ <Button variant="primary" onClick={onConfirm}>Confirm & Sign</Button>
+ </DialogActions>
+ </Dialog>
+ );
+}
+
+// ── Main Component ───────────────────────────────────────────────────────────
+
+/**
+ * ContributionFlow — Issue #442
+ * Full user flow for making contributions:
+ * - Contribution amount form with validation
+ * - Confirmation dialog
+ * - Wallet signing simulation
+ * - Transaction status display
+ * - Success / error messages
+ * - Retry functionality
+ */
+export function ContributionFlow({
+ minAmount = 1,
+ maxAmount = 100000,
+ defaultAmount,
+ cycleId,
+ walletAddress,
+ onSuccess,
+ onError,
+ disabled = false,
+}: ContributionFlowProps) {
+ const [amountInput, setAmountInput] = useState(defaultAmount ? String(defaultAmount) : '');
+ const [fieldError, setFieldError] = useState<string | null>(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [status, setStatus] = useState<TransactionStatus>('idle');
+ const [txHash, setTxHash] = useState<string | null>(null);
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const [showSuccess, setShowSuccess] = useState(false);
+
+ const isProcessing = ['confirming', 'pending', 'submitting'].includes(status);
+ const parsedAmount = parseFloat(amountInput);
+
+ const handleSubmitForm = (e: React.FormEvent) => {
+ e.preventDefault();
+ const err = validateAmount(amountInput, minAmount, maxAmount);
+ if (err) { setFieldError(err); return; }
+ setFieldError(null);
+ setConfirmOpen(true);
+ };
+
+ const handleConfirm = async () => {
+ setConfirmOpen(false);
+ setStatus('confirming');
+ setErrorMessage(null);
+ setTxHash(null);
+ try {
+ setStatus('pending');
+ const hash = await signAndSubmit(parsedAmount);
+ setStatus('submitting');
+ await new Promise((r) => setTimeout(r, 500));
+ setTxHash(hash);
+ setStatus('success');
+ setShowSuccess(true);
+ onSuccess?.(hash, parsedAmount);
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Transaction failed');
+ setErrorMessage(error.message);
+ setStatus('error');
+ onError?.(error);
+ }
+ };
+
+ const handleRetry = () => {
+ setStatus('idle');
+ setErrorMessage(null);
+ setTxHash(null);
+ };
+
+ const handleReset = () => {
+ setStatus('idle');
+ setAmountInput(defaultAmount ? String(defaultAmount) : '');
+ setFieldError(null);
+ setErrorMessage(null);
+ setTxHash(null);
+ setShowSuccess(false);
+ };
+
+ const statusSeverity = STATUS_SEVERITY[status];
+
+ return (
+ <Box>
+ {/* Wallet not connected warning */}
+ {!walletAddress && (
+ <Alert severity="warning" sx={{ mb: 2 }}>
+ Connect your wallet to make a contribution.
+ </Alert>
+ )}
+
+ {/* Transaction status banner */}
+ {statusSeverity && STATUS_LABEL[status] && (
+ <Alert
+ severity={statusSeverity}
+ sx={{ mb: 2 }}
+ action={
+ status === 'error' ? (
+ <Button variant="ghost" size="sm" onClick={handleRetry}>Retry</Button>
+ ) : status === 'success' ? (
+ <Button variant="ghost" size="sm" onClick={handleReset}>New</Button>
+ ) : undefined
+ }
+ >
+ <Stack spacing={0.5}>
+ <span>{STATUS_LABEL[status]}</span>
+ {txHash && status === 'success' && (
+ <a
+ href={`https://stellar.expert/explorer/testnet/tx/${txHash}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ fontSize: '0.75rem', color: 'inherit' }}
+ >
+ View on Stellar Explorer →
+ </a>
+ )}
+ </Stack>
+ </Alert>
+ )}
+
+ {/* Contribution form — hidden while processing or after success */}
+ {status !== 'success' && (
+ <Box component="form" onSubmit={handleSubmitForm} noValidate>
+ <Stack spacing={2}>
+ <TextField
+ label="Contribution Amount (XLM)"
+ type="number"
+ value={amountInput}
+ onChange={(e) => { setAmountInput(e.target.value); setFieldError(null); }}
+ error={!!fieldError}
+ helperText={fieldError ?? `Min: ${minAmount} XLM · Max: ${maxAmount.toLocaleString()} XLM`}
+ inputProps={{ min: minAmount, max: maxAmount, step: '0.01' }}
+ disabled={isProcessing || !walletAddress || disabled}
+ fullWidth
+ size="small"
+ />
+
+ {/* Amount quick-select chips */}
+ {defaultAmount && (
+ <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
+ {[defaultAmount * 0.5, defaultAmount, defaultAmount * 2].map((v) => (
+ <Chip
+ key={v}
+ label={`${v} XLM`}
+ size="small"
+ variant={parseFloat(amountInput) === v ? 'filled' : 'outlined'}
+ color={parseFloat(amountInput) === v ? 'primary' : 'default'}
+ onClick={() => { setAmountInput(String(v)); setFieldError(null); }}
+ disabled={isProcessing || !walletAddress || disabled}
+ sx={{ cursor: 'pointer' }}
+ />
+ ))}
+ </Box>
+ )}
+
+ <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
+ <Button
+ type="submit"
+ variant="primary"
+ disabled={isProcessing || !walletAddress || disabled}
+ >
+ {isProcessing ? (
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
+ <CircularProgress size={16} color="inherit" />
+ Processing...
+ </Box>
+ ) : 'Contribute'}
+ </Button>
+ {status === 'error' && (
+ <Button variant="secondary" onClick={handleRetry}>Try Again</Button>
+ )}
+ </Box>
+ </Stack>
+ </Box>
+ )}
+
+ {/* Success state */}
+ {status === 'success' && (
+ <Stack spacing={2} alignItems="center" sx={{ py: 2 }}>
+ <Typography variant="h6" color="success.main">🎉 Contribution Successful!</Typography>
+ <Typography variant="body2" color="text.secondary">
+ {parsedAmount} XLM contributed to Cycle #{cycleId}
+ </Typography>
+ <Button variant="secondary" onClick={handleReset}>Make Another Contribution</Button>
+ </Stack>
+ )}
+
+ {/* Confirmation dialog */}
+ <ConfirmDialog
+ open={confirmOpen}
+ amount={parsedAmount || 0}
+ cycleId={cycleId}
+ onConfirm={() => void handleConfirm()}
+ onCancel={() => setConfirmOpen(false)}
+ />
+
+ {/* Success animation modal */}
+ <ContributionSuccessModal
+ open={showSuccess}
+ amount={parsedAmount || 0}
+ cycleId={cycleId}
+ txHash={txHash ?? undefined}
+ onClose={handleReset}
+ />
+ </Box>
+ );
+}
+
+export default ContributionFlow;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 | 1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + +7x +7x +7x +7x +7x +7x + +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x + +35x +35x +8x +8x + + + + + + + + + + + + + + + + + + + +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x + +35x +1x +1x +1x + +35x +35x +35x +8x +8x +8x + + +35x +35x +35x +35x + +35x +8x +8x +8x + + +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x +35x + +35x +35x +4x +4x +4x +4x +4x + +4x + +35x +5x +5x +5x +5x +5x + +5x + +35x +35x + + +35x +8x +8x +8x +8x +8x +8x + +8x +8x + +8x + +27x +27x +27x +27x + +27x + + + + + + + + + + + + + +1x +49x + + +49x +49x +49x +49x +49x +49x + +49x +21x + +21x +3x +3x +3x +3x +3x +3x + +3x + +7x +7x + +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x + +7x + + +28x +49x + +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x +49x + +49x + | import { Link } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { buildRoute } from '../routing/constants';
+import { fetchGroup } from '../utils/groupApi';
+import type { GroupDetail } from '../types/group';
+import { GroupBadge } from './GroupBadge';
+import { GroupCardSkeleton } from './Skeleton/GroupCardSkeleton';
+import { Button } from './Button';
+import './GroupCard.css';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type Status = 'active' | 'completed' | 'pending' | 'complete';
+
+/** Prop-driven mode: caller supplies all data directly. */
+interface GroupCardStaticProps {
+ groupId?: string;
+ groupName: string;
+ memberCount: number;
+ contributionAmount: number;
+ currency?: string;
+ status?: Status;
+ currentCycle?: number;
+ nextPayoutDate?: Date | null;
+ description?: string;
+ imageUrl?: string;
+ onClick?: () => void;
+ onViewDetails?: () => void;
+ onJoin?: () => void;
+ className?: string;
+}
+
+/** Fetch mode: only groupId is required; data is loaded via React Query. */
+interface GroupCardFetchProps {
+ groupId: string;
+ groupName?: never;
+ memberCount?: never;
+ contributionAmount?: never;
+ currency?: string;
+ status?: never;
+ currentCycle?: never;
+ nextPayoutDate?: never;
+ description?: never;
+ imageUrl?: never;
+ onClick?: () => void;
+ onViewDetails?: () => void;
+ onJoin?: () => void;
+ className?: string;
+}
+
+export type GroupCardProps = GroupCardStaticProps | GroupCardFetchProps;
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const STROOPS_PER_XLM = 10_000_000;
+
+function formatXlm(stroops: number): string {
+ return (stroops / STROOPS_PER_XLM).toLocaleString('en-US', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ });
+}
+
+function computeNextPayout(
+ startedAt: Date | null,
+ currentCycle: number,
+ cycleDurationSecs: number,
+): Date | null {
+ if (!startedAt || cycleDurationSecs <= 0) return null;
+ const nextCycleEnd =
+ startedAt.getTime() + (currentCycle + 1) * cycleDurationSecs * 1000;
+ return new Date(nextCycleEnd);
+}
+
+function formatDate(date: Date | null | undefined): string {
+ if (!date) return '—';
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+// ─── Inner UI ─────────────────────────────────────────────────────────────────
+
+interface CardUIProps {
+ groupId?: string;
+ groupName: string;
+ memberCount: number;
+ contributionAmount: string;
+ status: Status;
+ currentCycle: number;
+ nextPayoutDate: Date | null | undefined;
+ description?: string;
+ imageUrl?: string;
+ onClick?: () => void;
+ onViewDetails?: () => void;
+ onJoin?: () => void;
+ className?: string;
+}
+
+function GroupCardUI({
+ groupId,
+ groupName,
+ memberCount,
+ contributionAmount,
+ status,
+ currentCycle,
+ nextPayoutDate,
+ description,
+ imageUrl,
+ onClick,
+ onViewDetails,
+ onJoin,
+ className = '',
+}: CardUIProps) {
+ const classes = ['group-card', className].filter(Boolean).join(' ');
+
+ const handleCardClick = (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest('button')) return;
+ onClick?.();
+ };
+
+ const content = (
+ <>
+ {imageUrl && (
+ <div className="group-card-image">
+ <img src={imageUrl} alt={groupName} />
+ </div>
+ )}
+
+ <div className="group-card-header">
+ <h3 className="group-card-title">{groupName}</h3>
+ <GroupBadge status={status} />
+ </div>
+
+ {description && (
+ <div className="group-card-description">
+ <p>{description}</p>
+ </div>
+ )}
+
+ <div className="group-card-body">
+ <div className="group-card-stats">
+ <div className="group-card-stat">
+ <span className="group-card-stat-label">Contribution</span>
+ <span className="group-card-stat-value">{contributionAmount}</span>
+ </div>
+ <div className="group-card-stat">
+ <span className="group-card-stat-label">Members</span>
+ <span className="group-card-stat-value">{memberCount}</span>
+ </div>
+ <div className="group-card-stat">
+ <span className="group-card-stat-label">Cycle</span>
+ <span className="group-card-stat-value">{currentCycle}</span>
+ </div>
+ <div className="group-card-stat">
+ <span className="group-card-stat-label">Next Payout</span>
+ <span className="group-card-stat-value group-card-stat-value--date">
+ {formatDate(nextPayoutDate)}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <div className="group-card-footer">
+ {onViewDetails && (
+ <Button
+ variant="secondary"
+ size="sm"
+ onClick={(e) => { e.stopPropagation(); onViewDetails(); }}
+ >
+ View Details
+ </Button>
+ )}
+ {onJoin && (
+ <Button
+ variant="primary"
+ size="sm"
+ onClick={(e) => { e.stopPropagation(); onJoin(); }}
+ >
+ Join Group
+ </Button>
+ )}
+ </div>
+ </>
+ );
+
+ if (groupId) {
+ return (
+ <Link
+ to={buildRoute.groupDetail(groupId)}
+ className={classes}
+ style={{ textDecoration: 'none', color: 'inherit' }}
+ onClick={handleCardClick}
+ >
+ {content}
+ </Link>
+ );
+ }
+
+ return (
+ <div className={classes} onClick={handleCardClick}>
+ {content}
+ </div>
+ );
+}
+
+// ─── Public component ─────────────────────────────────────────────────────────
+
+/**
+ * GroupCard — displays group name, contribution amount, current cycle,
+ * member count, next payout date, and a status badge.
+ *
+ * Two modes:
+ * - **Static**: pass all data as props (backward-compatible with existing usage).
+ * - **Fetch**: pass only `groupId`; data is fetched via React Query using the
+ * Soroban RPC client (`fetchGroup`). Shows a skeleton while loading and an
+ * inline error on failure.
+ */
+export function GroupCard(props: GroupCardProps) {
+ const isFetchMode = props.groupId !== undefined && props.groupName === undefined;
+
+ // Fetch mode — React Query
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['group', props.groupId],
+ queryFn: () => fetchGroup(props.groupId!) as Promise<GroupDetail | null>,
+ enabled: isFetchMode,
+ staleTime: 30_000,
+ });
+
+ if (isFetchMode) {
+ if (isLoading) return <GroupCardSkeleton />;
+
+ if (error || !data) {
+ return (
+ <div className="group-card group-card--error" role="alert">
+ <p className="group-card-error-msg">
+ {error instanceof Error ? error.message : 'Failed to load group.'}
+ </p>
+ </div>
+ );
+ }
+
+ const nextPayout = computeNextPayout(data.startedAt, data.currentCycle, data.cycleDuration);
+ const amountStr = `${formatXlm(data.contributionAmount)} ${data.currency}`;
+
+ return (
+ <GroupCardUI
+ groupId={data.id}
+ groupName={data.name}
+ memberCount={data.memberCount}
+ contributionAmount={amountStr}
+ status={data.status as Status}
+ currentCycle={data.currentCycle}
+ nextPayoutDate={nextPayout}
+ description={data.description}
+ imageUrl={data.imageUrl}
+ onClick={props.onClick}
+ onViewDetails={props.onViewDetails}
+ onJoin={props.onJoin}
+ className={props.className}
+ />
+ );
+ }
+
+ // Static mode — props supplied directly (backward-compatible)
+ const p = props as GroupCardStaticProps;
+ const amountStr = `${(p.contributionAmount ?? 0).toLocaleString()} ${p.currency ?? 'XLM'}`;
+
+ return (
+ <GroupCardUI
+ groupId={p.groupId}
+ groupName={p.groupName}
+ memberCount={p.memberCount}
+ contributionAmount={amountStr}
+ status={p.status ?? 'active'}
+ currentCycle={p.currentCycle ?? 0}
+ nextPayoutDate={p.nextPayoutDate}
+ description={p.description}
+ imageUrl={p.imageUrl}
+ onClick={p.onClick}
+ onViewDetails={p.onViewDetails}
+ onJoin={p.onJoin}
+ className={p.className}
+ />
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 | 1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x +1x + + + + + + + + + + + + + + +10x +10x +10x +10x +10x +10x +10x + +10x +10x + +6x +10x + +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x +10x + + + +1x +1x +1x +1x +1x +1x +1x + + + +1x +1x +1x +1x +1x +1x +1x +1x + +16x +16x +1x +15x + +16x +16x +16x +16x +16x +16x +47x +16x +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x +47x +47x +47x + +16x +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x +47x +47x +47x + +47x +47x + +47x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x + +16x +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x + +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x +47x +47x + +16x +16x +16x +16x +16x +16x +16x +47x +47x +47x +47x +47x +47x +47x + +47x + +16x +16x +16x + + + + + + + + + + + + + + + + + + + + + +1x +41x +41x +41x +41x +41x +41x +41x + +41x +41x +41x +41x +41x +41x + + +41x +16x +6x +6x +6x +10x +10x +10x +10x +6x +10x +10x + +4x +4x +10x +10x +41x + + +41x +31x + +41x + +41x + +41x +41x +41x +41x + +41x +41x + +41x +41x + +41x +41x +41x +41x +41x +41x +41x +41x + +41x +41x +45x +41x +41x +41x +12x + +12x + +41x + +41x + + +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +19x +19x +19x + +41x +32x +32x +32x + +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x +41x + +41x + +1x + | /**
+ * TransactionHistory — Issue #769
+ *
+ * Paginated table component displaying a member's full contribution and
+ * payout history. Uses MUI DataGrid with:
+ * - Sorting by date, amount, and type
+ * - Horizon API fetch filtered by account and contract ID
+ * - Loading and empty states
+ */
+import { useMemo, useState, useEffect } from 'react';
+import {
+ Box,
+ Chip,
+ Typography,
+ TextField,
+ MenuItem,
+ Stack,
+ CircularProgress,
+ Alert,
+ Link,
+} from '@mui/material';
+import {
+ DataGrid,
+ type GridColDef,
+ type GridSortModel,
+ type GridRenderCellParams,
+} from '@mui/x-data-grid';
+import type { Transaction, TransactionType } from '../types/transaction';
+import { useWallet } from '../hooks/useWallet';
+
+// ── Horizon fetch ─────────────────────────────────────────────────────────────
+
+const HORIZON_URLS: Record<string, string> = {
+ TESTNET: 'https://horizon-testnet.stellar.org',
+ MAINNET: 'https://horizon.stellar.org',
+ FUTURENET: 'https://horizon-futurenet.stellar.org',
+};
+
+interface HorizonPayment {
+ id: string;
+ type: string;
+ created_at: string;
+ transaction_hash: string;
+ amount?: string;
+ asset_code?: string;
+ asset_type?: string;
+ from?: string;
+ to?: string;
+ memo?: string;
+}
+
+async function fetchHorizonTransactions(
+ address: string,
+ network: string,
+ contractId?: string,
+): Promise<Transaction[]> {
+ const base = HORIZON_URLS[network] ?? HORIZON_URLS.TESTNET;
+ const url = `${base}/accounts/${address}/payments?limit=50&order=desc`;
+
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Horizon error: ${res.status}`);
+
+ const data = (await res.json()) as { _embedded: { records: HorizonPayment[] } };
+ const records = data._embedded?.records ?? [];
+
+ return records
+ .filter((r) => !contractId || r.transaction_hash.includes(contractId.slice(0, 8)))
+ .map((r): Transaction => ({
+ id: r.id,
+ hash: r.transaction_hash,
+ createdAt: r.created_at,
+ type: (r.type === 'payment' ? 'payment' : 'deposit') as TransactionType,
+ amount: r.amount ? `+${r.amount}` : '0',
+ assetCode: r.asset_code ?? (r.asset_type === 'native' ? 'XLM' : 'UNKNOWN'),
+ from: r.from ?? address,
+ to: r.to,
+ memo: r.memo,
+ status: 'success',
+ fee: '0.00001',
+ }));
+}
+
+// ── Mock fallback ─────────────────────────────────────────────────────────────
+
+const MOCK_TRANSACTIONS: Transaction[] = [
+ { id: '1', hash: 'abc123def456abc123def456abc123de', createdAt: '2026-04-20T10:30:00Z', type: 'deposit', amount: '+250', assetCode: 'XLM', from: 'GABC...', to: 'GDEF...', memo: 'Group contribution cycle 2', status: 'success', fee: '0.00001' },
+ { id: '2', hash: 'def456ghi789def456ghi789def456gh', createdAt: '2026-04-15T14:22:00Z', type: 'payment', amount: '+1000', assetCode: 'XLM', from: 'GDEF...', to: 'GABC...', memo: 'Payout cycle 1', status: 'success', fee: '0.00001' },
+ { id: '3', hash: 'ghi789jkl012ghi789jkl012ghi789jk', createdAt: '2026-03-20T09:15:00Z', type: 'deposit', amount: '+250', assetCode: 'XLM', from: 'GABC...', to: 'GDEF...', memo: 'Group contribution cycle 1', status: 'success', fee: '0.00001' },
+ { id: '4', hash: 'jkl012mno345jkl012mno345jkl012mn', createdAt: '2026-03-10T16:45:00Z', type: 'withdraw', amount: '-45.50', assetCode: 'USDC', from: 'GABC...', to: 'GXYZ...', memo: '', status: 'success', fee: '0.00001' },
+ { id: '5', hash: 'mno345pqr678mno345pqr678mno345pq', createdAt: '2026-02-28T11:20:00Z', type: 'claimable', amount: '+15.75', assetCode: 'USDC', from: 'GXYZ...', memo: 'Reward claim', status: 'pending', fee: '0.00001' },
+];
+
+// ── Column definitions ────────────────────────────────────────────────────────
+
+const TYPE_COLORS: Record<string, 'success' | 'primary' | 'warning' | 'error' | 'default'> = {
+ deposit: 'success',
+ payment: 'primary',
+ withdraw: 'warning',
+ swap: 'default',
+ claimable: 'success',
+ other: 'default',
+};
+
+function buildColumns(network: string): GridColDef[] {
+ const explorerBase = network === 'MAINNET'
+ ? 'https://stellar.expert/explorer/public/tx'
+ : 'https://stellar.expert/explorer/testnet/tx';
+
+ return [
+ {
+ field: 'createdAt',
+ headerName: 'Date',
+ width: 160,
+ valueFormatter: (value: string) =>
+ new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }),
+ sortable: true,
+ },
+ {
+ field: 'type',
+ headerName: 'Type',
+ width: 130,
+ sortable: true,
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => (
+ <Chip
+ label={params.value ?? ''}
+ size="small"
+ color={TYPE_COLORS[params.value ?? ''] ?? 'default'}
+ sx={{ textTransform: 'capitalize', fontWeight: 600 }}
+ />
+ ),
+ },
+ {
+ field: 'amount',
+ headerName: 'Amount',
+ width: 140,
+ sortable: true,
+ sortComparator: (a: string, b: string) => parseFloat(a) - parseFloat(b),
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => {
+ const val = parseFloat(params.value ?? '0');
+ return (
+ <Typography
+ variant="body2"
+ fontWeight={600}
+ color={val >= 0 ? 'success.main' : 'error.main'}
+ >
+ {params.value} {params.row.assetCode}
+ </Typography>
+ );
+ },
+ },
+ {
+ field: 'assetCode',
+ headerName: 'Asset',
+ width: 90,
+ sortable: true,
+ },
+ {
+ field: 'from',
+ headerName: 'From',
+ width: 140,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => (
+ <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
+ {params.value}
+ </Typography>
+ ),
+ },
+ {
+ field: 'memo',
+ headerName: 'Memo',
+ flex: 1,
+ minWidth: 160,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => (
+ <Typography variant="caption" color="text.secondary" noWrap>
+ {params.value || '—'}
+ </Typography>
+ ),
+ },
+ {
+ field: 'status',
+ headerName: 'Status',
+ width: 110,
+ sortable: true,
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => (
+ <Chip
+ label={params.value}
+ size="small"
+ color={params.value === 'success' ? 'success' : params.value === 'pending' ? 'warning' : 'error'}
+ />
+ ),
+ },
+ {
+ field: 'hash',
+ headerName: 'TX',
+ width: 80,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams<Transaction, string>) => (
+ <Link
+ href={`${explorerBase}/${params.value}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="caption"
+ onClick={(e) => e.stopPropagation()}
+ >
+ View ↗
+ </Link>
+ ),
+ },
+ ];
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface TransactionHistoryProps {
+ /** Override the wallet address to query (defaults to connected wallet) */
+ address?: string;
+ /** Optional Soroban contract ID to filter transactions */
+ contractId?: string;
+ /** Initial page size */
+ pageSize?: number;
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+/**
+ * TransactionHistory
+ *
+ * Fetches and displays a paginated, sortable table of Stellar transactions
+ * for the connected wallet address, filtered by account and optionally by
+ * contract ID.
+ */
+export function TransactionHistory({
+ address: addressProp,
+ contractId,
+ pageSize: initialPageSize = 10,
+}: TransactionHistoryProps) {
+ const { activeAddress, network } = useWallet();
+ const address = addressProp ?? activeAddress;
+ const net = network ?? 'TESTNET';
+
+ const [transactions, setTransactions] = useState<Transaction[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [typeFilter, setTypeFilter] = useState<string>('all');
+ const [sortModel, setSortModel] = useState<GridSortModel>([{ field: 'createdAt', sort: 'desc' }]);
+ const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: initialPageSize });
+
+ // Fetch from Horizon (falls back to mock if no address or fetch fails)
+ useEffect(() => {
+ if (!address) {
+ setTransactions(MOCK_TRANSACTIONS);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ fetchHorizonTransactions(address, net, contractId)
+ .then((txs) => {
+ setTransactions(txs.length > 0 ? txs : MOCK_TRANSACTIONS);
+ })
+ .catch(() => {
+ // Graceful fallback to mock data
+ setTransactions(MOCK_TRANSACTIONS);
+ setError(null); // don't show error — just use mock
+ })
+ .finally(() => setLoading(false));
+ }, [address, net, contractId]);
+
+ // Client-side type filter
+ const filtered = useMemo(() => {
+ if (typeFilter === 'all') return transactions;
+ return transactions.filter((tx) => tx.type === typeFilter);
+ }, [transactions, typeFilter]);
+
+ const columns = useMemo(() => buildColumns(net), [net]);
+
+ const uniqueTypes = useMemo(
+ () => Array.from(new Set(transactions.map((t) => t.type))),
+ [transactions],
+ );
+
+ return (
+ <Box>
+ {/* Toolbar */}
+ <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }} alignItems={{ sm: 'center' }}>
+ <Typography variant="h3" sx={{ flex: 1 }}>
+ Transaction History
+ </Typography>
+ <TextField
+ select
+ size="small"
+ label="Type"
+ value={typeFilter}
+ onChange={(e) => setTypeFilter(e.target.value)}
+ sx={{ minWidth: 140 }}
+ >
+ <MenuItem value="all">All types</MenuItem>
+ {uniqueTypes.map((t) => (
+ <MenuItem key={t} value={t} sx={{ textTransform: 'capitalize' }}>{t}</MenuItem>
+ ))}
+ </TextField>
+ {!address && (
+ <Typography variant="caption" color="text.secondary">
+ Showing demo data — connect wallet to see real transactions
+ </Typography>
+ )}
+ </Stack>
+
+ {error && <Alert severity="warning" sx={{ mb: 2 }}>{error}</Alert>}
+
+ {/* DataGrid */}
+ <Box sx={{ height: 480, width: '100%' }}>
+ <DataGrid
+ rows={filtered}
+ columns={columns}
+ loading={loading}
+ sortModel={sortModel}
+ onSortModelChange={setSortModel}
+ paginationModel={paginationModel}
+ onPaginationModelChange={setPaginationModel}
+ pageSizeOptions={[5, 10, 25, 50]}
+ disableRowSelectionOnClick
+ density="compact"
+ slots={{
+ loadingOverlay: () => (
+ <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
+ <CircularProgress size={32} />
+ </Box>
+ ),
+ noRowsOverlay: () => (
+ <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
+ <Typography color="text.secondary">No transactions found</Typography>
+ </Box>
+ ),
+ }}
+ sx={{
+ border: '1px solid',
+ borderColor: 'divider',
+ borderRadius: 2,
+ '& .MuiDataGrid-columnHeaders': { bgcolor: 'action.hover' },
+ '& .MuiDataGrid-row:hover': { bgcolor: 'action.hover' },
+ }}
+ />
+ </Box>
+ </Box>
+ );
+}
+
+export default TransactionHistory;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| ContributionFlow.tsx | +
+
+ |
+ 100% | +226/226 | +95.58% | +65/68 | +100% | +12/12 | +100% | +226/226 | +
| GroupCard.tsx | +
+
+ |
+ 100% | +166/166 | +90.9% | +30/33 | +100% | +9/9 | +100% | +166/166 | +
| TransactionHistory.tsx | +
+
+ |
+ 99.58% | +242/243 | +73.58% | +39/53 | +80% | +12/15 | +99.58% | +242/243 | +