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
convertendpoint (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();
});
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.