diff --git a/components/escrow/FiatXlmPreview.tsx b/components/escrow/FiatXlmPreview.tsx index 26b2e58..30923a3 100644 --- a/components/escrow/FiatXlmPreview.tsx +++ b/components/escrow/FiatXlmPreview.tsx @@ -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'; @@ -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'; @@ -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(() => { @@ -117,6 +124,20 @@ export function FiatXlmPreview({ + {/* Localized fiat equivalents — updates instantly as xlmAmount changes */} + {localizedFiat.equivalents.some((eq) => eq.formatted) && ( +
+ {localizedFiat.equivalents.map((eq) => + eq.formatted ? ( + + ≈ {eq.formatted}{' '} + ({eq.fiatCode}) + + ) : null, + )} +
+ )} + {/* Quoted NGN */}
diff --git a/components/fleet/DriverReputation.tsx b/components/fleet/DriverReputation.tsx index 8066041..41bf13c 100644 --- a/components/fleet/DriverReputation.tsx +++ b/components/fleet/DriverReputation.tsx @@ -97,10 +97,11 @@ export function DriverReputation({
) : onChainScore && onChainScore > 0 ? (
+ Verified On-Chain {onChainScore.toLocaleString()}
) : null} diff --git a/components/layout/Breadcrumbs.tsx b/components/layout/Breadcrumbs.tsx index f2b2528..606a933 100644 --- a/components/layout/Breadcrumbs.tsx +++ b/components/layout/Breadcrumbs.tsx @@ -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 ( - - ); -}; +export const Breadcrumbs = BreadcrumbNav; export default Breadcrumbs; diff --git a/components/shared/BreadcrumbNav.tsx b/components/shared/BreadcrumbNav.tsx index 6195dcf..ded9333 100644 --- a/components/shared/BreadcrumbNav.tsx +++ b/components/shared/BreadcrumbNav.tsx @@ -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 ; + const breadcrumbs = useBreadcrumbs(); + + if (breadcrumbs.length <= 1) return null; + + return ( + + ); }; export default BreadcrumbNav; diff --git a/hooks/useDriverReputation.ts b/hooks/useDriverReputation.ts index c17c92e..e70e2d1 100644 --- a/hooks/useDriverReputation.ts +++ b/hooks/useDriverReputation.ts @@ -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, - }; -}; \ No newline at end of file +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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/hooks/useLocalizedFiatPreview.ts b/hooks/useLocalizedFiatPreview.ts new file mode 100644 index 0000000..0fa8d47 --- /dev/null +++ b/hooks/useLocalizedFiatPreview.ts @@ -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, + }; +} diff --git a/services/localizedFxService.ts b/services/localizedFxService.ts new file mode 100644 index 0000000..c2eea1c --- /dev/null +++ b/services/localizedFxService.ts @@ -0,0 +1,88 @@ +/** + * localizedFxService — Service layer for multi-currency (NGN + USD) live FX rates. + * + * Wraps currencyRateService with a small in-memory TTL cache keyed per fiat + * code, and exposes an Intl.NumberFormat-based formatter so the hook/component + * layer never instantiates formatters directly. + * + * Architecture: Component -> useLocalizedFiatPreview (Hook) -> + * localizedFxService -> currencyRateService -> Backend + */ + +import { currencyRateService } from '@/services/currencyRateService'; + +export type SupportedFiatCode = 'NGN' | 'USD'; + +export const SUPPORTED_FIAT_CODES: SupportedFiatCode[] = ['NGN', 'USD']; + +export interface LocalizedRate { + fiatCode: SupportedFiatCode; + ratePerXlm: number; + updatedAt: string; +} + +const CACHE_TTL_MS = 60_000; // 60 seconds — balance between freshness and API load + +interface CacheEntry { + data: LocalizedRate; + expiresAt: number; +} + +const cache = new Map(); + +const FORMATTERS: Record = { + NGN: new Intl.NumberFormat('en-NG', { + style: 'currency', + currency: 'NGN', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + USD: new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), +}; + +/** + * Formats an amount as a localized currency string. + * e.g. formatLocalizedAmount(1234.5, 'NGN') -> "₦1,234.50" + */ +export function formatLocalizedAmount(amount: number, fiatCode: SupportedFiatCode): string { + return FORMATTERS[fiatCode].format(amount); +} + +export const localizedFxService = { + /** + * Returns the current XLM rate for the given fiat currency. + * Hits the in-memory cache for up to `CACHE_TTL_MS` before re-fetching. + */ + async getRate(fiatCode: SupportedFiatCode): Promise { + const now = Date.now(); + + const cached = cache.get(fiatCode); + if (cached && now < cached.expiresAt) { + return cached.data; + } + + const response = await currencyRateService.getXlmRate(fiatCode); + + const entry: LocalizedRate = { + fiatCode, + ratePerXlm: response.xlmRate, + updatedAt: response.updatedAt, + }; + + cache.set(fiatCode, { data: entry, expiresAt: now + CACHE_TTL_MS }); + + return entry; + }, + + /** + * Clears the in-memory cache — useful for testing or forced refresh. + */ + clearCache(): void { + cache.clear(); + }, +}; diff --git a/services/reputationService.ts b/services/reputationService.ts new file mode 100644 index 0000000..2b539c6 --- /dev/null +++ b/services/reputationService.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +export interface DriverReputationData { + driverId: string; + onChainScore: number; + updatedAt: string; +} + +/** + * reputationService — fetches a driver's tokenized on-chain reputation. + * + * The backend resolves this by querying the Soroban RPC network for the + * driver's reputation token state; the frontend only ever talks to the + * backend API, never the RPC endpoint directly. + * + * Hooks call this; components never call this directly. + */ +export const reputationService = { + async getDriverReputation( + driverId: string, + signal?: AbortSignal, + ): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/fleet/drivers/${driverId}/reputation`, + { signal }, + ); + return data; + }, +};