Currency Manager

PreviousNext

A currency manager component

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/currency-manager/currency-manager.tsx
"use client"

import { createContext, useContext, useState, ReactNode, useEffect, ComponentType, useCallback } from 'react'
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Loader, Check } from 'lucide-react'
import { cn } from '@/lib/utils'

export type Currency = {
  code: string
  name: string
}

const CURRENCY_NAMES_API_URL = "https://cdn.jsdelivr.net/npm/@fgrreloaded/currencies@latest/v1/currencies.json";

const CURRENCY_CACHE_KEY = 'rigidui_currency_names';
const SELECTED_CURRENCY_KEY = 'rigidui_selected_currency';
const RATES_CACHE_KEY = 'rigidui_exchange_rates';
const CURRENCY_CACHE_DURATION = 24 * 60 * 60 * 1000;

interface CachedRates {
  rates: Record<string, number>;
  timestamp: number;
  baseCurrency: string;
}

interface CachedCurrencies {
  data: Currency[];
  timestamp: number;
}

const safeLocalStorage = {
  getItem: (key: string): string | null => {
    try {
      return typeof window !== 'undefined' ? localStorage.getItem(key) : null;
    } catch {
      return null;
    }
  },
  setItem: (key: string, value: string): void => {
    try {
      if (typeof window !== 'undefined') {
        localStorage.setItem(key, value);
      }
    } catch {
    }
  },
  removeItem: (key: string): void => {
    try {
      if (typeof window !== 'undefined') {
        localStorage.removeItem(key);
      }
    } catch {
    }
  }
};

const getCachedCurrencies = (): Currency[] | null => {
  try {
    const cached = safeLocalStorage.getItem(CURRENCY_CACHE_KEY);
    if (!cached) return null;

    const { data, timestamp }: CachedCurrencies = JSON.parse(cached);
    if (Date.now() - timestamp > CURRENCY_CACHE_DURATION) {
      safeLocalStorage.removeItem(CURRENCY_CACHE_KEY);
      return null;
    }
    return data;
  } catch {
    return null;
  }
};

const setCachedCurrencies = (currencies: Currency[]) => {
  try {
    safeLocalStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({
      data: currencies,
      timestamp: Date.now()
    }));
  } catch {
  }
};

const getStoredCurrency = (): string | null => {
  return safeLocalStorage.getItem(SELECTED_CURRENCY_KEY);
};

const setStoredCurrency = (currencyCode: string) => {
  safeLocalStorage.setItem(SELECTED_CURRENCY_KEY, currencyCode);
};

const getCachedRates = (baseCurrency: string): { rates: Record<string, number>, timestamp: number } | null => {
  try {
    const cached = safeLocalStorage.getItem(RATES_CACHE_KEY);
    if (!cached) return null;

    const { rates, timestamp, baseCurrency: cachedBase }: CachedRates = JSON.parse(cached);

    if (cachedBase !== baseCurrency) return null;

    return { rates, timestamp };
  } catch {
    return null;
  }
};

const setCachedRates = (rates: Record<string, number>, baseCurrency: string) => {
  try {
    safeLocalStorage.setItem(RATES_CACHE_KEY, JSON.stringify({
      rates,
      timestamp: Date.now(),
      baseCurrency
    }));
  } catch {
  }
};

const shouldRefetchRates = (refetchIntervalMs?: number, lastFetchTimestamp?: number): boolean => {
  if (!refetchIntervalMs || !lastFetchTimestamp) return true;
  return Date.now() - lastFetchTimestamp > refetchIntervalMs;
};

const DefaultLoader = () => <Loader className='w-4 h-4 animate-spin dark:text-gray-300' />;

type CurrencyContextType = {
  currency: Currency | undefined
  setCurrency: (currency: Currency) => void
  formatValue: (value: number) => string
  convertValue: (value: number, fromCurrencyCode?: string) => number
  rates: Record<string, number>
  loadingRates: boolean
  ratesError: string | null
  LoaderComponent: ComponentType
  availableCurrencies: Currency[]
  loadingCurrencies: boolean
  currenciesError: string | null
  fixedBaseCurrencyCode: string
  lastRatesUpdate: number | null
}

const CurrencyContext = createContext<CurrencyContextType | undefined>(undefined)

interface CurrencyProviderProps {
  children: ReactNode;
  loaderComponent?: ComponentType;
  fixedBaseCurrencyCode: string;
  initialRates?: Record<string, number>;
  fetchRatesFunction?: () => Promise<Record<string, number>>;
  refetchIntervalMs?: number;
  defaultSelectedCurrencyCode?: string;
}

