You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Web-Dev-For-Beginners/7-bank-project/solution/app.js

516 lines
15 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const serverUrl = 'http://localhost:5000/api'; // reserved for future server swap
const storageKey = 'savedAccount';
const accountsKey = 'accounts';
const schemaKey = 'schemaVersion';
const schemaVersion = 1;
// ---------------------------------------------------------------------------
// Intl helpers
// ---------------------------------------------------------------------------
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 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;
}
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;
}
function formatDate(dateStr) {
const d = toDate(dateStr);
const fmt = new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' });
return fmt.format(d);
}
// ---------------------------------------------------------------------------
// Storage and state
// ---------------------------------------------------------------------------
function safeParse(json, fallback) {
try { return JSON.parse(json); } catch { return fallback; }
}
function getAccounts() {
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) {
return new Promise(resolve => {
setTimeout(() => {
const acc = findAccount(user);
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(() => {
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,
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);
}, 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 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(newTx);
}, 80);
});
}
// 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 });
// 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(() => {});
}
}
});
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function qs(id) {
return document.getElementById(id);
}
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 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);
  const formData = new FormData(registerForm);
  const data = Object.fromEntries(formData);
  const jsonData = JSON.stringify(data);
  const result = await createAccount(jsonData);
  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();
const data = await getAccount(account.user);
if (data.error) return logout();
  updateState('account', data);
}
async function refresh() {
  await updateAccountData();
  updateDashboard();
}
function updateDashboard() {
const account = state.account;
if (!account) return logout();
// Description (support both #description and #transactions-description subtitles)
setTextByAnyId(['description', 'transactions-description'], account.description || 'Transactions');
// Balance and currency
const balanceText = toCurrency(account.balance, account.currency);
setTextByAnyId(['balance', 'balance-value'], balanceText);
// 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 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 = qs('transactionDialog');
if (!dialog) return;
dialog.classList.add('show');
// 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();
}
}
function onDialogKeydown(e) {
if (e.key === 'Escape') {
cancelTransaction();
}
}
async function confirmTransaction() {
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;
}
clearFormError('transactionError');
const jsonData = JSON.stringify(Object.fromEntries(new FormData(form)));
const data = await createTransaction(state.account.user, jsonData);
if (data.error) {
setFormError('transactionError', data.error);
return;
}
// Update local state
const newAccount = {
...state.account,
balance: (Number(state.account.balance) || 0) + data.amount,
transactions: [...(state.account.transactions || []), data]
};
updateState('account', newAccount);
// Close dialog and update view
cancelTransaction();
updateDashboard();
}
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() {
  updateState('account', null);
  navigate('/login');
}
// ---------------------------------------------------------------------------
// Global listeners
// ---------------------------------------------------------------------------
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);
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init() {
// 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);
}
// Initial route render
updateRoute();
}
init();