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
91 changes: 90 additions & 1 deletion src/components/QuoteCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,82 @@
color: var(--color-muted);
}

.quote-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0.75rem 0 0;
padding: 0.6rem 0.75rem;
border: 1px solid #1f2a3a;
border-radius: 8px;
font-size: 0.8rem;
line-height: 1.4;
}

.quote-meta dt {
margin: 0;
color: var(--color-muted);
font-weight: 500;
}

.quote-meta dd {
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums;
word-break: break-word;
}

.quote-meta-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem;
}

.quote-expired {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.75rem 0 0;
padding: 0.6rem 0.75rem;
border: 1px solid #b45309;
border-left-width: 4px;
border-radius: 8px;
background: #3a2a12;
color: #fde68a;
font-size: 0.85rem;
line-height: 1.4;
}

.quote-expired-icon {
flex: 0 0 auto;
font-weight: 700;
}

.quote-expired-text {
flex: 1 1 auto;
}

[data-theme='light'] .quote-expired {
border-color: #b45309;
background: #fef3c7;
color: #7c2d12;
}

.quote-invalid-reasons {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
font-size: 0.8rem;
line-height: 1.4;
color: #fca5a5;
}

.quote-invalid-reasons li {
margin: 0.15rem 0;
}

[data-theme='light'] .quote-invalid-reasons {
color: #991b1b;
}

@media (prefers-contrast: more) {
.quote-card {
border-width: 2px;
Expand All @@ -47,4 +123,17 @@
.quote-divider {
height: 2px;
}
}

.quote-meta {
border-width: 2px;
}

.quote-expired {
border-width: 2px;
border-left-width: 6px;
}

.quote-invalid-reasons {
font-weight: 600;
}
}
104 changes: 101 additions & 3 deletions src/components/QuoteCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,121 @@ 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 { isQuoteExpired } from '../utils/quote.js';
import './QuoteCard.css';

/**
* Displays the breakdown of an FX quote: rate, fee and amount received.
* Formats a timestamp (ms since epoch) for display, or a dash when absent.
* @param {number|undefined} timestamp
* @param {string} locale
*/
function formatTimestamp(timestamp, locale) {
if (!Number.isFinite(timestamp)) return '—';
try {
return new Date(timestamp).toLocaleString(locale);
} catch {
return new Date(timestamp).toISOString();
}
}