export function CurrencyProvider({
  children,
  loaderComponent = DefaultLoader,
  fixedBaseCurrencyCode,
  initialRates,
  fetchRatesFunction,
  refetchIntervalMs,
  defaultSelectedCurrencyCode = "inr",
}: CurrencyProviderProps) {
  const [availableCurrencies, setAvailableCurrencies] = useState<Currency[]>([])
  const [loadingCurrencies, setLoadingCurrencies] = useState(true);
  const [currenciesError, setCurrenciesError] = useState<string | null>(null);

  const [currency, setCurrency] = useState<Currency | undefined>(undefined)
  const [rates, setRates] = useState<Record<string, number>>(initialRates || {})
  const [loadingRates, setLoadingRates] = useState(false)
  const [ratesError, setRatesError] = useState<string | null>(null)
  const [lastRatesUpdate, setLastRatesUpdate] = useState<number | null>(null)

  useEffect(() => {
    const fetchCurrencyNames = async () => {
      setLoadingCurrencies(true);
      setCurrenciesError(null);

      const cachedCurrencies = getCachedCurrencies();
      if (cachedCurrencies && cachedCurrencies.length > 0) {
        setAvailableCurrencies(cachedCurrencies);

        const storedCurrencyCode = getStoredCurrency();
        const targetCurrency = storedCurrencyCode || defaultSelectedCurrencyCode;
        const selectedCurrency = cachedCurrencies.find(c => c.code === targetCurrency) || cachedCurrencies[0];
        setCurrency(selectedCurrency);

        setLoadingCurrencies(false);
        return;
      }

      try {
        const response = await fetch(CURRENCY_NAMES_API_URL);
        if (!response.ok) {
          throw new Error(`Failed to fetch currency names: ${response.statusText}`);
        }
        const data: Record<string, string> = await response.json();
        const loadedCurrencies: Currency[] = Object.entries(data)
          .map(([code, name]) => ({ code, name }))
          .sort((a, b) => a.name.localeCompare(b.name));

        setAvailableCurrencies(loadedCurrencies);
        setCachedCurrencies(loadedCurrencies);

        if (loadedCurrencies.length > 0) {
          const storedCurrencyCode = getStoredCurrency();
          const targetCurrency = storedCurrencyCode || defaultSelectedCurrencyCode;
          const selectedCurrency = loadedCurrencies.find(c => c.code === targetCurrency) || loadedCurrencies[0];
          setCurrency(selectedCurrency);
        } else {
          setCurrency(undefined);
        }

      } catch (err) {
        console.error("Currency names API error:", err);
        setCurrenciesError(err instanceof Error ? err.message : "Failed to fetch currency names");
        setAvailableCurrencies([]);
        setCurrency(undefined);
      } finally {
        setLoadingCurrencies(false);
      }
    };

    fetchCurrencyNames();
  }, [defaultSelectedCurrencyCode]);


  const handleFetchRates = useCallback(async () => {
    const cachedData = getCachedRates(fixedBaseCurrencyCode);
    if (cachedData && !shouldRefetchRates(refetchIntervalMs, cachedData.timestamp)) {
      setRates(cachedData.rates);
      setLastRatesUpdate(cachedData.timestamp);
      return;
    }

    if (!fetchRatesFunction) {
      if (initialRates) {
        setRates(initialRates);
        setCachedRates(initialRates, fixedBaseCurrencyCode);
        setLastRatesUpdate(Date.now());
      }
      return;
    }

    setLoadingRates(true);
    setRatesError(null);
    try {
      const newRates = await fetchRatesFunction();
      const timestamp = Date.now();
      setRates(newRates);
      setLastRatesUpdate(timestamp);
      setCachedRates(newRates, fixedBaseCurrencyCode);
      console.log(`Fetched new rates relative to base: ${fixedBaseCurrencyCode}`, newRates);
    } catch (err) {
      console.error("User provided fetchRatesFunction error:", err);
      setRatesError(err instanceof Error ? err.message : "Failed to fetch exchange rates via provided function");

      if (cachedData) {
        setRates(cachedData.rates);
        setLastRatesUpdate(cachedData.timestamp);
        console.log("Using cached rates as fallback");
      }
    } finally {
      setLoadingRates(false);
    }
  }, [fetchRatesFunction, fixedBaseCurrencyCode, initialRates, refetchIntervalMs]);

  useEffect(() => {
    handleFetchRates();

    if (fetchRatesFunction && refetchIntervalMs && refetchIntervalMs > 0) {
      const intervalId = setInterval(() => {
        if (shouldRefetchRates(refetchIntervalMs, lastRatesUpdate ?? undefined)) {
          handleFetchRates();
        }
      }, refetchIntervalMs);
      return () => clearInterval(intervalId);
    }
  }, [handleFetchRates, refetchIntervalMs, fetchRatesFunction, lastRatesUpdate]);


  const convertValue = useCallback((value: number, fromCurrencyCode?: string) => {
    if (!currency || Object.keys(rates).length === 0) return value;

    const toCurrencyCode = currency.code;
    const sourceCurrencyCode = fromCurrencyCode || fixedBaseCurrencyCode;

    if (sourceCurrencyCode === toCurrencyCode) return value;

    const effectiveRates = { ...rates, [fixedBaseCurrencyCode]: 1.0 };

    const fromRate = effectiveRates[sourceCurrencyCode];
    const toRate = effectiveRates[toCurrencyCode];

    if (typeof fromRate !== 'number' || typeof toRate !== 'number') {
      console.warn(`Cannot convert: Missing rate for ${sourceCurrencyCode} or ${toCurrencyCode}. Rates available:`, effectiveRates);
      return value;
    }

    const valueInBase = value / fromRate;
    return valueInBase * toRate;

  }, [currency, rates, fixedBaseCurrencyCode]);


  const formatValue = (value: number) => {
    if (!currency) return String(value);
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: currency.code,
      currencyDisplay: 'symbol',
    }).format(value)
  }

  const handleCurrencyChange = (newCurrency: Currency) => {
    setCurrency(newCurrency);
    setStoredCurrency(newCurrency.code);
  };

  const LoaderToShow = loaderComponent;

  if (loadingCurrencies) {
    return <div className="flex justify-center items-center min-h-[200px] p-4"><LoaderToShow /> <span className="ml-2">Loading currencies...</span></div>;
  }

  if (currenciesError) {
    return <div className="text-red-500 p-4">Error loading currencies: {currenciesError}</div>;
  }

  if (ratesError && Object.keys(rates).length === 0) {
    return (
      <div className="bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
        <p className="text-yellow-800 dark:text-yellow-200">
          <strong>Warning:</strong> Failed to fetch exchange rates: {ratesError}
          {initialRates && Object.keys(initialRates).length > 0 && (
            <span className="block mt-1 text-sm">Using initial rates as fallback.</span>
          )}
        </p>
      </div>
    );
  }

  return (
    <CurrencyContext.Provider value={{
      currency,
      setCurrency: handleCurrencyChange,
      formatValue,
      convertValue,
      rates,
      loadingRates,
      ratesError,
      LoaderComponent: loaderComponent,
      availableCurrencies,
      loadingCurrencies,
      currenciesError,
      fixedBaseCurrencyCode,
      lastRatesUpdate,
    }}>
      {children}
    </CurrencyContext.Provider>
  )
}

