Skip to content
Merged
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
81 changes: 10 additions & 71 deletions components/escrow/FiatXlmPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@

import React, { useEffect } from 'react';
import {
AlertTriangle,
AlertCircle,
TrendingDown,
TrendingUp,
Loader2,
CheckCircle2,
} from 'lucide-react';
import { useFiatXlmSlippage } from '@/hooks/useFiatXlmSlippage';
import { formatNgn } from '@/services/fxService';
import { SlippageWarning } from '@/components/escrow/SlippageWarning';

export interface FiatXlmPreviewProps {
/** The XLM amount being paid */
Expand Down Expand Up @@ -184,67 +183,15 @@ export function FiatXlmPreview({
)}
</div>

{/* Warning Section — volatile slippage (>2%) */}
{slippage.isVolatile && !slippage.requiresAcknowledgment && (
<div
role="alert"
className="mb-4 flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20"
>
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-500" aria-hidden="true" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-semibold">FX Rate Volatility Detected</p>
<p className="mt-1 text-xs">
The NGN/XLM rate has shifted{' '}
<span className="font-semibold">
{Math.abs(slippagePercent).toFixed(2)}%
</span>
{' '}since your quote. Please review the updated rate.
</p>
</div>
</div>
)}

{/* Critical Section — critical slippage (>5%) */}
{slippage.requiresAcknowledgment && (
<div
role="alert"
className="mb-4 flex gap-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
>
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400" aria-hidden="true" />
<div className="flex-1 text-sm text-red-800 dark:text-red-200">
<p className="font-semibold">⚠️ High FX Rate Volatility</p>
<p className="mt-1 text-xs">
The NGN/XLM rate has shifted more than{' '}
<span className="font-semibold">
{Math.abs(slippagePercent).toFixed(2)}%
</span>
. This is a significant change. You must acknowledge this volatility
before proceeding.
</p>

{/* Acknowledgment Checkbox */}
<div className="mt-3 flex items-start gap-2">
<input
id="volatility-ack"
type="checkbox"
checked={slippage.isAcknowledged}
onChange={(e) => slippage.setAcknowledged(e.target.checked)}
disabled={isSubmitting}
className="mt-1 h-4 w-4 cursor-pointer rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Acknowledge FX rate volatility"
/>
<label
htmlFor="volatility-ack"
className={`text-xs cursor-pointer select-none ${
isSubmitting ? 'cursor-not-allowed opacity-50' : ''
}`}
>
I acknowledge the rate volatility and accept the current rate
</label>
</div>
</div>
</div>
)}
{/* FX rate volatility warning — see SlippageWarning for the 2%/5% threshold UI */}
<SlippageWarning
slippagePercent={slippagePercent}
isVolatile={slippage.isVolatile}
requiresAcknowledgment={slippage.requiresAcknowledgment}
isAcknowledged={slippage.isAcknowledged}
onAcknowledgeChange={slippage.setAcknowledged}
isSubmitting={isSubmitting}
/>

{/* Loading State */}
{slippage.isLoadingRate && (
Expand All @@ -254,14 +201,6 @@ export function FiatXlmPreview({
</div>
)}

{/* Success State */}
{slippage.isAcknowledged && slippage.requiresAcknowledgment && (
<div className="mb-4 flex items-center gap-2 rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
Volatility acknowledged. Ready to proceed.
</div>
)}

{/* Action Buttons */}
<div className="flex gap-3">
<button
Expand Down
113 changes: 113 additions & 0 deletions components/escrow/SlippageWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

/**
* SlippageWarning — standalone FX rate volatility warning UI.
*
* Presentational component covering the three slippage states:
* - Volatile (>2%): non-blocking advisory banner.
* - Critical (>5%): blocking banner with a required acknowledgment checkbox.
* - Acknowledged: confirmation that the user accepted the critical variance.
*
* Kept separate from FiatXlmPreview so any escrow flow that quotes a
* fiat/XLM rate can reuse the same warning UI and thresholds.
*
* Architecture: SlippageWarning (Component) → useFiatXlmSlippage (Hook) →
* fiatXlmSlippageService → fxService → Backend
*/

import React from 'react';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';

export interface SlippageWarningProps {
/** Percentage change of the current rate against the quoted rate */
slippagePercent: number;
/** True if slippage magnitude exceeds the 2% volatility threshold */
isVolatile: boolean;
/** True if slippage magnitude exceeds the 5% critical threshold */
requiresAcknowledgment: boolean;
/** Whether the user has ticked the acknowledgment checkbox */
isAcknowledged: boolean;
/** Called with the new checked state when the checkbox is toggled */
onAcknowledgeChange: (acknowledged: boolean) => void;
/** Disables the checkbox while a submission is in flight */
isSubmitting?: boolean;
}

export function SlippageWarning({
slippagePercent,
isVolatile,
requiresAcknowledgment,
isAcknowledged,
onAcknowledgeChange,
isSubmitting = false,
}: SlippageWarningProps) {
if (!isVolatile && !requiresAcknowledgment) {
return null;
}

const magnitude = Math.abs(slippagePercent).toFixed(2);

if (requiresAcknowledgment) {
return (
<div
role="alert"
className="mb-4 flex gap-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
>
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400" aria-hidden="true" />
<div className="flex-1 text-sm text-red-800 dark:text-red-200">
<p className="font-semibold">High FX Rate Volatility</p>
<p className="mt-1 text-xs">
The NGN/XLM rate has shifted more than{' '}
<span className="font-semibold">{magnitude}%</span>. This is a
significant change. You must acknowledge this volatility before
proceeding.
</p>

<div className="mt-3 flex items-start gap-2">
<input
id="volatility-ack"
type="checkbox"
checked={isAcknowledged}
onChange={(e) => onAcknowledgeChange(e.target.checked)}
disabled={isSubmitting}
className="mt-1 h-4 w-4 cursor-pointer rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Acknowledge FX rate volatility"
/>
<label
htmlFor="volatility-ack"
className={`text-xs cursor-pointer select-none ${
isSubmitting ? 'cursor-not-allowed opacity-50' : ''
}`}
>
I acknowledge the rate volatility and accept the current rate
</label>
</div>

{isAcknowledged && (
<div className="mt-3 flex items-center gap-2 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
Volatility acknowledged. Ready to proceed.
</div>
)}
</div>
</div>
);
}

return (
<div
role="alert"
className="mb-4 flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20"
>
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-500" aria-hidden="true" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-semibold">FX Rate Volatility Detected</p>
<p className="mt-1 text-xs">
The NGN/XLM rate has shifted{' '}
<span className="font-semibold">{magnitude}%</span> since your
quote. Please review the updated rate.
</p>
</div>
</div>
);
}
111 changes: 111 additions & 0 deletions components/escrow/__tests__/SlippageWarning.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Tests for SlippageWarning
*
* Covers:
* - No render when slippage is within normal range
* - Advisory banner for volatile slippage (>2%)
* - Blocking banner + checkbox for critical slippage (>5%)
* - Acknowledgment callback wiring
* - Success state once acknowledged
*/

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { SlippageWarning } from '@/components/escrow/SlippageWarning';

describe('SlippageWarning', () => {
it('renders nothing when slippage is within the normal range', () => {
render(
<SlippageWarning
slippagePercent={0.5}
isVolatile={false}
requiresAcknowledgment={false}
isAcknowledged={false}
onAcknowledgeChange={jest.fn()}
/>
);

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('shows the volatility advisory banner when slippage exceeds 2% but not 5%', () => {
render(
<SlippageWarning
slippagePercent={3.14}
isVolatile={true}
requiresAcknowledgment={false}
isAcknowledged={false}
onAcknowledgeChange={jest.fn()}
/>
);

expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/FX Rate Volatility Detected/i)).toBeInTheDocument();
expect(screen.getByText(/3\.14%/)).toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});

it('shows the blocking critical banner with a checkbox when slippage exceeds 5%', () => {
render(
<SlippageWarning
slippagePercent={6.2}
isVolatile={true}
requiresAcknowledgment={true}
isAcknowledged={false}
onAcknowledgeChange={jest.fn()}
/>
);

expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/High FX Rate Volatility/i)).toBeInTheDocument();
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(false);
});

it('invokes onAcknowledgeChange with the new checked state when toggled', () => {
const onAcknowledgeChange = jest.fn();
render(
<SlippageWarning
slippagePercent={6.2}
isVolatile={true}
requiresAcknowledgment={true}
isAcknowledged={false}
onAcknowledgeChange={onAcknowledgeChange}
/>
);

fireEvent.click(screen.getByRole('checkbox'));

expect(onAcknowledgeChange).toHaveBeenCalledWith(true);
});

it('disables the checkbox while a submission is in flight', () => {
render(
<SlippageWarning
slippagePercent={6.2}
isVolatile={true}
requiresAcknowledgment={true}
isAcknowledged={false}
onAcknowledgeChange={jest.fn()}
isSubmitting={true}
/>
);

expect(screen.getByRole('checkbox')).toBeDisabled();
});

it('shows the acknowledged confirmation once the checkbox is checked', () => {
render(
<SlippageWarning
slippagePercent={6.2}
isVolatile={true}
requiresAcknowledgment={true}
isAcknowledged={true}
onAcknowledgeChange={jest.fn()}
/>
);

expect(screen.getByText(/Volatility acknowledged/i)).toBeInTheDocument();
});
});
Loading