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
9 changes: 5 additions & 4 deletions src/components/QuoteCard.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatAmount, formatRate, formatPercent } from '../utils/format.js';
import { formatRate, formatPercent } from '../utils/format.js';
import { formatMoney } from '../utils/money.js';
import { FEE_PERCENT } from '../constants/fees.js';
import { DEFAULT_LOCALE } from '../constants/locales.js';
import './QuoteCard.css';
Expand All @@ -20,12 +21,12 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {

<div className="quote-line">
<span>You send</span>
<span>{formatAmount(sendAmount, from, locale)}</span>
<span>{formatMoney(sendAmount, from, locale)}</span>
</div>

<div className="quote-line quote-muted">
<span>RemitFlow fee ({formatPercent(FEE_PERCENT, 1)} + flat)</span>
<span>- {formatAmount(fee, from, locale)}</span>
<span>- {formatMoney(fee, from, locale)}</span>
</div>

<div className="quote-line quote-muted">
Expand All @@ -37,7 +38,7 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {

<div className="quote-line quote-total">
<span>Recipient gets</span>
<span>{formatAmount(receiveAmount, to, locale)}</span>
<span>{formatMoney(receiveAmount, to, locale)}</span>
</div>

<p className="quote-note">
Expand Down
50 changes: 50 additions & 0 deletions src/components/StatusBadge.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
letter-spacing: 0.03em;
}

/* Pre-submission states share a neutral/blue family; in-flight states are
amber; terminal states are green (settled), red (failed) or grey (expired). */
.status-quoted {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
}

.status-validating {
background: rgba(56, 189, 248, 0.15);
color: #7dd3fc;
}

.status-authorizing {
background: rgba(168, 85, 247, 0.15);
color: #d8b4fe;
}

.status-pending {
background: rgba(234, 179, 8, 0.15);
color: #fde047;
Expand All @@ -23,7 +40,35 @@
color: #f87171;
}

.status-expired {
background: rgba(148, 163, 184, 0.15);
color: #cbd5e1;
}

/* A status the contract does not recognise. Rendered deliberately plainly so
it reads as "unexpected" rather than as one of the known states. */
.status-unknown {
background: rgba(148, 163, 184, 0.12);
color: #94a3b8;
border: 1px dashed #64748b;
}

@media (prefers-contrast: more) {
.status-quoted {
background: rgba(99, 102, 241, 0.35);
border: 1px solid #a5b4fc;
}

.status-validating {
background: rgba(56, 189, 248, 0.35);
border: 1px solid #7dd3fc;
}

.status-authorizing {
background: rgba(168, 85, 247, 0.35);
border: 1px solid #d8b4fe;
}

.status-pending {
background: rgba(234, 179, 8, 0.35);
border: 1px solid #fde047;
Expand All @@ -38,4 +83,9 @@
background: rgba(239, 68, 68, 0.35);
border: 1px solid #f87171;
}

.status-expired {
background: rgba(148, 163, 184, 0.35);
border: 1px solid #cbd5e1;
}
}
48 changes: 43 additions & 5 deletions src/components/StatusBadge.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
import { normalizeStatus } from '../services/contracts/transfer.js';
import './StatusBadge.css';

// Human-readable labels for each transfer status.
const LABELS = {
/**
* Human-readable labels for every state in the transfer lifecycle. Exported so
* the transfers filter and the badge cannot drift apart; the contract test
* asserts this covers TRANSFER_STATUSES exactly.
*/
export const TRANSFER_STATUS_LABELS = {
quoted: 'Quoted',
validating: 'Validating',
authorizing: 'Authorizing',
pending: 'Pending',
completed: 'Completed',
failed: 'Failed',
expired: 'Expired',
};

// Short explanations surfaced as a tooltip/accessible description, so a
// colour-coded pill is not the only way to know what a state means.
const DESCRIPTIONS = {
quoted: 'Quote prepared, not yet submitted',
validating: 'Checking recipient and amount',
authorizing: 'Waiting for wallet authorization',
pending: 'Sent, waiting to settle',
completed: 'Funds delivered',
failed: 'Transfer did not go through',
expired: 'Quote expired before authorization',
};

/**
* Colored badge showing a transfer status.
*
* Legacy and provider spellings (`settled`, `submitted`, ...) are normalised
* through the transfer contract. A genuinely unknown status renders as a
* neutral badge with the raw value rather than an unstyled, unlabelled pill.
*
* @param {object} props
* @param {'pending'|'completed'|'failed'} props.status
* @param {string} props.status - a status from TRANSFER_STATUSES or a known alias
*/
export default function StatusBadge({ status }) {
const label = LABELS[status] || status;
return <span className={`status-badge status-${status}`}>{label}</span>;
const canonical = normalizeStatus(status);
const label = canonical
? TRANSFER_STATUS_LABELS[canonical]
: status || 'Unknown';
const description = canonical ? DESCRIPTIONS[canonical] : undefined;

return (
<span
className={`status-badge status-${canonical ?? 'unknown'}`}
title={description}
>
{label}
</span>
);
}
65 changes: 44 additions & 21 deletions src/components/StatusBadge.stories.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import StatusBadge from './StatusBadge.jsx';
import {
TRANSFER_STATUSES,
TRANSFER_STATUS_ALIASES,
} from '../services/contracts/transfer.js';

export default {
title: 'Components/StatusBadge',
Expand All @@ -7,35 +11,54 @@ export default {
argTypes: {
status: {
control: 'select',
options: ['pending', 'completed', 'failed'],
// Driven by the contract so the control cannot drift from the data.
options: [...TRANSFER_STATUSES, ...Object.keys(TRANSFER_STATUS_ALIASES)],
},
},
};

export const Pending = {
args: {
status: 'pending',
},
};

export const Completed = {
args: {
status: 'completed',
},
};
export const Quoted = { args: { status: 'quoted' } };
export const Validating = { args: { status: 'validating' } };
export const Authorizing = { args: { status: 'authorizing' } };
export const Pending = { args: { status: 'pending' } };
export const Completed = { args: { status: 'completed' } };
export const Failed = { args: { status: 'failed' } };
export const Expired = { args: { status: 'expired' } };

export const Failed = {
args: {
status: 'failed',
},
export const AllStatuses = {
render: () => (
<div
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{TRANSFER_STATUSES.map((status) => (
<StatusBadge key={status} status={status} />
))}
</div>
),
};

export const AllStatuses = {
/** Provider spellings the adapter normalises on the way in. */
export const LegacySpellings = {
render: () => (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<StatusBadge status="pending" />
<StatusBadge status="completed" />
<StatusBadge status="failed" />
<div
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{Object.keys(TRANSFER_STATUS_ALIASES).map((alias) => (
<StatusBadge key={alias} status={alias} />
))}
</div>
),
};

/** A status the contract does not know: visibly unexpected, never blank. */
export const UnknownStatus = { args: { status: 'in_flight' } };
11 changes: 7 additions & 4 deletions src/components/TransferRow.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import StatusBadge from './StatusBadge.jsx';
import { formatAmount, formatDate, shortenAddress } from '../utils/format.js';
import { formatDate, shortenAddress } from '../utils/format.js';
import { formatMoney } from '../utils/money.js';
import { DEFAULT_LOCALE } from '../constants/locales.js';
import './TransferRow.css';

/**
* A single row in the transfers list.
* @param {object} props
* @param {object} props.transfer - the transfer record
* @param {object} props.transfer - a contract-normalised transfer record;
* amounts are decimal strings and are rendered with formatMoney so an
* unparseable value shows a placeholder rather than a fabricated 0.00
* @param {string} [props.locale] - locale used for currency/date formatting
* @param {boolean} [props.selected] - whether this row is selected
* @param {Function} [props.onToggleSelect] - called when the checkbox is toggled
Expand Down Expand Up @@ -43,12 +46,12 @@ export default function TransferRow({

<div className="transfer-cell">
<span className="transfer-label">Sent</span>
<span>{formatAmount(sendAmount, from, locale)}</span>
<span>{formatMoney(sendAmount, from, locale)}</span>
</div>

<div className="transfer-cell">
<span className="transfer-label">Received</span>
<span>{formatAmount(receiveAmount, to, locale)}</span>
<span>{formatMoney(receiveAmount, to, locale)}</span>
</div>

<div className="transfer-cell">
Expand Down
14 changes: 12 additions & 2 deletions src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { listTransfers, createTransfer } from '../services/api.js';
import { ContractViolationError } from '../services/contracts/schema.js';

/**
* Hook for loading and creating transfers.
Expand All @@ -17,8 +18,17 @@ export function useTransfers() {
try {
const data = await listTransfers();
setTransfers(data);
} catch {
setError('Could not load transfers. Please try again.');
} catch (err) {
if (err instanceof ContractViolationError) {
// A schema change, not a flaky request. Retrying will not help, and
// showing an empty list would imply the transfers no longer exist.
console.error(err.message);
setError(
`Your transfers could not be displayed: the data did not match the expected format (${err.contract}). Nothing has been lost — please try again shortly.`,
);
} else {
setError('Could not load transfers. Please try again.');
}
} finally {
setLoading(false);
}
Expand Down
27 changes: 25 additions & 2 deletions src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import QuoteCard from '../components/QuoteCard.jsx';
import Button from '../components/Button.jsx';
import ErrorMessage from '../components/ErrorMessage.jsx';
import { buildQuote } from '../services/quote.js';
import { ContractViolationError } from '../services/contracts/schema.js';
import { formatCurrencyInput } from '../utils/format.js';
import {
isPositiveAmount,
Expand Down Expand Up @@ -100,18 +101,40 @@ export default function SendMoney() {

// Build from the live amount so a pending debounce can't submit a stale quote.
const finalQuote = buildQuote(amount, from, to);
if (!finalQuote) return;
if (!finalQuote) {
// Previously this returned silently, leaving the user on an enabled
// button with no explanation of why nothing happened.
setSubmitError(
'We could not price this transfer. Check the amount and the selected currencies.',
);
return;
}

// Record the fee, rate and expiry alongside the amounts so the receipt
// can reproduce exactly what was quoted rather than re-deriving it from
// a rate that may since have moved.
await addTransfer({
recipient,
from,
to,
sendAmount: finalQuote.sendAmount,
receiveAmount: finalQuote.receiveAmount,
fee: finalQuote.fee,
rate: finalQuote.rate,
expiresAt: finalQuote.expiresAt,
});
navigate('/transfers');
} catch (err) {
setSubmitError('Could not submit the transfer. Please try again.');
if (err instanceof ContractViolationError) {
// The full field-by-field diff goes to the console; the user gets a
// message that distinguishes "we rejected this" from "try again".
console.error(err.message);
setSubmitError(
'This transfer was rejected before it was sent because the details did not match the expected format. Nothing was submitted.',
);
} else {
setSubmitError('Could not submit the transfer. Please try again.');
}
} finally {
submissionLock.current = false;
setSubmitting(false);
Expand Down
Loading