/* =====================================================
TrainGo — Customer Portal (US_UI_002)
===================================================== */
// ===== STORAGE KEYS =====
const USERS_KEY = "traingo_users";
const TICKETS_KEY = "traingo_tickets";
const SESSION_KEY = "traingo_session";
// ===== 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",
];
const TRAINS = [
{ id: "TR-12951", name: "Rajdhani Express", depart: "16:55", duration: 15.5 },
{ id: "TR-12009", name: "Shatabdi Express", depart: "06:00", duration: 7.0 },
{ id: "TR-12621", name: "Tamil Nadu Express", depart: "22:30", duration: 20.0 },
{ id: "TR-12301", name: "Howrah Rajdhani", depart: "16:able", duration: 17.0 },
{ id: "TR-12627", name: "Karnataka Express", depart: "20:15", duration: 22.5 },
{ id: "TR-12723", name: "Telangana Express", depart: "18:40", duration: 19.0 },
];
// ===== DOM =====
const toast = document.getElementById("toast");
const cancelModal = document.getElementById("cancel-modal");
let pendingCancelId = null;
// ===== STORAGE HELPERS =====
function readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function writeJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
showToast("Could not save — storage unavailable.", true);
return false;
}
}
const getUsers = () => readJSON(USERS_KEY, []);
const getTickets = () => readJSON(TICKETS_KEY, []);
const saveTickets = (t) => writeJSON(TICKETS_KEY, t);
function getSession() {
return readJSON(SESSION_KEY, null);
}
function currentUser() {
const session = getSession();
if (!session) return null;
return getUsers().find((u) => u.username === session.username) || 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"));
}
// ===== 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"));
const target = document.getElementById(panelId);
if (target) target.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 BOOTSTRAP =====
function initSession() {
const user = currentUser();
if (!user) {
// No session — fall back to a demo user so the page is usable standalone.
document.getElementById("current-user").textContent = "guest";
document.getElementById("bk-id").value = "USR-GUEST";
return null;
}
document.getElementById("current-user").textContent = user.username;
document.getElementById("bk-id").value = user.userId || "USR-" + user.username.toUpperCase().slice(0, 6);
const nameField = document.getElementById("bk-name");
if (nameField && !nameField.value) nameField.value = user.username;
return user;
}
document.getElementById("logout-btn").addEventListener("click", () => {
localStorage.removeItem(SESSION_KEY);
showToast("Logged out. Redirecting…");
setTimeout(() => (window.location.href = "index.html"), 900);
});
// ===== POPULATE 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 =====
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;
}
trainSelect.innerHTML = `<option value="">Select a train</option>`;
TRAINS.forEach((t) => {
trainSelect.insertAdjacentHTML(
"beforeend",
`<option value="${t.id}">${t.id} — ${t.name} (dep ${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 = TRAINS.find((t) => t.id === trainId);
const date = document.getElementById("bk-date").value;
const boardingDT = `${date} ${train.depart}`;
const arrivalDT = computeArrival(date, train.depart, train.duration);
const ticket = {
ticketId: "TKT-" + Date.now().toString().slice(-8),
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: Number(document.getElementById("bk-count").value),
status: "Active",
bookedOn: new Date().toISOString(),
};
const tickets = getTickets();
tickets.push(ticket);
if (!saveTickets(tickets)) 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] = 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(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 PAGE =====
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 tickets = getTickets();
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.hidden = false;
}
function closeCancelModal() {
pendingCancelId = null;
cancelModal.hidden = true;
}
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.hidden) closeCancelModal();
});
document.getElementById("cancel-yes").addEventListener("click", () => {
if (!pendingCancelId) return;
const tickets = getTickets();
const t = tickets.find((x) => x.ticketId === pendingCancelId);
if (t) {
t.status = "Cancelled";
t.cancelledOn = new Date().toISOString();
saveTickets(tickets);
}
closeCancelModal();
renderTickets();
renderAggregates();
showToast("Ticket cancelled.");
});
// ===== 1. TABLE AGGREGATES =====
function renderAggregates() {
const tickets = getTickets();
const active = tickets.filter((t) => t.status === "Active");
document.getElementById("stat-total").textContent = tickets.length;
document.getElementById("stat-active").textContent = active.length;
document.getElementById("stat-cancelled").textContent = tickets.length - active.length;
// 1.1 per class
const classBody = document.getElementById("class-agg-body");
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);
if (totalSeats === 0) {
classBody.innerHTML = `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`;
} else {
classBody.innerHTML = 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 qBody = document.getElementById("quarter-agg-body");
const byQuarter = {};
active.forEach((t) => {
const d = new Date(t.bookedOn);
const q = `Q${Math.floor(d.getMonth() / 3) + 1} ${d.getFullYear()}`;
if (!byQuarter[q]) byQuarter[q] = { bookings: 0, seats: 0 };
byQuarter[q].bookings += 1;
byQuarter[q].seats += t.count;
});
const qKeys = Object.keys(byQuarter).sort();
if (qKeys.length === 0) {
qBody.innerHTML = `<tr><td colspan="3" class="empty-row">No bookings yet.</td></tr>`;
} else {
qBody.innerHTML = qKeys
.map((q) => `
<tr>
<td>${q}</td>
<td>${byQuarter[q].bookings}</td>
<td>${byQuarter[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.hidden = !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 = currentUser();
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 users = getUsers();
const session = getSession();
const idx = users.findIndex((u) => u.username === (session && session.username));
if (idx === -1) {
showToast("No logged-in account found. Please log in again.", true);
return;
}
if (pwToggle.checked) {
if (users[idx].password !== document.getElementById("up-current").value) {
setError("up-current-err", "Current password is incorrect.");
showToast("Current password is incorrect.", true);
return;
}
users[idx].password = document.getElementById("up-new").value;
}
users[idx].email = document.getElementById("up-email").value.trim();
users[idx].mobile = document.getElementById("up-mobile").value.trim();
users[idx].address = document.getElementById("up-address").value.trim();
if (!writeJSON(USERS_KEY, users)) return;
pwToggle.checked = false;
pwSection.hidden = true;
["up-current", "up-new", "up-confirm"].forEach((id) => {
document.getElementById(id).value = "";
});
clearForm5 views