Currency Converter with HTML, CSS & JavaScript

20 DAYS 20 PROJECT CHALLENGE

Day #13

Project Overview

A small Currency Converter that uses a public exchange-rate API to convert amounts between currencies in real time. The demo fetches the list of available currencies, shows a simple UI (amount, from, to), performs conversion using the API’s convert endpoint, and displays the converted amount + the applied rate and timestamp. This demonstrates fetch()/async/await, DOM updates, basic caching (symbols in localStorage), and error handling for network/API problems.

Key Features

  • Fetches supported currency codes dynamically (and caches them locally).
  • Convert an amount from one currency to another using the API’s convert endpoint (real-time rate).
  • Shows the exchange rate used, converted value, and timestamp/date.
  • Swap currencies, quick-recent pairs, and input validation.
  • Graceful error messages for network/API failures.
  • Small, easy-to-read code you can extend (history, charts, offline caching).

HTML Code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Day 13 — Currency Converter</title>
  <link rel="stylesheet" href="styles.css">
  <!-- Tip: serve this folder over HTTP (Live Server / python -m http.server) — otherwise browser fetch may be blocked. -->
</head>
<body>
  <main class="card" role="main" aria-labelledby="title">
    <header>
      <div>
        <h1 id="title">Day 13: Currency Converter</h1>
        <p class="lead">Tries API first. Falls back to a manual rate & built-in list if the API is unavailable.</p>
      </div>
    </header>

    <section class="converter">
      <div class="row inputs">
        <label class="field">
          Amount
          <input id="amount" type="number" min="0" step="any" value="1" />
        </label>

        <label class="field">
          From
          <select id="fromCurrency" aria-label="From currency"></select>
        </label>

        <label class="field">
          To
          <select id="toCurrency" aria-label="To currency"></select>
        </label>

        <div class="actions">
          <button id="swapBtn" class="btn secondary">Swap</button>
          <button id="convertBtn" class="btn">Convert</button>
        </div>
      </div>

      <div id="result" class="result" aria-live="polite">
        <div class="muted">Result will appear here.</div>
      </div>

      <div id="meta" class="meta muted"></div>
      <div id="error" class="error" role="alert" aria-live="assertive"></div>

      <div class="manual">
        <label class="small">Manual rate (if API fails):</label>
        <input id="manualRate" placeholder="e.g. 0.01234" />
        <button id="applyManual" class="btn secondary">Apply rate</button>
        <button id="testApi" class="btn secondary">Test API</button>
      </div>

      <div class="footer-note small">If you open the file via <code>file://</code> the browser may block requests. Serve with Live Server or <code>python -m http.server</code>.</div>
    </section>
  </main>

  <script src="script.js"></script>
</body>
</html>

CSS Code

:root {
  --bg: #071026;
  --card: #0b1220;
  --accent: #7c3aed;
  --muted: #9aa4b2;
  --white: #e6eef6;
  --danger: #ef4444;
  font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  background: #002252;
  color: var(--white);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 28px;
}

.card {
  width: min(920px, 96%);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
  border-radius: 12px;
  padding: 18px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
  border: 1px solid rgba(255, 255, 255, 0.03);
}

header {
  display: flex;
  gap: 12px;
  align-items: center;
}

.logo {
  width: 48px;
  height: 48px;
  border-radius: 10px;
  display: block;
  object-fit: cover;
}

h1 {
  margin: 0;
  font-size: 18px;
}

.lead {
  margin: 4px 0 12px;
  color: var(--muted);
  font-size: 14px;
}

.converter {
  margin-top: 12px;
}

.row.inputs {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  align-items: end;
}

.field {
  display: flex;
  flex-direction: column;
  min-width: 160px;
}

.field input,
.field select {
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.04);
  background: #000;
  color: #fff;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}

.field input::placeholder {
  color: var(--muted);
}

.field input:focus,
.field select:focus {
  box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.06);
  border-color: rgba(124, 58, 237, 0.6);
}

.actions {
  display: flex;
  gap: 8px;
  align-items: center;
}