function useCurrency() {
  const context = useContext(CurrencyContext)
  if (context === undefined) {
    throw new Error('useCurrency must be used within a CurrencyProvider')
  }
  return context
}

export function CurrencySelector({ className }: { className?: string }) {
  const { currency, setCurrency, loadingRates, availableCurrencies, loadingCurrencies } = useCurrency()
  const [open, setOpen] = useState(false)

  if (loadingCurrencies || !currency) {
    return (
      <Button
        variant="outline"
        role="combobox"
        disabled={true}
        className={cn("w-[200px] justify-between", className)}
      >
        Loading currencies...
        <Loader className="ml-2 h-4 w-4 animate-spin" />
      </Button>
    )
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className={cn("w-fit justify-between", className)}
          disabled={loadingRates || availableCurrencies.length === 0}
        >
          {currency ? `${currency.code} - ${currency.name.slice(0, 20)}` : "Select currency..."}
          {/* <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> */}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[300px] p-0">
        <Command>
          <CommandInput placeholder="Search currencies..." className="h-9" />
          <CommandList>
            <CommandEmpty>No currency found.</CommandEmpty>
            <CommandGroup>
              {availableCurrencies.map((c) => (
                <CommandItem
                  key={c.code}
                  value={`${c.code} ${c.name}`}
                  onSelect={() => {
                    setCurrency(c)
                    setOpen(false)
                  }}
                >
                  <div className="flex flex-col">
                    <span className="font-medium">{c.code}</span>
                    <span className="text-sm text-muted-foreground">{c.name}</span>
                  </div>
                  <Check
                    className={cn(
                      "ml-auto h-4 w-4",
                      currency?.code === c.code ? "opacity-100" : "opacity-0"
                    )}
                  />
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

interface CurrencyDisplayProps {
  value: number
  className?: string
  sourceCurrency?: string
}

export function CurrencyDisplay({
  value,
  className,
  sourceCurrency
}: CurrencyDisplayProps) {
  const {
    formatValue,
    convertValue,
    loadingRates,
    LoaderComponent,
    currency,
    fixedBaseCurrencyCode
  } = useCurrency()

  const actualSourceCurrency = sourceCurrency || fixedBaseCurrencyCode;

  const convertedValue = currency ? convertValue(value, actualSourceCurrency) : value;

  return (
    <span className={cn("dark:text-gray-100", className)}>
      {loadingRates ? <span className='inline dark:text-gray-300'><LoaderComponent /></span> : formatValue(convertedValue)}
    </span>
  );
}

Installation

npx shadcn@latest add @rigidui/currency-manager

Usage

import { CurrencyManager } from "@/components/currency-manager"
<CurrencyManager />