/* =====================================================
TrainGo — Login & Register (US_UI_001)
Requires store.js to be loaded first.
===================================================== */
// ===== ALREADY SIGNED IN? SKIP THE FORM =====
(() => {
const s = DB.session.get();
if (s) {
window.location.href = s.role === "admin" ? "admin.html" : "home.html";
}
})();
// ===== DOM =====
const loginCard = document.getElementById("login-card");
const registerCard = document.getElementById("register-card");
const loginForm = document.getElementById("login-form");
const registerForm = document.getElementById("register-form");
const toast = document.getElementById("toast");
// ===== VIEW SWITCHING =====
document.getElementById("go-register").addEventListener("click", () => {
loginCard.classList.remove("active");
registerCard.classList.add("active");
clearForm(loginForm);
});
document.getElementById("go-login").addEventListener("click", () => {
registerCard.classList.remove("active");
loginCard.classList.add("active");
clearForm(registerForm);
});
// ===== PASSWORD TOGGLE =====
document.querySelectorAll(".toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = document.getElementById(btn.dataset.target);
const isPassword = input.type === "password";
input.type = isPassword ? "text" : "password";
btn.textContent = isPassword ? "🙈" : "👁";
});
});
// ===== UI HELPERS =====
function showToast(message, isError = false) {
toast.textContent = message;
toast.classList.toggle("toast--error", isError);
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 3000);
}
function setError(errId, msg) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = msg;
const field =
el.previousElementSibling?.querySelector?.("input") ||
el.previousElementSibling;
if (field && field.tagName === "INPUT") {
field.classList.remove("valid");
field.classList.add("invalid");
}
}
function setValid(errId) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = "";
const field =
el.previousElementSibling?.querySelector?.("input") ||
el.previousElementSibling;
if (field && field.tagName === "INPUT") {
field.classList.remove("invalid");
field.classList.add("valid");
}
}
function clearForm(form) {
form.reset();
form.querySelectorAll(".error-msg").forEach((e) => (e.textContent = ""));
form.querySelectorAll("input").forEach((i) =>
i.classList.remove("invalid", "valid")
);
}
// ===== VALIDATOR REGISTRY =====
const validators = {};
function attachLiveValidation(inputId, errId, validator) {
validators[inputId] = validator;
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener("input", () => {
const msg = validator(input.value.trim());
msg ? setError(errId, msg) : setValid(errId);
});
}
function runValidation(fields) {
let ok = true;
fields.forEach(([inputId, errId]) => {
const input = document.getElementById(inputId);
const validator = validators[inputId];
if (!input) { console.warn(`Missing input: #${inputId}`); return; }
if (!validator) { console.warn(`No validator: #${inputId}`); return; }
const msg = validator(input.value.trim());
if (msg) { setError(errId, msg); ok = false; }
else { setValid(errId); }
});
return ok;
}
// ===== LOGIN VALIDATORS =====
attachLiveValidation("login-username", "login-username-err", (v) =>
v === "" ? "Username is required." : null
);
attachLiveValidation("login-password", "login-password-err", (v) =>
v === "" ? "Password is required." : null
);
// ===== REGISTER VALIDATORS (per US_UI_001 spec) =====
// 1. username — min 6 chars, no special chars or numbers, not null
attachLiveValidation("reg-username", "reg-username-err", (v) => {
if (v === "") return "Username is required.";
if (v.length < 6) return "Must be at least 6 characters.";
if (!/^[A-Za-z]+$/.test(v)) return "Letters only — no numbers or symbols.";
return null;
});
// 2. password — min 8, 1 special, 1 number, 1 uppercase, not null
attachLiveValidation("reg-password", "reg-password-err", (v) => {
if (v === "") return "Password is required.";
if (v.length < 8) return "Must be at least 8 characters.";
if (!/[A-Z]/.test(v)) return "Needs at least 1 uppercase letter.";
if (!/[0-9]/.test(v)) return "Needs at least 1 number.";
if (!/[^A-Za-z0-9]/.test(v)) return "Needs at least 1 special character.";
return null;
});
// 3. confirm password — must match, not null
attachLiveValidation("reg-confirm", "reg-confirm-err", (v) => {
if (v === "") return "Please confirm your password.";
if (v !== document.getElementById("reg-password").value)
return "Passwords do not match.";
return null;
});
// 4. mail — contains @, has a domain part, not null
attachLiveValidation("reg-email", "reg-email-err", (v) => {
if (v === "") return "Email is required.";
if (!v.includes("@")) return "Must contain an @ sign.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v))
return "Must include a domain like gmail.com.";
return null;
});
// 5. mobile — no alphabets, exactly 10 digits, not null
attachLiveValidation("reg-mobile", "reg-mobile-err", (v) => {
if (v === "") return "Mobile number is required.";
if (/[A-Za-z]/.test(v)) return "Alphabets are not allowed.";
if (!/^[0-9]{10}$/.test(v)) return "Must be exactly 10 digits.";
return null;
});
// 6. aadhaar — not null
attachLiveValidation("reg-aadhaar", "reg-aadhaar-err", (v) => {
if (v === "") return "Aadhaar number is required.";
if (!/^[0-9]{12}$/.test(v)) return "Must be 12 digits.";
return null;
});
// ===== LOGIN SUBMIT =====
loginForm.addEventListener("submit", (e) => {
e.preventDefault();
const fields = [
["login-username", "login-username-err"],
["login-password", "login-password-err"],
];
if (!runValidation(fields)) return;
const username = document.getElementById("login-username").value.trim();
const password = document.getElementById("login-password").value;
// does the account exist?
if (!DB.users.find(username)) {
setError("login-username-err", "No account found with this username.");
return;
}
// does the password match?
const user = DB.users.authenticate(username, password);
if (!user) {
setError("login-password-err", "Incorrect password.");
return;
}
// role comes from the stored record, never from the form
DB.session.set(user.username, user.role);
const isAdmin = user.role === "admin";
const destination = isAdmin ? "admin.html" : "home.html";
const btn = loginForm.querySelector(".btn");
const btnText = btn.querySelector(".btn__text") || btn;
btnText.textContent = "Boarding…";
btn.disabled = true;
setTimeout(() => {
showToast(isAdmin ? "🛡️ Admin login successful." : "✓ Welcome aboard!");
setTimeout(() => (window.location.href = destination), 700);
}, 1000);
});
// ===== REGISTER SUBMIT =====
registerForm.addEventListener("submit", (e) => {
e.preventDefault();
const fields = [
["reg-username", "reg-username-err"],
["reg-email", "reg-email-err"],
["reg-password", "reg-password-err"],
["reg-confirm", "reg-confirm-err"],
["reg-mobile", "reg-mobile-err"],
["reg-aadhaar", "reg-aadhaar-err"],
];
if (!runValidation(fields)) return;
const username = document.getElementById("reg-username").value.trim();
if (DB.users.exists(username)) {
setError("reg-username-err", "Username already taken.");
return;
}
const created = DB.users.add({
username,
role: "customer",
email: document.getElementById("reg-email").value.trim(),
password: document.getElementById("reg-password").value,
mobile: document.getElementById("reg-mobile").value.trim(),
aadhaar: document.getElementById("reg-aadhaar").value.trim(),
});
if (!created) {
showToast("Registration failed — storage error.", true);
return;
}
const btn = registerForm.querySelector(".btn");
const btnText = btn.querySelector(".btn__text") || btn;
btnText.textContent = "Issuing ticket…";
btn.disabled = true;
setTimeout(() => {
showToast(`🎫 Account ${created.userId} created! Please log in.`);
btnText.textContent = "Register";
btn.disabled = false;
clearForm(registerForm);
setTimeout(() => {
registerCard.classList.remove("active");
loginCard.classList.add("active");
}, 1200);
}, 1000);
});
/* =====================================================
TrainGo — Customer Portal (US_UI_002)
Requires store.js to be loaded first.
===================================================== */
// ===== ACCESS GUARD =====
(() => {
const s = DB.session.get();
if (!s) { window.location.href = "index.html"; return; }
if (s.role === "admin") { window.location.href = "admin.html"; }
})();
// ===== STATIC DATA =====
const STATIONS = [
"New Delhi", "Mumbai Central", "Chennai Central", "Howrah Junction",
"Bengaluru City", "Hyderabad Deccan", "Pune Junction", "Ahmedabad Junction",
"Jaipur Junction", "Lucknow Charbagh", "Bhopal Junction", "Patna Junction",
];
// ===== DOM =====
const toast = document.getElementById("toast");
const cancelModal = document.getElementById("cancel-modal");
let pendingCancelId = null;
// ===== UI HELPERS =====
function showToast(message, isError = false) {
toast.textContent = message;
toast.classList.toggle("toast--error", isError);
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 3000);
}
function setError(errId, msg) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = msg;
const field = el.parentElement.querySelector("input, select, textarea");
if (field) { field.classList.remove("valid"); field.classList.add("invalid"); }
}
function setValid(errId) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = "";
const field = el.parentElement.querySelector("input, select, textarea");
if (field) { field.classList.remove("invalid"); field.classList.add("valid"); }
}
function clearFormState(form) {
form.querySelectorAll(".error-msg").forEach((e) => (e.textContent = ""));
form.querySelectorAll("input, select, textarea")
.forEach((f) => f.classList.remove("invalid", "valid"));
}
function escapeHTML(str) {
const map = {
"&": "&", "<": "<", ">": ">",
'"': """, "'": "'",
};
return String(str).replace(/[&<>"']/g, (c) => map[c]);
}
// ===== VALIDATOR REGISTRY =====
const validators = {};
function attachLiveValidation(inputId, errId, validator) {
validators[inputId] = validator;
const input = document.getElementById(inputId);
if (!input) return;
const evt = input.tagName === "SELECT" ? "change" : "input";
input.addEventListener(evt, () => {
const msg = validator(input.value.trim());
msg ? setError(errId, msg) : setValid(errId);
});
}
function runValidation(fields) {
let ok = true;
fields.forEach(([inputId, errId]) => {
const input = document.getElementById(inputId);
const validator = validators[inputId];
if (!input || !validator) return;
const msg = validator(input.value.trim());
if (msg) { setError(errId, msg); ok = false; }
else { setValid(errId); }
});
return ok;
}
// ===== TAB SWITCHING =====
function showPanel(panelId) {
document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
document.getElementById(panelId)?.classList.add("active");
document.querySelectorAll(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.panel === panelId)
);
window.scrollTo({ top: 0, behavior: "smooth" });
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
const panelId = tab.dataset.panel;
showPanel(panelId);
if (panelId === "panel-home") renderAggregates();
if (panelId === "panel-view") renderTickets();
if (panelId === "panel-update") loadUpdateForm();
});
});
// ===== SESSION =====
function initSession() {
const user = DB.session.user();
if (!user) return null;
document.getElementById("current-user").textContent = user.username;
document.getElementById("bk-id").value = user.userId;
const nameField = document.getElementById("bk-name");
if (nameField && !nameField.value) nameField.value = user.username;
return user;
}
document.getElementById("logout-btn").addEventListener("click", () => {
DB.session.clear();
window.location.href = "index.html";
});
// ===== STATION DROPDOWNS =====
function populateStations() {
const from = document.getElementById("bk-from");
const to = document.getElementById("bk-to");
STATIONS.forEach((s) => {
from.insertAdjacentHTML("beforeend", `<option value="${s}">${s}</option>`);
to.insertAdjacentHTML("beforeend", `<option value="${s}">${s}</option>`);
});
}
// ===== 2.7 AVAILABLE TRAINS (from admin-registered trains) =====
function refreshTrains() {
const from = document.getElementById("bk-from").value;
const to = document.getElementById("bk-to").value;
const trainSelect = document.getElementById("bk-train");
trainSelect.innerHTML = "";
if (!from || !to) {
trainSelect.innerHTML = `<option value="">Select boarding & destination first</option>`;
return;
}
if (from === to) {
trainSelect.innerHTML = `<option value="">Stations must differ</option>`;
return;
}
const matching = DB.trains.onRoute(from, to);
if (matching.length === 0) {
trainSelect.innerHTML = `<option value="">No trains registered on this route</option>`;
return;
}
trainSelect.innerHTML = `<option value="">Select a train</option>`;
matching.forEach((t) => {
trainSelect.insertAdjacentHTML(
"beforeend",
`<option value="${escapeHTML(t.id)}">${escapeHTML(t.id)} — ${escapeHTML(t.name)} (dep ${escapeHTML(t.depart)})</option>`
);
});
}
document.getElementById("bk-from").addEventListener("change", refreshTrains);
document.getElementById("bk-to").addEventListener("change", refreshTrains);
// ===== BOOKING VALIDATORS =====
attachLiveValidation("bk-name", "bk-name-err", (v) => {
if (v === "") return "Passenger name is required.";
if (!/^[A-Za-z ]+$/.test(v)) return "Letters and spaces only.";
return null;
});
attachLiveValidation("bk-mobile", "bk-mobile-err", (v) => {
if (v === "") return "Mobile number is required.";
if (/[A-Za-z]/.test(v)) return "Alphabets are not allowed.";
if (!/^[0-9]{10}$/.test(v)) return "Must be exactly 10 digits.";
return null;
});
attachLiveValidation("bk-age", "bk-age-err", (v) => {
if (v === "") return "Age is required.";
if (!/^[0-9]{1,3}$/.test(v)) return "Digits only.";
const n = Number(v);
if (n < 1 || n > 120) return "Enter a valid age (1-120).";
return null;
});
attachLiveValidation("bk-date", "bk-date-err", (v) => {
if (v === "") return "Journey date is required.";
const today = new Date(); today.setHours(0, 0, 0, 0);
if (new Date(v) < today) return "Date cannot be in the past.";
return null;
});
attachLiveValidation("bk-from", "bk-from-err", (v) =>
v === "" ? "Boarding station is required." : null
);
attachLiveValidation("bk-to", "bk-to-err", (v) => {
if (v === "") return "Destination station is required.";
if (v === document.getElementById("bk-from").value)
return "Destination must differ from boarding.";
return null;
});
attachLiveValidation("bk-class", "bk-class-err", (v) =>
v === "" ? "Ticket category is required." : null
);
attachLiveValidation("bk-train", "bk-train-err", (v) =>
v === "" ? "Please select a train." : null
);
attachLiveValidation("bk-count", "bk-count-err", (v) => {
if (v === "") return "Number of tickets is required.";
if (!/^[0-9]+$/.test(v)) return "Digits only.";
const n = Number(v);
if (n < 1 || n > 6) return "Between 1 and 6 tickets.";
return null;
});
// ===== BOOKING SUBMIT =====
const bookForm = document.getElementById("book-form");
bookForm.addEventListener("submit", (e) => {
e.preventDefault();
const fields = [
["bk-name", "bk-name-err"],
["bk-mobile", "bk-mobile-err"],
["bk-age", "bk-age-err"],
["bk-date", "bk-date-err"],
["bk-from", "bk-from-err"],
["bk-to", "bk-to-err"],
["bk-class", "bk-class-err"],
["bk-train", "bk-train-err"],
["bk-count", "bk-count-err"],
];
if (!runValidation(fields)) {
showToast("Please fix the highlighted fields.", true);
return;
}
const trainId = document.getElementById("bk-train").value;
const train = DB.trains.find(trainId);
if (!train) {
setError("bk-train-err", "That train is no longer available.");
refreshTrains();
return;
}
const date = document.getElementById("bk-date").value;
const count = Number(document.getElementById("bk-count").value);
// capacity check against seats already sold that day
const taken = DB.tickets.seatsTaken(train.id, date);
const free = train.seats - taken;
if (count > free) {
setError("bk-count-err", free > 0
? `Only ${free} seat${free === 1 ? "" : "s"} left on this train.`
: "This train is fully booked for that date.");
showToast("Not enough seats available.", true);
return;
}
const boardingDT = `${date} ${train.depart}`;
const arrivalDT = computeArrival(date, train.depart, train.duration);
const ticket = DB.tickets.add({
trainId: train.id,
trainName: train.name,
userId: document.getElementById("bk-id").value,
userName: document.getElementById("bk-name").value.trim(),
mobile: document.getElementById("bk-mobile").value.trim(),
age: document.getElementById("bk-age").value.trim(),
boarding: document.getElementById("bk-from").value,
destination: document.getElementById("bk-to").value,
category: document.getElementById("bk-class").value,
boardingDT,
arrivalDT,
count,
});
if (!ticket) {
showToast("Booking failed — storage error.", true);
return;
}
renderConfirmation(ticket);
bookForm.reset();
clearFormState(bookForm);
initSession();
refreshTrains();
showPanel("panel-confirm");
});
// arrival = boarding datetime + duration hours
function computeArrival(dateStr, departTime, durationHrs) {
const [h, m] = String(departTime).split(":").map(Number);
const dt = new Date(dateStr);
dt.setHours(isNaN(h) ? 0 : h, isNaN(m) ? 0 : m, 0, 0);
dt.setMinutes(dt.getMinutes() + Math.round(Number(durationHrs) * 60));
const pad = (n) => String(n).padStart(2, "0");
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ` +
`${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
// ===== CONFIRMATION =====
function renderConfirmation(t) {
const rows = [
["Ticket ID", t.ticketId],
["Train ID", `${t.trainId} — ${t.trainName}`],
["User ID", t.userId],
["Passenger", t.userName],
["Boarding Station", t.boarding],
["Destination Station", t.destination],
["Boarding Date & Time", t.boardingDT],
["Arrival Date & Time", t.arrivalDT],
["Category", t.category],
["Number of Tickets", t.count],
];
document.getElementById("confirm-list").innerHTML = rows
.map(([k, v]) =>
`<div class="confirm-item"><dt>${k}</dt><dd>${escapeHTML(String(v))}</dd></div>`)
.join("");
}
document.getElementById("confirm-view").addEventListener("click", () => {
renderTickets();
showPanel("panel-view");
});
document.getElementById("confirm-another").addEventListener("click", () => {
showPanel("panel-book");
});
// ===== 3. VIEW TICKETS =====
function renderTickets() {
const body = document.getElementById("tickets-body");
const user = DB.session.user();
if (!user) return;
// only this customer's tickets
const tickets = DB.tickets.byUser(user.userId)
.concat(DB.tickets.byUser(user.username))
.filter((t, i, arr) => arr.findIndex((x) => x.ticketId === t.ticketId) === i);
if (tickets.length === 0) {
body.innerHTML = `<tr><td colspan="11" class="empty-row">No tickets booked yet.</td></tr>`;
return;
}
body.innerHTML = tickets
.slice()
.reverse()
.map((t) => {
const cancelled = t.status === "Cancelled";
return `
<tr>
<td><strong>${escapeHTML(t.ticketId)}</strong></td>
<td>${escapeHTML(t.trainId)}</td>
<td>${escapeHTML(t.userId)}</td>
<td>${escapeHTML(t.userName)}</td>
<td>${escapeHTML(t.boarding)}</td>
<td>${escapeHTML(t.destination)}</td>
<td>${escapeHTML(t.boardingDT)}</td>
<td>${escapeHTML(t.arrivalDT)}</td>
<td>${t.count}</td>
<td><span class="pill ${cancelled ? "pill--cancelled" : "pill--active"}">${t.status}</span></td>
<td>
<button class="btn-cancel" data-ticket="${escapeHTML(t.ticketId)}" ${cancelled ? "disabled" : ""}>
${cancelled ? "Cancelled" : "Cancel"}
</button>
</td>
</tr>`;
})
.join("");
body.querySelectorAll(".btn-cancel:not([disabled])").forEach((btn) => {
btn.addEventListener("click", () => openCancelModal(btn.dataset.ticket));
});
}
// ===== 3.10.1 CANCEL CONFIRMATION =====
function openCancelModal(ticketId) {
pendingCancelId = ticketId;
document.getElementById("cancel-ticket-id").textContent = ticketId;
cancelModal.classList.add("is-open");
}
function closeCancelModal() {
pendingCancelId = null;
cancelModal.classList.remove("is-open");
}
document.getElementById("cancel-no").addEventListener("click", closeCancelModal);
cancelModal.addEventListener("click", (e) => {
if (e.target === cancelModal) closeCancelModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && cancelModal.classList.contains("is-open")) {
closeCancelModal();
}
});
document.getElementById("cancel-yes").addEventListener("click", () => {
if (!pendingCancelId) return;
const ok = DB.tickets.cancel(pendingCancelId);
closeCancelModal();
renderTickets();
renderAggregates();
showToast(ok ? "Ticket cancelled." : "Cancellation failed.", !ok);
});
// ===== 1. TABLE AGGREGATES =====
function renderAggregates() {
const user = DB.session.user();
if (!user) return;
const mine = DB.tickets.byUser(user.userId)
.concat(DB.tickets.byUser(user.username))
.filter((t, i, arr) => arr.findIndex((x) => x.ticketId === t.ticketId) === i);
const active = mine.filter((t) => t.status === "Active");
document.getElementById("stat-total").textContent = mine.length;
document.getElementById("stat-active").textContent = active.length;
document.getElementById("stat-cancelled").textContent = mine.length - active.length;
// 1.1 per class
const byClass = {};
active.forEach((t) => { byClass[t.category] = (byClass[t.category] || 0) + t.count; });
const totalSeats = Object.values(byClass).reduce((a, b) => a + b, 0);
const classBody = document.getElementById("class-agg-body");
classBody.innerHTML = totalSeats === 0
? `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`
: Object.entries(byClass)
.sort((a, b) => b[1] - a[1])
.map(([cat, n]) => {
const pct = Math.round((n / totalSeats) * 100);
return `<tr>
<td>${escapeHTML(cat)}</td>
<td>${n}</td>
<td><div class="bar-cell">
<div class="bar" style="width:${Math.max(pct * 1.4, 4)}px"></div>
<span>${pct}%</span>
</div></td>
</tr>`;
}).join("");
// 1.2 per quarter
const byQ = {};
active.forEach((t) => {
const d = new Date(t.bookedOn);
const q = `Q${Math.floor(d.getMonth() / 3) + 1} ${d.getFullYear()}`;
if (!byQ[q]) byQ[q] = { bookings: 0, seats: 0 };
byQ[q].bookings += 1;
byQ[q].seats += t.count;
});
const qKeys = Object.keys(byQ).sort();
const qBody = document.getElementById("quarter-agg-body");
qBody.innerHTML = qKeys.length === 0
? `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`
: qKeys.map((q) =>
`<tr><td>${q}</td><td>${byQ[q].bookings}</td><td>${byQ[q].seats}</td></tr>`
).join("");
}
// ===== 4. UPDATE DETAILS =====
const updateForm = document.getElementById("update-form");
const pwToggle = document.getElementById("up-toggle-pw");
const pwSection = document.getElementById("pw-section");
pwToggle.addEventListener("change", () => {
pwSection.classList.toggle("is-open", pwToggle.checked);
if (!pwToggle.checked) {
["up-current", "up-new", "up-confirm"].forEach((id) => {
const el = document.getElementById(id);
el.value = "";
el.classList.remove("invalid", "valid");
document.getElementById(id + "-err").textContent = "";
});
}
});
function loadUpdateForm() {
const user = DB.session.user();
if (!user) return;
document.getElementById("up-email").value = user.email || "";
document.getElementById("up-mobile").value = user.mobile || "";
document.getElementById("up-address").value = user.address || "";
}
// same rules as registration
attachLiveValidation("up-email", "up-email-err", (v) => {
if (v === "") return "Email is required.";
if (!v.includes("@")) return "Must contain an @ sign.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return "Must include a domain like gmail.com.";
return null;
});
attachLiveValidation("up-mobile", "up-mobile-err", (v) => {
if (v === "") return "Mobile number is required.";
if (/[A-Za-z]/.test(v)) return "Alphabets are not allowed.";
if (!/^[0-9]{10}$/.test(v)) return "Must be exactly 10 digits.";
return null;
});
attachLiveValidation("up-address", "up-address-err", (v) =>
v === "" ? "Address is required." : null
);
attachLiveValidation("up-current", "up-current-err", (v) =>
v === "" ? "Current password is required." : null
);
attachLiveValidation("up-new", "up-new-err", (v) => {
if (v === "") return "New password is required.";
if (v.length < 8) return "Must be at least 8 characters.";
if (!/[A-Z]/.test(v)) return "Needs at least 1 uppercase letter.";
if (!/[0-9]/.test(v)) return "Needs at least 1 number.";
if (!/[^A-Za-z0-9]/.test(v)) return "Needs at least 1 special character.";
return null;
});
attachLiveValidation("up-confirm", "up-confirm-err", (v) => {
if (v === "") return "Please confirm the new password.";
if (v !== document.getElementById("up-new").value) return "Passwords do not match.";
return null;
});
updateForm.addEventListener("submit", (e) => {
e.preventDefault();
let fields = [
["up-email", "up-email-err"],
["up-mobile", "up-mobile-err"],
["up-address", "up-address-err"],
];
if (pwToggle.checked) {
fields = fields.concat([
["up-current", "up-current-err"],
["up-new", "up-new-err"],
["up-confirm", "up-confirm-err"],
]);
}
if (!runValidation(fields)) {
showToast("Please fix the highlighted fields.", true);
return;
}
const user = DB.session.user();
if (!user) {
showToast("Session expired. Please log in again.", true);
setTimeout(() => (window.location.href = "index.html"), 1200);
return;
}
const patch = {
email: document.getElementById("up-email").value.trim(),
mobile: document.getElementById("up-mobile").value.trim(),
address: document.getElementById("up-address").value.trim(),
};
// 4.4 password change — verify current first
if (pwToggle.checked) {
if (user.password !== document.getElementById("up-current").value) {
setError("up-current-err", "Current password is incorrect.");
showToast("Current password is incorrect.", true);
return;
}
patch.password = document.getElementById("up-new").value;
}
const updated = DB.users.update(user.username, patch);
if (!updated) {
showToast("Update failed — storage error.", true);
return;
}
pwToggle.checked = false;
pwSection.classList.remove("is-open");
["up-current", "up-new", "up-confirm"].forEach((id) => {
document.getElementById(id).value = "";
});
clearFormState(updateForm);
showToast("✓ Details updated successfully.");
});
// ===== INIT =====
populateStations();
initSession();
loadUpdateForm();
renderAggregates();
renderTickets();
refreshTrains();
// journey date cannot be before tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById("bk-date").min = tomorrow.toISOString().split("T")[0];
/* =====================================================
TrainGo — Admin Portal (US_UI_003)
Requires store.js to be loaded first.
===================================================== */
const STATIONS = [
"New Delhi", "Mumbai Central", "Chennai Central", "Howrah Junction",
"Bengaluru City", "Hyderabad Deccan", "Pune Junction", "Ahmedabad Junction",
"Jaipur Junction", "Lucknow Charbagh", "Bhopal Junction", "Patna Junction",
];
const toast = document.getElementById("toast");
const confirmModal = document.getElementById("confirm-modal");
let pendingAction = null;
/* ===== UI HELPERS ===== */
function showToast(msg, isError = false) {
toast.textContent = msg;
toast.classList.toggle("toast--error", isError);
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 3000);
}
function setError(errId, msg) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = msg;
const f = el.parentElement.querySelector("input, select, textarea");
if (f) { f.classList.remove("valid"); f.classList.add("invalid"); }
}
function setValid(errId) {
const el = document.getElementById(errId);
if (!el) return;
el.textContent = "";
const f = el.parentElement.querySelector("input, select, textarea");
if (f) { f.classList.remove("invalid"); f.classList.add("valid"); }
}
function clearFormState(form) {
form.querySelectorAll(".error-msg").forEach((e) => (e.textContent = ""));
form.querySelectorAll("input, select, textarea")
.forEach((f) => f.classList.remove("invalid", "valid"));
}
function escapeHTML(str) {
const map = {
"&": "&", "<": "<", ">": ">",
'"': """, "'": "'",
};
return String(str).replace(/[&<>"']/g, (c) => map[c]);
}
/* ===== VALIDATORS ===== */
const validators = {};
function attachLiveValidation(inputId, errId, fn) {
validators[inputId] = fn;
const input = document.getElementById(inputId);
if (!input) return;
const evt = input.tagName === "SELECT" ? "change" : "input";
input.addEventListener(evt, () => {
const msg = fn(input.value.trim());
msg ? setError(errId, msg) : setValid(errId);
});
}
function runValidation(fields) {
let ok = true;
fields.forEach(([inputId, errId]) => {
const input = document.getElementById(inputId);
const fn = validators[inputId];
if (!input || !fn) return;
const msg = fn(input.value.trim());
if (msg) { setError(errId, msg); ok = false; }
else { setValid(errId); }
});
return ok;
}
/* ===== TABS ===== */
function showPanel(id) {
document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
document.getElementById(id)?.classList.add("active");
document.querySelectorAll(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.panel === id)
);
window.scrollTo({ top: 0, behavior: "smooth" });
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
const id = tab.dataset.panel;
showPanel(id);
if (id === "panel-home") renderDashboard();
if (id === "panel-train") renderTrains();
if (id === "panel-profile") loadProfile();
});
});
/* ===== ACCESS GUARD ===== */
function guardAdmin() {
const s = DB.session.get();
if (!s) { window.location.href = "index.html"; return false; }
if (s.role !== "admin") { window.location.href = "home.html"; return false; }
document.getElementById("current-user").textContent = s.username;
return true;
}
document.getElementById("logout-btn").addEventListener("click", () => {
DB.session.clear();
window.location.href = "index.html";
});
/* ===== CONFIRM MODAL ===== */
function openConfirm(title, text, onYes) {
document.getElementById("confirm-title").textContent = title;
document.getElementById("confirm-text").innerHTML = text;
pendingAction = onYes;
confirmModal.classList.add("is-open");
}
function closeConfirm() {
pendingAction = null;
confirmModal.classList.remove("is-open");
}
document.getElementById("confirm-no").addEventListener("click", closeConfirm);
document.getElementById("confirm-yes").addEventListener("click", () => {
const fn = pendingAction;
closeConfirm();
if (typeof fn === "function") fn();
});
confirmModal.addEventListener("click", (e) => {
if (e.target === confirmModal) closeConfirm();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && confirmModal.classList.contains("is-open")) closeConfirm();
});
/* ===== 1 + 2. DASHBOARD ===== */
function renderDashboard() {
const customers = DB.users.customers();
const active = DB.tickets.active();
document.getElementById("stat-customers").textContent = customers.length;
document.getElementById("stat-active").textContent = active.length;
document.getElementById("stat-trains").textContent = DB.trains.all().length;
/* 1.1 tickets per class — system wide */
const byClass = DB.tickets.countByClass();
const totalSeats = Object.values(byClass).reduce((a, b) => a + b, 0);
const classBody = document.getElementById("class-agg-body");
classBody.innerHTML = totalSeats === 0
? `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`
: Object.entries(byClass)
.sort((a, b) => b[1] - a[1])
.map(([cat, n]) => {
const pct = Math.round((n / totalSeats) * 100);
return `<tr>
<td>${escapeHTML(cat)}</td>
<td>${n}</td>
<td><div class="bar-cell">
<div class="bar" style="width:${Math.max(pct * 1.4, 4)}px"></div>
<span>${pct}%</span>
</div></td>
</tr>`;
}).join("");
/* 1.2 sales per quarter */
const byQ = DB.tickets.countByQuarter();
const qKeys = Object.keys(byQ).sort();
const qBody = document.getElementById("quarter-agg-body");
qBody.innerHTML = qKeys.length === 0
? `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`
: qKeys.map((q) =>
`<tr><td>${q}</td><td>${byQ[q].bookings}</td><td>${byQ[q].seats}</td></tr>`
).join("");
/* 2. customer records */
const custBody = document.getElementById("customers-body");
custBody.innerHTML = customers.length === 0
? `<tr><td colspan="5" class="empty-row">No customers registered yet.</td></tr>`
: customers.map((u) => {
const booked = active
.filter((t) => t.userId === u.userId || t.userName === u.username)
.reduce((sum, t) => sum + t.count, 0);
return `<tr>
<td><strong>${escapeHTML(u.userId || "—")}</strong></td>
<td>${escapeHTML(u.username)}</td>
<td>${escapeHTML(u.mobile || "—")}</td>
<td>${booked}</td>
<td><button class="btn-delete" data-user="${escapeHTML(u.username)}">Delete</button></td>
</tr>`;
}).join("");
custBody.querySelectorAll(".btn-delete").forEach((btn) => {
btn.addEventListener("click", () => {
const uname = btn.dataset.user;
openConfirm(
"Delete Customer?",
`This will permanently remove <strong>${escapeHTML(uname)}</strong> and all their tickets.`,
() => {
DB.users.remove(uname);
renderDashboard();
showToast(`Customer "${uname}" deleted.`);
}
);
});
});
}
/* ===== REGISTER A TRAIN ===== */
function populateStations() {
const from = document.getElementById("tr-from");
const to = document.getElementById("tr-to");
STATIONS.forEach((s) => {
from.insertAdjacentHTML("beforeend", `<option value="${s}">${s}</option>`);
to.insertAdjacentHTML("beforeend", `<option value="${s}">${s}</option>`);
});
}
attachLiveValidation("tr-name", "tr-name-err", (v) => {
if (v === "") return "Train name is required.";
if (v.length < 3) return "Must be at least 3 characters.";
return null;
});
attachLiveValidation("tr-seats", "tr-seats-err", (v) => {
if (v === "") return "Number of seats is required.";
if (!/^[0-9]+$/.test(v)) return "Digits only.";
const n = Number(v);
if (n < 1 || n > 2000) return "Enter between 1 and 2000.";
return null;
});
attachLiveValidation("tr-from", "tr-from-err", (v) =>
v === "" ? "Origin station is required." : null
);
attachLiveValidation("tr-to", "tr-to-err", (v) => {
if (v === "") return "Destination station is required.";
if (v === document.getElementById("tr-from").value)
return "Destination must differ from origin.";
return null;
});
attachLiveValidation("tr-depart", "tr-depart-err", (v) =>
v === "" ? "Departure time is required." : null
);
attachLiveValidation("tr-duration", "tr-duration-err", (v) => {
if (v === "") return "Duration is required.";
if (!/^[0-9]+(\.[0-9]+)?$/.test(v)) return "Numbers only (e.g. 16.5).";
const n = Number(v);
if (n <= 0 || n > 72) return "Enter between 0 and 72 hours.";
return null;
});
attachLiveValidation("tr-owner", "tr-owner-err", (v) =>
v === "" ? "Ownership is required." : null
);
const trainForm = document.getElementById("train-form");
trainForm.addEventListener("submit", (e) => {
e.preventDefault();
const fields = [
["tr-name", "tr-name-err"],
["tr-seats", "tr-seats-err"],
["tr-from", "tr-from-err"],
["tr-to", "tr-to-err"],
["tr-depart", "tr-depart-err"],
["tr-duration", "tr-duration-err"],
["tr-owner", "tr-owner-err"],
];
if (!runValidation(fields)) {
showToast("Please fix the highlighted fields.", true);
return;
}
const train = DB.trains.add({
name: document.getElementById("tr-name").value.trim(),
seats: Number(document.getElementById("tr-seats").value),
from: document.getElementById("tr-from").value,
to: document.getElementById("tr-to").value,
depart: document.getElementById("tr-depart").value,
duration: Number(document.getElementById("tr-duration").value),
owner: document.getElementById("tr-owner").value,
});
if (!train) {
showToast("Registration failed — storage error.", true);
return;
}
trainForm.reset();
clearFormState(trainForm);
renderTrains();
renderDashboard();
showToast(`✓ Train ${train.id} registered.`);
});
function renderTrains() {
const body = document.getElementById("trains-body");
const trains = DB.trains.all();
body.innerHTML = trains.length === 0
? `<tr><td colspan="8" class="empty-row">No trains registered yet.</td></tr>`
: trains.slice().reverse().map((t) => `
<tr>
<td><strong>${escapeHTML(t.id)}</strong></td>
<td>${escapeHTML(t.name)}</td>
<td>${t.seats}</td>
<td>${escapeHTML(t.from)}</td>
<td>${escapeHTML(t.to)}</td>
<td>${escapeHTML(t.depart)}</td>
<td>${escapeHTML(t.owner)}</td>
<td><button class="btn-delete" data-train="${escapeHTML(t.id)}">Remove</button></td>
</tr>`).join("");
body.querySelectorAll(".btn-delete").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.train;
openConfirm(
"Remove Train?",
`Train <strong>${escapeHTML(id)}</strong> will no longer be bookable.`,
() => {
DB.trains.remove(id);
renderTrains();
renderDashboard();
showToast(`Train ${id} removed.`);
}
);
});
});
}
/* ===== PROFILE ===== */
const profileForm = document.getElementById("profile-form");
const pfToggle = document.getElementById("pf-toggle-pw");
const pfSection = document.getElementById("pf-pw-section");
pfToggle.addEventListener("change", () => {
pfSection.classList.toggle("is-open", pfToggle.checked);
if (!pfToggle.checked) {
["pf-current", "pf-new", "pf-confirm"].forEach((id) => {
const el = document.getElementById(id);
el.value = "";
el.classList.remove("invalid", "valid");
document.getElementById(id + "-err").textContent = "";
});
}
});
function loadProfile() {
const admin = DB.session.user();
if (!admin) return;
document.getElementById("pf-username").value = admin.username || "";
document.getElementById("pf-email").value = admin.email || "";
document.getElementById("pf-mobile").value = admin.mobile || "";
}
attachLiveValidation("pf-username", "pf-username-err", (v) => {
if (v === "") return "Username is required.";
if (v.length < 6) return "Must be at least 6 characters.";
if (!/^[A-Za-z]+$/.test(v)) return "Letters only — no numbers or symbols.";
return null;
});
attachLiveValidation("pf-email", "pf-email-err", (v) => {
if (v === "") return "Email is required.";
if (!v.includes("@")) return "Must contain an @ sign.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return "Must include a domain like gmail.com.";
return null;
});
attachLiveValidation("pf-mobile", "pf-mobile-err", (v) => {
if (v === "") return "Mobile number is required.";
if (/[A-Za-z]/.test(v)) return "Alphabets are not allowed.";
if (!/^[0-9]{10}$/.test(v)) return "Must be exactly 10 digits.";
return null;
});
attachLiveValidation("pf-current", "pf-current-err", (v) =>
v === "" ? "Current password is required." : null
);
attachLiveValidation("pf-new", "pf-new-err", (v) => {
if (v === "") return "New password is required.";
if (v.length < 8) return "Must be at least 8 characters.";
if (!/[A-Z]/.test(v)) return "Needs at least 1 uppercase letter.";
if (!/[0-9]/.test(v)) return "Needs at least 1 number.";
if (!/[^A-Za-z0-9]/.test(v)) return "Needs at least 1 special character.";
return null;
});
attachLiveValidation("pf-confirm", "pf-confirm-err", (v) => {
if (v === "") return "Please confirm the new password.";
if (v !== document.getElementById("pf-new").value) return "Passwords do not match.";
return null;
});
profileForm.addEventListener("submit", (e) => {
e.preventDefault();
let fields = [
["pf-username", "pf-username-err"],
["pf-email", "pf-email-err"],
["pf-mobile", "pf-mobile-err"],
];
if (pfToggle.checked) {
fields = fields.concat([
["pf-current", "pf-current-err"],
["pf-new", "pf-new-err"],
["pf-confirm", "pf-confirm-err"],
]);
}
if (!runValidation(fields)) {
showToast("Please fix the highlighted fields.", true);
return;
}
const admin = DB.session.user();
if (!admin) {
showToast("Session expired. Please log in again.", true);
setTimeout(() => (window.location.href = "index.html"), 1200);
return;
}
const newUsername = document.getElementById("pf-username").value.trim();
if (newUsername.toLowerCase() !== admin.username.toLowerCase() &&
DB.users.exists(newUsername)) {
setError("pf-username-err", "Username already taken.");
return;
}
const patch = {
username: newUsername,
email: document.getElementById("pf-email").value.trim(),
mobile: document.getElementById("pf-mobile").value.trim(),
};
if (pfToggle.checked) {
if (admin.password !== document.getElementById("pf-current").value) {
setError("pf-current-err", "Current password is incorrect.");
showToast("Current password is incorrect.", true);
return;
}
patch.password = document.getElementById("pf-new").value;
}
// DB.users.update keeps the session and ticket ownership in sync
const updated = DB.users.update(admin.username, patch);
if (!updated) {
showToast("Update failed — username may be taken.", true);
return;
}
document.getElementById("current-user").textContent = updated.username;
pfToggle.checked = false;
pfSection.classList.remove("is-open");
["pf-current", "pf-new", "pf-confirm"].forEach((id) => {
document.getElementById(id).value = "";
});
clearFormState(profileForm);
showToast("✓ Profile updated successfully.");
});
/* delete own account */
document.getElementById("delete-self-btn").addEventListener("click", () => {
const s = DB.session.get();
if (!s) return;
openConfirm(
"Delete Your Account?",
`Account <strong>${escapeHTML(s.username)}</strong> will be permanently removed and you will be logged out.`,
() => {
DB.users.remove(s.username);
DB.session.clear();
window.location.href = "index.html";
}
);
});
/* ===== INIT ===== */
if (guardAdmin()) {
populateStations();
loadProfile();
renderDashboard();
renderTrains();
}