.btn {
  padding: 10px 14px;
  border-radius: 10px;
  border: 0;
  background: linear-gradient(90deg, var(--accent), #22c1c3);
  color: white;
  font-weight: 600;
  cursor: pointer;
}

.btn.secondary {
  background: transparent;
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: var(--muted);
}

.result {
  margin-top: 16px;
  padding: 12px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid rgba(255, 255, 255, 0.03);
  min-height: 50px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.result .value {
  font-family: ui-monospace, monospace;
  font-size: 18px;
  font-weight: 700;
}

.meta {
  margin-top: 8px;
  color: var(--muted);
  font-size: 13px;
}

.error {
  margin-top: 8px;
  color: var(--danger);
  font-size: 13px;
  min-height: 18px;
}

.manual {
  margin-top: 12px;
  display: flex;
  gap: 8px;
  align-items: center;
}

.manual input {
  width: 160px;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.04);
  background: transparent;
  color: inherit;
}

.small {
  font-size: 13px;
  color: var(--muted);
}

.footer-note {
  margin-top: 12px;
  color: var(--muted);
  font-size: 13px;
}

/* responsive tweaks */
@media (max-width:720px) {
  .row.inputs {
    align-items: stretch;
  }

  .field {
    min-width: calc(50% - 12px);
  }

  .actions {
    width: 100%;
    justify-content: flex-end;
  }

  .manual {
    flex-direction: column;
    align-items: flex-start;
  }
}

Javascript Code

// script.js — uses ExchangeRate-API v6 (inserted key) with fallback & manual rate
document.addEventListener('DOMContentLoaded', () => {
  // NOTE: keep this root as the key-host portion (no /latest/... appended)
  const API_BASE = 'https://v6.exchangerate-api.com/v6/6b74612b6a28f7f59732e515';

  const amountEl = document.getElementById('amount');
  const fromEl = document.getElementById('fromCurrency');
  const toEl = document.getElementById('toCurrency');
  const convertBtn = document.getElementById('convertBtn');
  const swapBtn = document.getElementById('swapBtn');
  const resultEl = document.getElementById('result');
  const metaEl = document.getElementById('meta');
  const errorEl = document.getElementById('error');
  const manualRateEl = document.getElementById('manualRate');
  const applyManualBtn = document.getElementById('applyManual');
  const testApiBtn = document.getElementById('testApi');

  const SYMBOLS_CACHE_KEY = 'er_symbols_v1';

  const FALLBACK_SYMBOLS = {
    "USD": { description: "United States Dollar" },
    "EUR": { description: "Euro" },
    "INR": { description: "Indian Rupee" },
    "GBP": { description: "British Pound" },
    "JPY": { description: "Japanese Yen" },
    "AUD": { description: "Australian Dollar" },
    "CAD": { description: "Canadian Dollar" },
    "CNY": { description: "Chinese Yuan" }
  };

  function log(...args){ console.log('[CC]', ...args); }
  function fmt(n){ return Number(n).toLocaleString(undefined, { maximumFractionDigits: 6 }); }
  function showError(msg){ errorEl.textContent = msg; }
  function clearError(){ errorEl.textContent = ''; }

  // fetch helper with timeout
  async function fetchWithTimeout(url, opts = {}, ms = 9000) {
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), ms);
    try {
      const res = await fetch(url, { ...opts, signal: controller.signal });
      clearTimeout(id);
      return res;
    } catch (err) {
      clearTimeout(id);
      throw err;
    }
  }

  // populate <select>s
  function populateSymbols(symbolsObj){
    fromEl.innerHTML = '';
    toEl.innerHTML = '';

    const placeholderFrom = document.createElement('option');
    placeholderFrom.value = '';
    placeholderFrom.disabled = true;
    placeholderFrom.textContent = 'Select';
    fromEl.appendChild(placeholderFrom);

    const placeholderTo = placeholderFrom.cloneNode(true);
    toEl.appendChild(placeholderTo);

    Object.keys(symbolsObj).sort().forEach(code => {
      const desc = (symbolsObj[code] && (symbolsObj[code].description || symbolsObj[code].name)) || code;
      const o = document.createElement('option');
      o.value = code;
      o.textContent = `${code} — ${desc}`;
      fromEl.appendChild(o);
      toEl.appendChild(o.cloneNode(true));
    });

    // set sensible defaults if available
    if (!fromEl.value) {
      const usdOption = Array.from(fromEl.options).find(o => o.value === 'USD');
      fromEl.value = usdOption ? 'USD' : fromEl.options[1] ? fromEl.options[1].value : '';
    }
    if (!toEl.value) {
      const inrOption = Array.from(toEl.options).find(o => o.value === 'INR');
      toEl.value = inrOption ? 'INR' : toEl.options[1] ? toEl.options[1].value : '';
    }
  }

  // try cache first, then network
  async function loadSymbols() {
    try {
      const cached = localStorage.getItem(SYMBOLS_CACHE_KEY);
      if (cached) {
        const parsed = JSON.parse(cached);
        if (parsed && Object.keys(parsed).length) {
          populateSymbols(parsed);
          // refresh in background
          fetchSymbolsAndCache().catch(e => log('background refresh failed', e));
          return;
        }
      }
    } catch (e) {
      log('cache parse error', e);
    }
    await fetchSymbolsAndCache();
  }

  // ExchangeRate-API v6: /codes -> supported_codes (array of [code, name])
  async function fetchSymbolsAndCache() {
    const url = `${API_BASE}/codes`;
    log('fetching symbols from', url);
    try {
      const res = await fetchWithTimeout(url, {}, 9000);
      log('symbols status', res.status);
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const data = await res.json();
      // Expecting { result: "success", supported_codes: [ ["USD", "United States Dollar"], ... ] }
      const codes = data.supported_codes || data.supportedCodes || data.supportedCodes;
      if (Array.isArray(codes) && codes.length) {
        const obj = {};
        codes.forEach(item => {
          if (Array.isArray(item) && item.length >= 2) obj[item[0]] = { description: item[1] };
          else if (item && item.code) obj[item.code] = { description: item.name || item.description || item.code };
        });
        if (Object.keys(obj).length) {
          localStorage.setItem(SYMBOLS_CACHE_KEY, JSON.stringify(obj));
          populateSymbols(obj);
          clearError();
          log('symbols loaded from API, count=', Object.keys(obj).length);
          return;
        }
      }

      log('symbols response shape unexpected', data);
      populateSymbols(FALLBACK_SYMBOLS);
      showError('Failed to load currencies (API unexpected). Using fallback list.');
    } catch (err) {
      log('fetchSymbolsAndCache error:', err && err.message ? err.message : err);
      populateSymbols(FALLBACK_SYMBOLS);
      const message = (err && err.name === 'AbortError') ? 'Network timeout while loading currencies.' :
                      (err && err.message && err.message.includes('Failed to fetch')) ? 'Network fetch failed (CORS or offline).' :
                      'Unable to load currencies from the network.';
      showError(message + ' Using fallback list.');
    }
  }

  // Convert: use ExchangeRate-API latest endpoint: /latest/{base}
  async function convert(useManual = false, manualRate = null) {
    clearError();
    const amount = Number(amountEl.value);
    const from = fromEl.value;
    const to = toEl.value;
    if (!isFinite(amount) || amount < 0) { showError('Enter a valid amount (>= 0).'); return; }
    if (!from || !to) { showError('Select both currencies.'); return; }

    resultEl.innerHTML = `<div class="muted">Converting…</div>`;
    metaEl.textContent = '';

    // manual override
    if (useManual && manualRate !== null) {
      const converted = amount * Number(manualRate);
      resultEl.innerHTML = `<div class="value">${fmt(amount)} ${from} → ${fmt(converted)} ${to}</div>`;
      metaEl.textContent = `Manual rate used: 1 ${from} = ${fmt(manualRate)} ${to}`;
      return;
    }

    const url = `${API_BASE}/latest/${encodeURIComponent(from)}`;
    log('convert url', url);
    try {
      const res = await fetchWithTimeout(url, {}, 9000);
      log('convert status', res.status);
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const data = await res.json();
      log('convert response', data);
      const rates = data && (data.conversion_rates || data.conversionRates || data.rates);
      const rate = rates && rates[to];
      if ((rate === undefined || rate === null) && rate !== 0) throw new Error('Rate missing in response');
      const converted = amount * rate;
      resultEl.innerHTML = `<div class="value">${fmt(amount)} ${from} → ${fmt(converted)} ${to}</div>`;
      const updated = data && (data.time_last_update_utc || data.time_next_update_utc || data.time_last_update_unix) || '';
      metaEl.textContent = `1 ${from} = ${fmt(rate)} ${to}` + (updated ? ` (Updated: ${updated})` : '');
      clearError();
    } catch (err) {
      log('conversion error', err);
      showError('Conversion failed. You can input a manual rate below and press "Apply rate".');
      resultEl.innerHTML = `<div class="muted">No result</div>`;
    }
  }

  function swapCurrencies() {
    const a = fromEl.value;
    fromEl.value = toEl.value || '';
    toEl.value = a || '';
  }

  // events
  convertBtn.addEventListener('click', (e) => { e.preventDefault(); convert(); });
  swapBtn.addEventListener('click', (e) => { e.preventDefault(); swapCurrencies(); });
  amountEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') convert(); });

  applyManualBtn.addEventListener('click', (e) => {
    e.preventDefault();
    const r = Number(manualRateEl.value);
    if (!isFinite(r) || r <= 0) { showError('Enter a valid manual rate'); return; }
    convert(true, r);
  });

  testApiBtn.addEventListener('click', async (e) => {
    e.preventDefault();
    clearError();
    try {
      const testUrl = `${API_BASE}/pair/USD/INR`;
      const r = await fetchWithTimeout(testUrl, {}, 9000);
      if (!r.ok) throw new Error('HTTP ' + r.status);
      const j = await r.json();
      const sample = (j.conversion_rate !== undefined) ? j.conversion_rate :
                     (j && j.conversion_rates && j.conversion_rates.INR ? j.conversion_rates.INR : 'N/A');
      alert('API test OK — sample: 1 USD = ' + sample);
      clearError();
    } catch (err) {
      log('API test failed', err);
      showError('API appears unreachable from this page (see console).');
    }
  });

  // init
  loadSymbols();
});
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

Related Projects

Day 11 : Drum Kit

Play drum sounds when clicking buttons or pressing keys.

Concepts: Keyboard events, Audio API.

Day 15 : Light/Dark Mode Toggle

Switch between light and dark themes with one click (and remember the choice).

Concepts: CSS variables, LocalStorage.

Day 16 : Scroll Progress Bar

Shows a progress bar at the top as the user scrolls down the page.

Concepts: scroll event, math calculations.