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
21 changes: 21 additions & 0 deletions components/escrow/FiatXlmPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
*
* The component blocks submission if critical slippage is detected but not acknowledged.
*
* Also renders live, localized NGN/USD equivalents below the XLM total,
* which update instantly whenever the caller's xlmAmount changes.
*
* Architecture: FiatXlmPreview (Component) → useFiatXlmSlippage (Hook) →
* fiatXlmSlippageService → fxService → Backend
* → useLocalizedFiatPreview (Hook) → localizedFxService →
* currencyRateService → Backend
*/

import React, { useEffect } from 'react';
Expand All @@ -21,6 +26,7 @@ import {
Loader2,
} from 'lucide-react';
import { useFiatXlmSlippage } from '@/hooks/useFiatXlmSlippage';
import { useLocalizedFiatPreview } from '@/hooks/useLocalizedFiatPreview';
import { formatNgn } from '@/services/fxService';
import { SlippageWarning } from '@/components/escrow/SlippageWarning';

Expand Down Expand Up @@ -53,6 +59,7 @@ export function FiatXlmPreview({
cancelLabel = 'Cancel',
}: FiatXlmPreviewProps) {
const slippage = useFiatXlmSlippage();
const localizedFiat = useLocalizedFiatPreview(xlmAmount);

// Initialize quote tracking when component mounts or rate changes
useEffect(() => {
Expand Down Expand Up @@ -117,6 +124,20 @@ export function FiatXlmPreview({
</span>
</div>

{/* Localized fiat equivalents — updates instantly as xlmAmount changes */}
{localizedFiat.equivalents.some((eq) => eq.formatted) && (
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
{localizedFiat.equivalents.map((eq) =>
eq.formatted ? (
<span key={eq.fiatCode}>
≈ {eq.formatted}{' '}
<span className="text-gray-400 dark:text-gray-500">({eq.fiatCode})</span>
</span>
) : null,
)}
</div>
)}

{/* Quoted NGN */}
<div className="flex items-center justify-between border-t border-gray-200 pt-2 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">
Expand Down
3 changes: 2 additions & 1 deletion components/fleet/DriverReputation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ export function DriverReputation({
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-200" />
) : onChainScore && onChainScore > 0 ? (
<div
className="flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700"
className="flex items-center gap-1.5 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700"
title="Verified On-Chain Reputation Score"
>
<ShieldCheckIcon className="h-3.5 w-3.5 text-blue-500" />
<span className="font-semibold uppercase tracking-wide">Verified On-Chain</span>
<span>{onChainScore.toLocaleString()}</span>
</div>
) : null}
Expand Down
53 changes: 4 additions & 49 deletions components/layout/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,9 @@
'use client';

import React from 'react';
import Link from 'next/link';
import { ChevronRight, Home } from 'lucide-react';
import { useBreadcrumbs } from '@/hooks/useBreadcrumbs';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// The dynamic breadcrumb implementation now lives in components/shared/BreadcrumbNav.
// Re-exported here under its original name/path for backward compatibility.
import { BreadcrumbNav } from '@/components/shared/BreadcrumbNav';

function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export const Breadcrumbs: React.FC = () => {
const breadcrumbs = useBreadcrumbs();

if (breadcrumbs.length <= 1) return null;

return (
<nav
aria-label="Breadcrumb"
className="mb-6 flex items-center space-x-2 text-sm font-medium"
>
<ol className="flex items-center space-x-2">
{breadcrumbs.map((breadcrumb, index) => (
<li key={breadcrumb.href} className="flex items-center">
{index !== 0 && (
<ChevronRight className="mx-2 h-4 w-4 text-slate-400 shrink-0" />
)}

{breadcrumb.isLast ? (
<span className="text-indigo-600 font-semibold truncate max-w-[200px]">
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className={cn(
"flex items-center text-slate-500 hover:text-indigo-600 transition-colors duration-200",
index === 0 && "hover:bg-slate-100 p-1 rounded-md"
)}
>
{index === 0 && <Home className="mr-1 h-4 w-4" />}
{breadcrumb.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
};
export const Breadcrumbs = BreadcrumbNav;

export default Breadcrumbs;
60 changes: 57 additions & 3 deletions components/shared/BreadcrumbNav.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,65 @@
'use client';

/**
* BreadcrumbNav — Dynamic breadcrumb navigation.
*
* Intercepts the current router pathname and renders a human-readable
* trail, mapping raw UUID/numeric slugs (e.g. /escrow/123) to friendly
* labels (e.g. "Escrow > View Contract") instead of showing the raw URL.
*
* Architecture: BreadcrumbNav (Component) -> useBreadcrumbs (Hook) ->
* breadcrumbService -> Backend
*/

import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import Link from 'next/link';
import { ChevronRight, Home } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { useBreadcrumbs } from '@/hooks/useBreadcrumbs';

function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

// Simple shared wrapper to expose a breadcrumb component from shared folder
export const BreadcrumbNav: React.FC = () => {
return <Breadcrumbs />;
const breadcrumbs = useBreadcrumbs();

if (breadcrumbs.length <= 1) return null;

return (
<nav
aria-label="Breadcrumb"
className="mb-6 flex items-center space-x-2 text-sm font-medium"
>
<ol className="flex items-center space-x-2">
{breadcrumbs.map((breadcrumb, index) => (
<li key={breadcrumb.href} className="flex items-center">
{index !== 0 && (
<ChevronRight className="mx-2 h-4 w-4 shrink-0 text-slate-400" />
)}

{breadcrumb.isLast ? (
<span className="max-w-[200px] truncate font-semibold text-indigo-600">
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className={cn(
'flex items-center text-slate-500 transition-colors duration-200 hover:text-indigo-600',
index === 0 && 'rounded-md p-1 hover:bg-slate-100',
)}
>
{index === 0 && <Home className="mr-1 h-4 w-4" />}
{breadcrumb.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
};

export default BreadcrumbNav;
74 changes: 62 additions & 12 deletions hooks/useDriverReputation.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,64 @@
'use client';

// This is a placeholder. In a real implementation, this would be a proper hook
// following the Component -> Hook -> Service pattern.
// It would use React Query and a service to call the Soroban RPC.
export const useDriverReputation = (driverId: string) => {
// For this demonstration, we return a static score.
console.log(`Fetching on-chain reputation for driver: ${driverId}`);
return {
onChainScore: 1250,
isLoading: false,
error: null,
};
};
import { useEffect, useState } from 'react';
import axios from 'axios';
import { reputationService } from '@/services/reputationService';

export interface UseDriverReputationResult {
onChainScore: number | null;
isLoading: boolean;
error: string | null;
}

/**
* useDriverReputation — fetches a driver's on-chain reputation token score.
*
* Components consume this hook; they never call reputationService directly.
* Aborts in-flight requests on unmount or driverId change to avoid setting
* state on an unmounted component.
*/
export function useDriverReputation(driverId: string): UseDriverReputationResult {
const [onChainScore, setOnChainScore] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!driverId) {
setOnChainScore(null);
setIsLoading(false);
return;
}

const controller = new AbortController();
let cancelled = false;

setIsLoading(true);

reputationService
.getDriverReputation(driverId, controller.signal)
.then((data) => {
if (cancelled) return;
setOnChainScore(data.onChainScore);
setError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
if (axios.isCancel(err)) return;
const message =
err instanceof Error && err.message
? err.message
: 'Failed to load on-chain reputation';
setError(message);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});

return () => {
cancelled = true;
controller.abort();
};
}, [driverId]);

return { onChainScore, isLoading, error };
}
82 changes: 82 additions & 0 deletions hooks/useLocalizedFiatPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* useLocalizedFiatPreview — Hook layer for live NGN/USD equivalents of an XLM amount.
*
* Fetches (and caches) the current NGN and USD rates per XLM, then computes
* the localized fiat equivalents for the given amount on every render so the
* display updates instantly as the caller's XLM amount changes.
*
* Architecture: Component -> useLocalizedFiatPreview (Hook) ->
* localizedFxService -> currencyRateService -> Backend
*/

'use client';

import { useQuery } from '@tanstack/react-query';
import {
localizedFxService,
formatLocalizedAmount,
SUPPORTED_FIAT_CODES,
type SupportedFiatCode,
} from '@/services/localizedFxService';

const REFETCH_INTERVAL_MS = 60_000; // matches the service cache TTL

export interface LocalizedFiatEquivalent {
fiatCode: SupportedFiatCode;
/** Formatted currency string, e.g. "₦25,000.00". Empty string when unavailable. */
formatted: string;
isLoading: boolean;
isError: boolean;
}

export interface UseLocalizedFiatPreviewResult {
equivalents: LocalizedFiatEquivalent[];
isLoading: boolean;
}

/**
* @param xlmAmount - The XLM amount to convert. Non-positive values resolve
* to unavailable equivalents rather than fetching.
*/
export function useLocalizedFiatPreview(xlmAmount: number): UseLocalizedFiatPreviewResult {
const isValidAmount = Number.isFinite(xlmAmount) && xlmAmount > 0;

const ngnQuery = useQuery({
queryKey: ['localized-fx-rate', 'NGN'],
queryFn: () => localizedFxService.getRate('NGN'),
enabled: isValidAmount,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: REFETCH_INTERVAL_MS,
retry: false,
});

const usdQuery = useQuery({
queryKey: ['localized-fx-rate', 'USD'],
queryFn: () => localizedFxService.getRate('USD'),
enabled: isValidAmount,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: REFETCH_INTERVAL_MS,
retry: false,
});

const queriesByFiat = { NGN: ngnQuery, USD: usdQuery } as const;

const equivalents: LocalizedFiatEquivalent[] = SUPPORTED_FIAT_CODES.map((fiatCode) => {
const query = queriesByFiat[fiatCode];
const rate = query.data?.ratePerXlm;
const amount =
isValidAmount && typeof rate === 'number' && rate > 0 ? xlmAmount * rate : null;

return {
fiatCode,
formatted: amount !== null ? formatLocalizedAmount(amount, fiatCode) : '',
isLoading: query.isLoading,
isError: query.isError,
};
});

return {
equivalents,
isLoading: ngnQuery.isLoading || usdQuery.isLoading,
};
}
Loading
Loading