diff --git a/7-bank-project/solution/app.js b/7-bank-project/solution/app.js index 21f10968..a776bb18 100644 --- a/7-bank-project/solution/app.js +++ b/7-bank-project/solution/app.js @@ -2,179 +2,312 @@ // Constants // --------------------------------------------------------------------------- -const serverUrl = 'http://localhost:5000/api'; +const serverUrl = 'http://localhost:5000/api'; // reserved for future server swap const storageKey = 'savedAccount'; -const accountsKey = 'accounts'; // New key for all accounts +const accountsKey = 'accounts'; +const schemaKey = 'schemaVersion'; +const schemaVersion = 1; // --------------------------------------------------------------------------- -// Router +// Intl helpers // --------------------------------------------------------------------------- -const routes = { - '/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh }, - '/login': { title: 'Login', templateId: 'login' } -}; - -function navigate(path) { - window.history.pushState({}, path, window.location.origin + path); - updateRoute(); +const userLocale = navigator.language || 'en-IN'; + +function isIsoCurrency(code) { + if (!code) return false; + const c = String(code).trim().toUpperCase(); + if (!/^[A-Z]{3}$/.test(c)) return false; + // Verify against supported values when available + try { + if (typeof Intl.supportedValuesOf === 'function') { + return Intl.supportedValuesOf('currency').includes(c); + } + } catch {} + return true; // fallback accept 3-letter code } -function updateRoute() { - const path = window.location.pathname; - const route = routes[path]; - - if (!route) { - return navigate('/dashboard'); +function toCurrency(amount, currency) { + const n = Number(amount); + if (!Number.isFinite(n)) return String(amount); + const c = String(currency || '').trim(); + if (isIsoCurrency(c)) { + // Accounting style shows negatives in parentheses if supported + const fmt = new Intl.NumberFormat(userLocale, { + style: 'currency', + currency: c.toUpperCase(), + currencySign: 'accounting', + maximumFractionDigits: 2 + }); + return fmt.format(n); } + // Fallback: symbol + localized number + const num = new Intl.NumberFormat(userLocale, { + maximumFractionDigits: 2 + }).format(n); + return c ? `${c} ${num}` : num; +} - const template = document.getElementById(route.templateId); - const view = template.content.cloneNode(true); - const app = document.getElementById('app'); - app.innerHTML = ''; - app.appendChild(view); - - if (typeof route.init === 'function') { - route.init(); - } +function toDate(dateStr) { + // Expect yyyy-mm-dd; fallback to today if invalid + const d = dateStr ? new Date(dateStr) : new Date(); + if (Number.isNaN(d.getTime())) return new Date(); + return d; +} - document.title = route.title; +function formatDate(dateStr) { + const d = toDate(dateStr); + const fmt = new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' }); + return fmt.format(d); } // --------------------------------------------------------------------------- -// API interactions (replaced with localStorage logic) +// Storage and state // --------------------------------------------------------------------------- +function safeParse(json, fallback) { + try { return JSON.parse(json); } catch { return fallback; } +} + function getAccounts() { - return JSON.parse(localStorage.getItem(accountsKey) || '[]'); + return safeParse(localStorage.getItem(accountsKey), []); } function saveAccounts(accounts) { localStorage.setItem(accountsKey, JSON.stringify(accounts)); } +function migrateSchema() { + const v = Number(localStorage.getItem(schemaKey) || 0); + if (v >= schemaVersion) return; + let accounts = getAccounts(); + // Example migration scaffolding: + // if (v < 1) { /* future migrations */ } + saveAccounts(accounts); + localStorage.setItem(schemaKey, String(schemaVersion)); +} + function findAccount(user) { const accounts = getAccounts(); return accounts.find(acc => acc.user === user) || null; } async function getAccount(user) { - // Simulate async return new Promise(resolve => { setTimeout(() => { const acc = findAccount(user); - if (!acc) resolve({ error: 'Account not found' }); - else resolve(acc); - }, 100); + resolve(acc || { error: 'Account not found' }); + }, 60); }); } +function uuid() { + // Works on HTTPS and localhost; fallback otherwise + if (globalThis.crypto && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'tx-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); +} + async function createAccount(accountJson) { return new Promise(resolve => { setTimeout(() => { - let data; - try { - data = JSON.parse(accountJson); - } catch (e) { - return resolve({ error: 'Malformed account data' }); - } - if (!data.user) return resolve({ error: 'Username required' }); - if (findAccount(data.user)) return resolve({ error: 'User already exists' }); - // Set up initial account structure + const data = safeParse(accountJson, null); + if (!data) return resolve({ error: 'Malformed account data' }); + const user = String(data.user || '').trim(); + if (!user) return resolve({ error: 'Username required' }); + if (findAccount(user)) return resolve({ error: 'User already exists' }); + + const currency = String(data.currency || 'INR').trim(); const newAcc = { - user: data.user, - description: data.description || '', - balance: 0, - currency: data.currency || 'USD', + user, + description: String(data.description || ''), + balance: Number(data.balance || 0) || 0, + currency: isIsoCurrency(currency) ? currency.toUpperCase() : currency, transactions: [] }; const accounts = getAccounts(); accounts.push(newAcc); saveAccounts(accounts); resolve(newAcc); - }, 100); + }, 80); }); } async function createTransaction(user, transactionJson) { return new Promise(resolve => { setTimeout(() => { + const tx = safeParse(transactionJson, null); + if (!tx) return resolve({ error: 'Malformed transaction data' }); + + const amount = Number(tx.amount); + const object = String(tx.object || '').trim(); + const dateStr = tx.date || new Date().toISOString().slice(0, 10); + + if (!Number.isFinite(amount)) return resolve({ error: 'Amount must be a valid number' }); + if (!object) return resolve({ error: 'Object is required' }); + const accounts = getAccounts(); const idx = accounts.findIndex(acc => acc.user === user); if (idx === -1) return resolve({ error: 'Account not found' }); - const tx = JSON.parse(transactionJson); - tx.amount = parseFloat(tx.amount); - tx.date = tx.date || new Date().toISOString().slice(0, 10); - accounts[idx].balance += tx.amount; - accounts[idx].transactions.push(tx); + + const newTx = { + id: uuid(), + date: dateStr, + object, + amount + }; + + // Update balance and push transaction + accounts[idx].balance = (Number(accounts[idx].balance) || 0) + amount; + accounts[idx].transactions = (accounts[idx].transactions || []); + accounts[idx].transactions.push(newTx); + saveAccounts(accounts); - resolve(tx); - }, 100); + resolve(newTx); + }, 80); }); } -// --------------------------------------------------------------------------- -// Global state -// --------------------------------------------------------------------------- - +// Keep a frozen state object to avoid accidental mutations let state = Object.freeze({ account: null }); function updateState(property, newData) { - state = Object.freeze({ - ...state, - [property]: newData - }); + state = Object.freeze({ ...state, [property]: newData }); + // Persist active account only localStorage.setItem(storageKey, JSON.stringify(state.account)); } +// Cross-tab sync: refresh when accounts or active account change in another tab +window.addEventListener('storage', (e) => { + if (e.key === accountsKey || e.key === storageKey) { + if (state.account?.user) { + refresh().catch(() => {}); + } + } +}); + // --------------------------------------------------------------------------- -// Login/register +// DOM helpers // --------------------------------------------------------------------------- -async function login() { - const loginForm = document.getElementById('loginForm') - const user = loginForm.user.value; - const data = await getAccount(user); +function qs(id) { + return document.getElementById(id); +} - if (data.error) { - return updateElement('loginError', data.error); +function updateElement(id, textOrNode) { + const el = qs(id); + if (!el) return; + while (el.firstChild) el.removeChild(el.firstChild); + el.append(textOrNode); +} + +// Resolve multiple possible IDs used by older markup +function setTextByAnyId(ids, text) { + for (const id of ids) { + const el = qs(id); + if (el) { el.textContent = text; return true; } + } + return false; +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +const routes = { + '/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh }, + '/login': { title: 'Login', templateId: 'login', init: attachAuthHandlers } +}; + +function navigate(path) { + // Store path in history state and URL + history.pushState({ path }, '', path); + updateRoute(); +} + +function updateRoute() { + const path = history.state?.path || window.location.pathname; + const route = routes[path] || routes['/dashboard']; + + const template = document.getElementById(route.templateId); + const view = template.content.cloneNode(true); + const app = document.getElementById('app'); + app.innerHTML = ''; + app.appendChild(view); + + // Attach handlers after DOM is rendered + attachGlobalHandlers(); + + if (typeof route.init === 'function') { + Promise.resolve(route.init()).catch(err => console.error(err)); } + document.title = route.title; +} + +// Browser back/forward +window.addEventListener('popstate', () => updateRoute()); + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +async function login() { + const form = qs('loginForm'); + if (!form) return; + if (!form.checkValidity()) { form.reportValidity(); return; } + + const user = String(form.user.value || '').trim(); + const data = await getAccount(user); + if (data.error) return updateElement('loginError', data.error); updateState('account', data); navigate('/dashboard'); } async function register() { - const registerForm = document.getElementById('registerForm'); - const formData = new FormData(registerForm); - const data = Object.fromEntries(formData); + const form = qs('registerForm'); + if (!form) return; + if (!form.checkValidity()) { form.reportValidity(); return; } + + const data = Object.fromEntries(new FormData(form)); + data.user = String(data.user || '').trim(); + data.currency = String(data.currency || '').trim(); + data.description = String(data.description || '').trim(); + data.balance = Number(data.balance || 0) || 0; + const jsonData = JSON.stringify(data); const result = await createAccount(jsonData); - if (result.error) { - return updateElement('registerError', result.error); - } + if (result.error) return updateElement('registerError', result.error); updateState('account', result); navigate('/dashboard'); } +function attachAuthHandlers() { + const loginForm = qs('loginForm'); + if (loginForm) { + loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); }); + } + const registerForm = qs('registerForm'); + if (registerForm) { + registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); }); + } +} + // --------------------------------------------------------------------------- // Dashboard // --------------------------------------------------------------------------- async function updateAccountData() { const account = state.account; - if (!account) { - return logout(); - } + if (!account) return logout(); const data = await getAccount(account.user); - if (data.error) { - return logout(); - } + if (data.error) return logout(); updateState('account', data); } @@ -186,74 +319,133 @@ async function refresh() { function updateDashboard() { const account = state.account; - if (!account) { - return logout(); - } + if (!account) return logout(); - updateElement('description', account.description); - updateElement('balance', account.balance.toFixed(2)); - updateElement('currency', account.currency); + // Description (support both #description and #transactions-description subtitles) + setTextByAnyId(['description', 'transactions-description'], account.description || 'Transactions'); - // Update transactions - const transactionsRows = document.createDocumentFragment(); - for (const transaction of account.transactions) { - const transactionRow = createTransactionRow(transaction); - transactionsRows.appendChild(transactionRow); - } - updateElement('transactions', transactionsRows); -} + // Balance and currency + const balanceText = toCurrency(account.balance, account.currency); + setTextByAnyId(['balance', 'balance-value'], balanceText); -function createTransactionRow(transaction) { + // Some markups use a separate currency span; keep it empty when using formatted output + setTextByAnyId(['balance-currency', 'currency'], ''); + + // Transactions (sorted by date desc, then by insertion order) + const tbody = qs('transactions'); + if (!tbody) return; + + const frag = document.createDocumentFragment(); const template = document.getElementById('transaction'); - const transactionRow = template.content.cloneNode(true); - const tr = transactionRow.querySelector('tr'); - tr.children[0].textContent = transaction.date; - tr.children[1].textContent = transaction.object; - tr.children[2].textContent = transaction.amount.toFixed(2); - return transactionRow; + + const sorted = [...(account.transactions || [])] + .sort((a, b) => String(b.date).localeCompare(String(a.date))); + + for (const tx of sorted) { + const row = template.content.cloneNode(true); + const tr = row.querySelector('tr'); + const tds = tr.children; + tds[0].textContent = formatDate(tx.date); + tds[1].textContent = tx.object; + tds[2].textContent = toCurrency(tx.amount, account.currency); + if (tx.amount < 0) tr.classList.add('debit'); + if (tx.amount > 0) tr.classList.add('credit'); + frag.appendChild(row); + } + + tbody.innerHTML = ''; + tbody.appendChild(frag); } function addTransaction() { - const dialog = document.getElementById('transactionDialog'); + const dialog = qs('transactionDialog'); + if (!dialog) return; dialog.classList.add('show'); - // Reset form - const transactionForm = document.getElementById('transactionForm'); - transactionForm.reset(); + // Reset form and set today + const form = qs('transactionForm'); + if (form) { + form.reset(); + form.date.valueAsDate = new Date(); + // Move focus to first field + form.date.focus(); + } + + // Close handlers + const backdrop = dialog.querySelector('[data-dismiss]') || dialog; + backdrop.addEventListener('click', onDialogDismissClick); + dialog.addEventListener('keydown', onDialogKeydown); +} + +function onDialogDismissClick(e) { + if (e.target?.hasAttribute?.('data-dismiss')) { + cancelTransaction(); + } +} - // Set date to today - transactionForm.date.valueAsDate = new Date(); +function onDialogKeydown(e) { + if (e.key === 'Escape') { + cancelTransaction(); + } } async function confirmTransaction() { - const dialog = document.getElementById('transactionDialog'); - dialog.classList.remove('show'); + const form = qs('transactionForm'); + if (!form) return; + + // Inline validation + const amountVal = Number(form.amount.value); + const objectVal = String(form.object.value || '').trim(); + if (!Number.isFinite(amountVal)) { + setFormError('transactionError', 'Amount must be a valid number'); + return; + } + if (!objectVal) { + setFormError('transactionError', 'Object is required'); + return; + } - const transactionForm = document.getElementById('transactionForm'); + clearFormError('transactionError'); - const formData = new FormData(transactionForm); - const jsonData = JSON.stringify(Object.fromEntries(formData)); + const jsonData = JSON.stringify(Object.fromEntries(new FormData(form))); const data = await createTransaction(state.account.user, jsonData); if (data.error) { - return updateElement('transactionError', data.error); + setFormError('transactionError', data.error); + return; } - // Update local state with new transaction + // Update local state const newAccount = { ...state.account, - balance: state.account.balance + data.amount, - transactions: [...state.account.transactions, data] - } + balance: (Number(state.account.balance) || 0) + data.amount, + transactions: [...(state.account.transactions || []), data] + }; updateState('account', newAccount); - // Update display + // Close dialog and update view + cancelTransaction(); updateDashboard(); } -async function cancelTransaction() { - const dialog = document.getElementById('transactionDialog'); +function setFormError(id, message) { + updateElement(id, message); + const el = qs(id); + if (el) el.focus(); +} + +function clearFormError(id) { + const el = qs(id); + if (el) el.textContent = ''; +} + +function cancelTransaction() { + const dialog = qs('transactionDialog'); + if (!dialog) return; dialog.classList.remove('show'); + dialog.removeEventListener('keydown', onDialogKeydown); + const opener = document.querySelector('button[onclick="addTransaction()"]'); + if (opener) opener.focus(); } function logout() { @@ -262,13 +454,31 @@ function logout() { } // --------------------------------------------------------------------------- -// Utils +// Global listeners // --------------------------------------------------------------------------- -function updateElement(id, textOrNode) { - const element = document.getElementById(id); - element.textContent = ''; // Removes all children - element.append(textOrNode); +function attachGlobalHandlers() { + // Intercept form submissions for inline handlers in markup + const loginForm = qs('loginForm'); + if (loginForm && !loginForm.__wired) { + loginForm.__wired = true; + loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); }); + } + const registerForm = qs('registerForm'); + if (registerForm && !registerForm.__wired) { + registerForm.__wired = true; + registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); }); + } + const txForm = qs('transactionForm'); + if (txForm && !txForm.__wired) { + txForm.__wired = true; + txForm.addEventListener('submit', (e) => { e.preventDefault(); confirmTransaction(); }); + } + const cancelBtn = document.querySelector('#transactionDialog [data-dismiss]'); + if (cancelBtn && !cancelBtn.__wired) { + cancelBtn.__wired = true; + cancelBtn.addEventListener('click', cancelTransaction); + } } // --------------------------------------------------------------------------- @@ -276,14 +486,20 @@ function updateElement(id, textOrNode) { // --------------------------------------------------------------------------- function init() { - // Restore state - const savedState = localStorage.getItem(storageKey); - if (savedState) { - updateState('account', JSON.parse(savedState)); + // Schema migration + migrateSchema(); + + // Restore active account + const saved = safeParse(localStorage.getItem(storageKey), null); + if (saved) updateState('account', saved); + + // Seed history state if missing + if (!history.state || !history.state.path) { + const initialPath = state.account ? '/dashboard' : '/login'; + history.replaceState({ path: initialPath }, '', initialPath); } - // Update route for browser back/next buttons - window.onpopstate = () => updateRoute(); + // Initial route render updateRoute(); }