/**
* Displays the breakdown of an FX quote: rate, fee and amount received,
* plus quote provenance (source, timestamps, currency metadata, canonical
* amount) and any validation warnings.
* @param {object} props
* @param {object} props.quote - quote object from buildQuote()
* @param {string} [props.locale] - locale used for currency formatting
* @param {number} [props.now] - clock used to evaluate expiry (ms since epoch)
* @param {boolean} [props.invalid] - whether validateQuoteForTransfer failed
* @param {string[]} [props.reasons] - validation failure reasons
*/
export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
export default function QuoteCard({
quote,
locale = DEFAULT_LOCALE,
now = Date.now(),
invalid = false,
reasons = [],
}) {
if (!quote) return null;

const { from, to, rate, sendAmount, fee, receiveAmount } = quote;
const {
from,
to,
rate,
sendAmount,
fee,
receiveAmount,
source,
createdAt,
expiresAt,
fromMeta,
toMeta,
canonicalAmount,
} = quote;

const expired = isQuoteExpired(quote, now);
const showWarnings = invalid || expired;

return (
<div className="quote-card">
<h3 className="quote-title">Transfer summary</h3>

<div className="quote-line quote-muted">
<span>Quote source</span>
<span>{source || '—'}</span>
</div>

<div className="quote-line quote-muted">
<span>Created</span>
<span>{formatTimestamp(createdAt, locale)}</span>
</div>

<div
className={`quote-line quote-muted${expired ? ' quote-expired' : ''}`}
>
<span>Expires</span>
<span>
{formatTimestamp(expiresAt, locale)}
{expired ? ' (expired)' : ''}
</span>
</div>

<div className="quote-line quote-muted">
<span>From currency</span>
<span>
{from}
{fromMeta?.name ? ` — ${fromMeta.name}` : ''}
{Number.isFinite(fromMeta?.minorUnits)
? ` (${fromMeta.minorUnits} minor units)`
: ''}
</span>
</div>

<div className="quote-line quote-muted">
<span>To currency</span>
<span>
{to}
{toMeta?.name ? ` — ${toMeta.name}` : ''}
{Number.isFinite(toMeta?.minorUnits)
? ` (${toMeta.minorUnits} minor units)`
: ''}
</span>
</div>

<div className="quote-line quote-muted">
<span>Canonical amount</span>
<span>{canonicalAmount ?? '—'}</span>
</div>

{showWarnings && (
<ul className="quote-warnings" role="alert">
{expired && <li>This quote has expired. Refresh to get a new rate.</li>}
{reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
)}

<div className="quote-line">
<span>You send</span>
<span>{formatMoney(sendAmount, from, locale)}</span>
Expand Down
21 changes: 21 additions & 0 deletions src/constants/currencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const CURRENCIES = [
export const DEFAULT_SOURCE = 'USD';
export const DEFAULT_DEST = 'NGN';

// Flat list of supported ISO currency codes, derived from CURRENCIES.
// Used for currency-matrix validation in the send flow.
export const SUPPORTED_CURRENCY_CODES = CURRENCIES.map((c) => c.code);

// Popular remittance corridors highlighted on the landing page.
export const POPULAR_CORRIDORS = [
{ from: 'USD', to: 'NGN' },
Expand All @@ -52,3 +56,20 @@ export function getCurrency(code) {
export function getCurrencyMinorUnits(code) {
return getCurrency(code)?.minorUnits ?? 2;
}

/**
* Return display metadata for a currency, suitable for rendering in the
* quote card and amount entry fields.
* @param {string} code - ISO currency code, e.g. "USD"
* @returns {{code: string, name: string, symbol: string, minorUnits: number}|undefined}
*/
export function getCurrencyMetadata(code) {
const currency = getCurrency(code);
if (!currency) return undefined;
return {
code: currency.code,
name: currency.name,
symbol: currency.symbol,
minorUnits: currency.minorUnits,
};
}
26 changes: 23 additions & 3 deletions src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,30 @@ export function useTransfers() {
reload();
}, [reload]);

// The payload is forwarded verbatim so the quote ID that priced this
// transfer reaches the backend and binds the transfer to that quote.
const addTransfer = useCallback(async (payload) => {
const created = await createTransfer(payload);
setTransfers((prev) => [created, ...prev]);
return created;
try {
const created = await createTransfer(payload);
setTransfers((prev) => [created, ...prev]);
return created;
} catch (err) {
if (err instanceof ContractViolationError) {
// A missing or mismatched quote ID is a contract violation, not a
// transient failure; surface it through the same non-retryable path
// used when loading transfers.
console.error(err.message);
setError(
`Your transfer could not be created: the quote could not be verified (${err.contract}). Nothing has been sent — please refresh the quote and try again.`,
);
setRetryable(false);
} else {
const normalized = normalizeError(err, { source: 'api' });
setError(getUserErrorMessage(normalized));
setRetryable(normalized.retryable);
}
throw err;
}
}, []);

// Existing consumers use reload for both pull-to-refresh and the error-state
Expand Down
55 changes: 55 additions & 0 deletions src/pages/SendMoney.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
border-style: solid;
border-width: 2px;
}

.send-quote-invalid {
border-width: 2px;
}
}

.send-quote-hint {
Expand All @@ -84,6 +88,57 @@
text-align: center;
}

/* Active quote invalidated (expired or mismatched with current inputs) */
.send-quote-invalid {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.6rem 1rem;
margin-bottom: 1rem;
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.875rem;
}

.send-quote-invalid strong {
color: #fecaca;
}

[data-theme='light'] .send-quote-invalid {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}

[data-theme='light'] .send-quote-invalid strong {
color: #991b1b;
}

/* Expanded quote metadata layout */
.send-quote-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0.75rem 0 0;
font-size: 0.85rem;
}

.send-quote-meta dt {
color: var(--color-muted);
}

.send-quote-meta dd {
margin: 0;
word-break: break-all;
}

.send-quote-meta .send-quote-expiry-expired {
color: #f87171;
font-weight: 600;
}

/* Confirmation / result dialog content */
.send-dialog-summary {
margin: 0 0 0.75rem;
Expand Down
Loading