diff --git a/Basic Projects/6-currency_converter/assets/js/ApiClient.js b/Basic Projects/6-currency_converter/assets/js/ApiClient.js new file mode 100644 index 0000000..2bad01b --- /dev/null +++ b/Basic Projects/6-currency_converter/assets/js/ApiClient.js @@ -0,0 +1,179 @@ +// Constants +const DEFAULT_METHOD = 'GET'; +const DEFAULT_CACHE_DURATION = 0; +const DEFAULT_RETRIES = 0; +const DEFAULT_RETRY_DELAY = 2000; +const DEFAULT_TIMEOUT = 10000; +const NO_CACHE = 'no-cache'; +const CACHE_KEY = 'cacheData'; + +const noop = () => {}; + +class CacheManager { + constructor() { + this.storage = window.localStorage; + this.cacheKey = CACHE_KEY; + if (!this.storage.getItem(this.cacheKey)) { + this.storage.setItem(this.cacheKey, JSON.stringify({})); + } + } + + get(key, duration = DEFAULT_CACHE_DURATION) { + const cache = JSON.parse(this.storage.getItem(this.cacheKey)); + const item = cache[key]; + if (item && (Date.now() - item.timestamp < duration)) { + return item.data; + } else if (item) { + delete cache[key]; + this.storage.setItem(this.cacheKey, JSON.stringify(cache)); + } + return null; + } + + set(key, data) { + const cache = JSON.parse(this.storage.getItem(this.cacheKey)); + cache[key] = { + data: data, + timestamp: Date.now() + }; + this.storage.setItem(this.cacheKey, JSON.stringify(cache)); + } + + clear() { + this.storage.setItem(this.cacheKey, JSON.stringify({})); + } +} + +class FetchManager { + constructor() { + this.controller = new AbortController(); + } + + async fetch(url, options) { + const { + method = DEFAULT_METHOD, + data, + params, + retries = DEFAULT_RETRIES, + retryDelay = DEFAULT_RETRY_DELAY + } = options; + const fetchOptions = this.createFetchOptions(method, data); + + for (let i = 0; i <= retries; i++) { + try { + const fullUrl = this.createFullUrl(url, method, params); + const response = await fetch(fullUrl, fetchOptions); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (i === retries) throw error; + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + + createFetchOptions(method, data) { + return { + method, + signal: this.controller.signal, + cache: NO_CACHE, + body: method !== DEFAULT_METHOD ? JSON.stringify(data) : undefined, + }; + } + + createFullUrl(url, method, params) { + if (method === DEFAULT_METHOD && Object.keys(params).length > 0) { + return `${url}?${new URLSearchParams(params)}`; + } + return url; + } + + cancelRequest(message = 'Request canceled by the user') { + this.controller.abort(message); + this.controller = new AbortController(); + } +} + +class ResponseHandler { + handleSuccess(data, onSuccess = noop) { + onSuccess(data); + return { success: true, data }; + } + + handleFailure(error, onFailure = noop) { + if (error.name === 'AbortError') { + console.log('Request canceled', error.message); + } else { + onFailure(error); + } + return { success: false, error }; + } +} + +class ApiClient { + constructor() { + this.cacheManager = new CacheManager(); + this.fetchManager = new FetchManager(); + this.responseHandler = new ResponseHandler(); + } + + async request(url, options = {}) { + const { + method = DEFAULT_METHOD, + data = {}, + params = {}, + useCache = true, + cacheDuration = DEFAULT_CACHE_DURATION, + onWait = noop, + onSuccess = noop, + onFailure = noop, + onComplete = noop + } = options; + + const cacheKey = this.getCacheKey(url, method, data, params); + + onWait(); + + if (useCache) { + const cachedData = this.cacheManager.get(cacheKey, cacheDuration); + if (cachedData) { + onSuccess(cachedData); + onComplete(); + return { success: true, data: cachedData }; + } + } + + try { + const result = await this.fetchManager.fetch(url, { method, data, params, ...options }); + + if (useCache) { + this.cacheManager.set(cacheKey, result); + } + + onComplete(); + return this.responseHandler.handleSuccess(result, onSuccess); + } catch (error) { + onComplete(); + return this.responseHandler.handleFailure(error, onFailure); + } + } + + getCacheKey(url, method, data, params) { + return `${method}_${url}_${JSON.stringify(data)}_${JSON.stringify(params)}`; + } + + cancelRequest(message) { + this.fetchManager.cancelRequest(message); + } + + clearCache() { + this.cacheManager.clear(); + } +} + +const apiClient = new ApiClient(); +export default apiClient; \ No newline at end of file diff --git a/Basic Projects/6-currency_converter/assets/js/CurrencyConverter.js b/Basic Projects/6-currency_converter/assets/js/CurrencyConverter.js index 2fd06f1..74c49e4 100644 --- a/Basic Projects/6-currency_converter/assets/js/CurrencyConverter.js +++ b/Basic Projects/6-currency_converter/assets/js/CurrencyConverter.js @@ -6,85 +6,118 @@ import { FALLBACK_CURRENCIES, MAX_DECIMAL_PLACES } from './constants.js'; +import apiClient from './ApiClient.js'; class CurrencyConverter { - constructor() { - this.amountInput = $('#amount'); - this.fromCurrencySelect = $('#fromCurrency'); - this.toCurrencySelect = $('#toCurrency'); - this.resultInput = $('#result'); - this.convertBtn = $('#convertBtn'); - this.currencies = []; - this.cache = {}; - } + constructor() { + this.amountInput = $('#amount'); + this.fromCurrencySelect = $('#fromCurrency'); + this.toCurrencySelect = $('#toCurrency'); + this.resultInput = $('#result'); + this.convertBtn = $('#convertBtn'); + this.currencies = []; + } - async init() { - await this.fetchSupportedCurrencies(); - this.populateCurrencySelects(); - this.setupEventListeners(); - } + async init() { + await this.fetchSupportedCurrencies(); + this.setupEventListeners(); + } - async fetchSupportedCurrencies() { - try { - const response = await fetch(`${API_BASE_URL}${DEFAULT_CURRENCY}`); - const data = await response.json(); - this.currencies = Object.keys(data.rates); - } catch (error) { - console.error('Error fetching supported currencies:', error); - this.currencies = FALLBACK_CURRENCIES; - } - } + async fetchSupportedCurrencies() { + const options = { + cacheDuration: CACHE_EXPIRATION, + onWait: this.showLoadingState.bind(this), + onSuccess: this.handleFetchSuccess.bind(this), + onFailure: this.handleFetchFailure.bind(this), + onComplete: this.hideLoadingState.bind(this), + }; + + apiClient.request(`${API_BASE_URL}${DEFAULT_CURRENCY}`, options); + } + + showLoadingState() { + this.setSelectLoadingState(this.fromCurrencySelect); + this.setSelectLoadingState(this.toCurrencySelect); + } + + setSelectLoadingState(selectElement) { + selectElement.innerHTML = ''; + selectElement.disabled = true; + } + + handleFetchSuccess(data) { + this.currencies = Object.keys(data.rates); + this.populateCurrencySelects(); + } - populateCurrencySelects() { - this.currencies.forEach(currency => { - this.fromCurrencySelect.add(new Option(currency, currency)); - this.toCurrencySelect.add(new Option(currency, currency)); - }); + handleFetchFailure(error) { + console.error("Failure:", error); + this.currencies = FALLBACK_CURRENCIES; + this.populateCurrencySelects(); + } + + hideLoadingState() { + this.fromCurrencySelect.disabled = false; + this.toCurrencySelect.disabled = false; + this.removeLoadingOption(this.fromCurrencySelect); + this.removeLoadingOption(this.toCurrencySelect); + } + + removeLoadingOption(selectElement) { + const loadingOption = selectElement.querySelector('option[value="Loading..."]'); + if (loadingOption) { + selectElement.removeChild(loadingOption); } + } + + populateCurrencySelects() { + this.currencies.forEach(currency => { + this.fromCurrencySelect.add(new Option(currency, currency)); + this.toCurrencySelect.add(new Option(currency, currency)); + }); + } + + setupEventListeners() { + this.convertBtn.addEventListener('click', () => this.convertCurrency()); + } - setupEventListeners() { - this.convertBtn.addEventListener('click', () => this.convertCurrency()); + async convertCurrency() { + const amount = parseFloat(this.amountInput.value); + const fromCurrency = this.fromCurrencySelect.value; + const toCurrency = this.toCurrencySelect.value; + + if (isNaN(amount)) { + this.resultInput.value = 'Invalid amount'; + return; } - async convertCurrency() { - const amount = this.amountInput.value; - const fromCurrency = this.fromCurrencySelect.value; - const toCurrency = this.toCurrencySelect.value; - - try { - const rate = await this.getExchangeRate(fromCurrency, toCurrency); - const result = (amount * rate).toFixed(MAX_DECIMAL_PLACES); - this.resultInput.value = `${result} ${toCurrency}`; - } catch (error) { - console.error('Error converting currency:', error); - this.resultInput.value = 'Error converting currency'; - } + try { + const rate = await this.getExchangeRate(fromCurrency, toCurrency); + const result = (amount * rate).toFixed(MAX_DECIMAL_PLACES); + this.resultInput.value = `${result} ${toCurrency}`; + } catch (error) { + console.error('Error converting currency:', error); + this.resultInput.value = 'Error converting currency'; } + } + + async getExchangeRate(fromCurrency, toCurrency) { + const options = { + onWait: () => { + this.convertBtn.disabled = true; + this.resultInput.value = "Loading..." + }, + onComplete: () => { this.convertBtn.disabled = false; }, + }; + + const { success, data, error } = await apiClient.request(`${API_BASE_URL}${fromCurrency}`, options); - async getExchangeRate(fromCurrency, toCurrency) { - const cacheKey = `${fromCurrency}_${toCurrency}`; - const cachedData = this.cache[cacheKey]; - - if (cachedData && Date.now() - cachedData.timestamp < CACHE_EXPIRATION) { - return cachedData.rate; - } - - try { - const response = await fetch(`${API_BASE_URL}${fromCurrency}`); - const data = await response.json(); - const rate = data.rates[toCurrency]; - - this.cache[cacheKey] = { - rate: rate, - timestamp: Date.now() - }; - - return rate; - } catch (error) { - console.error('Error fetching exchange rates:', error); - throw error; - } + if (success) { + return data.rates[toCurrency]; + } else { + throw error; } + } } export default CurrencyConverter; \ No newline at end of file diff --git a/Basic Projects/6-currency_converter/index.html b/Basic Projects/6-currency_converter/index.html index 5eac601..4bd0319 100644 --- a/Basic Projects/6-currency_converter/index.html +++ b/Basic Projects/6-currency_converter/index.html @@ -6,6 +6,7 @@ Currency Converter + diff --git a/Basic Projects/6-currency_converter/styles.css b/Basic Projects/6-currency_converter/styles.css index 4ccc903..705e637 100644 --- a/Basic Projects/6-currency_converter/styles.css +++ b/Basic Projects/6-currency_converter/styles.css @@ -4,7 +4,9 @@ --background-color: #ffffff; --text-color: #333333; --header-bg: #f0f0f0; - + --disabled-bg-color: #cccccc; + --disabled-text-color: #666666; + --fs-small: 0.875rem; --fs-medium: 1rem; --fs-large: 1.25rem; @@ -84,6 +86,17 @@ body { background-color: var(--secondary-color); } +.converter__button:disabled { + background-color: var(--disabled-bg-color); + color: var(--disabled-text-color); + cursor: not-allowed; + opacity: 0.7; +} + +.converter__button:disabled:hover { + background-color: var(--disabled-bg-color); +} + .theme-switcher { display: flex; justify-content: center;