diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index 7ad3c08..966cb38 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "strings" "unicard-go/backend/internal/admin" authentication "unicard-go/backend/internal/auth" "unicard-go/backend/internal/user" @@ -89,12 +90,7 @@ func main() { mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword) mux.HandleFunc("GET /{username}", userHandler.DashboardView) mux.HandleFunc("GET /v1/user/{username}", userHandler.DashboardHandler) - //mux.HandleFunc("GET /transaction", userHandler.TransactionView) - //mux.HandleFunc("GET /topup", userHandler.TopupView) - //mux.HandleFunc("GET /profile", userHandler.ProfileView) - //mux.HandleFunc("GET /settings", userHandler.SettingsView) - //mux.HandleFunc("GET /card", userHandler.CardView) - //mux.HandleFunc("GET /v1/user/transactions", userHandler.TransactionsJSONHandler) + mux.HandleFunc("GET /v1/user/{username}/transactions", userHandler.TransactionsJSONHandler) //mux.HandleFunc("GET /logout",) // super admin endpoints @@ -123,14 +119,28 @@ func main() { mux.HandleFunc("POST /v1/admin/{username}/deactivatecardauth", adminHanlder.DeactivateCardHanlder) mux.HandleFunc("POST /v1/admin/{username}/deletecardauth", adminHanlder.DeleteCardHandler) mux.HandleFunc("GET /admin/{username}/delete-cards", adminHanlder.DeleteCardView) - - + + // terminal simulation endpoints + mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) + mux.HandleFunc("POST /v1/terminal-sim/transact", adminHanlder.TerminalSimTransactionHandler) + // Wrap mux with custom handler for root redirect customHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } + + // Handle GET /{username}/transaction(s) manually to avoid ServeMux conflict with /assets/ + parts := strings.Split(r.URL.Path, "/") + if len(parts) == 3 && (parts[2] == "transaction" || parts[2] == "transactions") && r.Method == http.MethodGet { + if parts[1] != "assets" && parts[1] != "storage" && parts[1] != "v1" && parts[1] != "admin" { + r.SetPathValue("username", parts[1]) + userHandler.TransactionView(w, r) + return + } + } + mux.ServeHTTP(w, r) }) diff --git a/backend/internal/admin/terminal_sim.go b/backend/internal/admin/terminal_sim.go new file mode 100644 index 0000000..18cfc5f --- /dev/null +++ b/backend/internal/admin/terminal_sim.go @@ -0,0 +1,210 @@ +package admin + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "time" + + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +// TerminalSimView renders the terminal simulation page +func (h *Handler) TerminalSimView(w http.ResponseWriter, r *http.Request) { + fmt.Println("Terminal Simulation view is running...") + + type Merchant struct { + ID string + Name string + } + var merchants []Merchant + + rows, err := h.DB.Query("SELECT user_id, business_name FROM merchants") + if err == nil { + defer rows.Close() + for rows.Next() { + var m Merchant + if err := rows.Scan(&m.ID, &m.Name); err == nil { + merchants = append(merchants, m) + } + } + } + + data := struct { + Merchants []Merchant + }{ + Merchants: merchants, + } + + h.Tpl.ExecuteTemplate(w, "terminal_sim.html", data) +} + +// TerminalSimTransactionHandler handles simulated terminal transactions +func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("TerminalSimTransactionHandler is running...") + + type SimRequest struct { + CardNumber string `json:"card_number"` + Type string `json:"type"` + Amount float64 `json:"amount"` + MerchantID string `json:"merchant_id"` + } + + var req SimRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Invalid request payload", + }) + return + } + + if req.CardNumber == "" || req.Amount <= 0 || req.Type == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Missing required fields", + }) + return + } + + // 1. Check if card exists, is active, and linked to a user + var balance float64 + var status string + var userID sql.NullString + + err := h.DB.QueryRow(` + SELECT balance, status, user_id + FROM cards + WHERE card_number = ? + `, req.CardNumber).Scan(&balance, &status, &userID) + + if err != nil { + if err == sql.ErrNoRows { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Card not found", + }) + return + } + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Database error", + }) + return + } + + if status != "active" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Card is not active", + }) + return + } + + if !userID.Valid || userID.String == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Card is not linked to any user", + }) + return + } + + // 2. Check balance + if req.Type != "Refund" && balance < req.Amount { + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: false, + Message: fmt.Sprintf("Insufficient balance. Current balance: %.2f", balance), + }) + return + } + + // 2.5 Get merchant commission rate + var commissionRate float64 + err = h.DB.QueryRow("SELECT commission_rate FROM merchants WHERE user_id = ?", req.MerchantID).Scan(&commissionRate) + if err != nil { + commissionRate = 2.00 // default fallback + } + + serviceFee := req.Amount * (commissionRate / 100.0) + loyaltyPoints := req.Amount * 0.002 // 0.2% reward points + + // 3. Process Transaction (Start TX) + tx, err := h.DB.Begin() + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to start transaction", + }) + return + } + + // Deduct or add balance and adjust loyalty points based on type + if req.Type == "Refund" { + _, err = tx.Exec(`UPDATE cards SET balance = balance + ?, loyalty_points = loyalty_points - ? WHERE card_number = ?`, req.Amount, loyaltyPoints, req.CardNumber) + } else { + _, err = tx.Exec(`UPDATE cards SET balance = balance - ?, loyalty_points = loyalty_points + ? WHERE card_number = ?`, req.Amount, loyaltyPoints, req.CardNumber) + } + if err != nil { + tx.Rollback() + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to deduct balance", + }) + return + } + + // Insert transaction record + transactionID := fmt.Sprintf("TXN-SIM-%d", time.Now().UnixNano()) + + // Get a dummy terminal ID + var terminalID string + err = h.DB.QueryRow("SELECT terminal_id FROM terminals LIMIT 1").Scan(&terminalID) + if err != nil { + terminalID = "TRM-SIM-001" // Fallback if no terminals exist + } + + // Get a dummy processed_by user ID + var processedBy string + err = h.DB.QueryRow("SELECT user_id FROM users LIMIT 1").Scan(&processedBy) + if err != nil { + processedBy = "USR-SIM-001" // Fallback if no users exist + } + + // Determine transaction type + dbTransactionType := "payment" + if req.Type == "Refund" { + dbTransactionType = "refund" + } + + _, err = tx.Exec(` + INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, transactionID, req.CardNumber, req.MerchantID, terminalID, dbTransactionType, req.Amount, serviceFee, processedBy) + + if err != nil { + // If `merchant_id` is not nullable and causes error or id doesn't auto-increment + // let's try with dummy data if needed, but we rollback first + tx.Rollback() + fmt.Printf("Error inserting transaction: %v\n", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to record transaction", + }) + return + } + + if err = tx.Commit(); err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to commit transaction", + }) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Transaction successful", + "service_fee": serviceFee, + }) +} diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go index 1d605f8..04913e7 100644 --- a/backend/internal/user/dashboard.go +++ b/backend/internal/user/dashboard.go @@ -9,12 +9,14 @@ import ( jsonwrite "unicard-go/backend/internal/pkg/handler" ) -// Transaction struct represents a user's transaction for the dashboard view type Transaction struct { - Date string `json:"date" db:"date"` - Description string `json:"description" db:"description"` - Type string `json:"type" db:"transaction_type"` - Amount float64 `json:"amount" db:"transaction_amount"` + TransactionID string `json:"transaction_id"` + TerminalID string `json:"terminal_id"` + Date string `json:"date" db:"date"` + Time string `json:"time"` + Description string `json:"description" db:"description"` + Type string `json:"type" db:"transaction_type"` + Amount float64 `json:"amount" db:"transaction_amount"` } // DashboardUser info struct for the user dashboard view @@ -38,9 +40,14 @@ type DashboardUser struct { func (h *Handler) DashboardView(w http.ResponseWriter, r *http.Request) { fmt.Println("Dashboard view is running...") - // Check if session cookie is present (Removed) + username := r.PathValue("username") + data := struct { + Username string + }{ + Username: username, + } - h.Tpl.ExecuteTemplate(w, "dashboard.html", nil) + h.Tpl.ExecuteTemplate(w, "dashboard.html", data) } func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { @@ -128,11 +135,12 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { // Fetch recent transactions txnQuery := ` - SELECT t.created_at, m.business_name, t.transaction_type, t.amount + SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id FROM transactions t JOIN cards c ON t.card_number = c.card_number - JOIN merchants m ON t.merchant_id = m.id - WHERE c.user_id = ? + JOIN users u ON c.user_id = u.user_id + LEFT JOIN merchants m ON t.merchant_id = m.user_id + WHERE u.username = ? ORDER BY t.created_at DESC LIMIT 5 ` rows, err := h.DB.Query(txnQuery, userID) @@ -142,8 +150,15 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var t Transaction var createdAt string - if err := rows.Scan(&createdAt, &t.Description, &t.Type, &t.Amount); err == nil { + var businessName sql.NullString + if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID); err == nil { t.Date = formatDate(createdAt) + t.Time = formatTime(createdAt) + if businessName.Valid { + t.Description = businessName.String + } else { + t.Description = "Terminal Simulation" + } transactions = append(transactions, t) } } @@ -185,3 +200,18 @@ func formatDate(dbTime string) string { } return dbTime } + +func formatTime(dbTime string) string { + t, err := time.Parse("2006-01-02 15:04:05", dbTime) + if err == nil { + return t.Format("03:04 PM") + } + t2, err := time.Parse(time.RFC3339, dbTime) + if err == nil { + return t2.Format("03:04 PM") + } + if len(dbTime) > 10 { + return dbTime[11:16] + } + return "" +} diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go new file mode 100644 index 0000000..090d44f --- /dev/null +++ b/backend/internal/user/transaction.go @@ -0,0 +1,92 @@ +package user + +import ( + "database/sql" + "fmt" + "net/http" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +// TransactionView renders the transaction.html template +func (h *Handler) TransactionView(w http.ResponseWriter, r *http.Request) { + fmt.Println("Transaction view is running...") + + username := r.PathValue("username") + data := struct { + Username string + }{ + Username: username, + } + + h.Tpl.ExecuteTemplate(w, "transaction.html", data) +} + +// TransactionsJSONHandler returns the user's transactions as JSON +func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("TransactionsJSONHandler is running...") + + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "user is required", + }) + return + } + + // Fetch transactions + txnQuery := ` + SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id + FROM transactions t + JOIN cards c ON t.card_number = c.card_number + JOIN users u ON c.user_id = u.user_id + LEFT JOIN merchants m ON t.merchant_id = m.user_id + WHERE u.username = ? + ORDER BY t.created_at DESC + ` + rows, err := h.DB.Query(txnQuery, username) + + type TxnResponse struct { + TransactionID string `json:"transaction_id"` + TerminalID string `json:"terminal_id"` + Date string `json:"date"` + Time string `json:"time"` + Description string `json:"description"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + } + + var transactions []TxnResponse + if err == nil { + defer rows.Close() + for rows.Next() { + var t TxnResponse + var createdAt string + var businessName sql.NullString + if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID); err == nil { + t.Status = "Completed" + t.Date = formatDate(createdAt) // Uses formatDate from dashboard.go + t.Time = formatTime(createdAt) // Uses formatTime from dashboard.go + if businessName.Valid { + t.Description = businessName.String + } else { + t.Description = "Transaction" + } + transactions = append(transactions, t) + } + } + } else { + fmt.Printf("Error fetching transactions: %v\n", err) + } + + response := struct { + Success bool `json:"success"` + Transactions []TxnResponse `json:"transactions"` + }{ + Success: true, + Transactions: transactions, + } + + jsonwrite.WriteJSON(w, http.StatusOK, response) +} diff --git a/frontend/assets/js/card.js b/frontend/assets/js/card.js index 2780459..acb2358 100644 --- a/frontend/assets/js/card.js +++ b/frontend/assets/js/card.js @@ -95,7 +95,7 @@ document.addEventListener("DOMContentLoaded", function () { // --- Logic Update --- // Disable the "Report" button, but leave "Request Replacement" active. reportButton.disabled = true; - reportButton.innerHTML = ' Card Blocked'; + reportButton.innerHTML = ' Card Blocked'; reportButton.classList.add('opacity-50', 'cursor-not-allowed'); // Update the status badge @@ -130,7 +130,7 @@ document.addEventListener("DOMContentLoaded", function () { // --- Logic Update --- // Disable BOTH buttons since a replacement has been requested. reportButton.disabled = true; - reportButton.innerHTML = ' Card Blocked'; + reportButton.innerHTML = ' Card Blocked'; reportButton.classList.add('opacity-50', 'cursor-not-allowed'); replacementButton.disabled = true; @@ -185,7 +185,7 @@ document.addEventListener("DOMContentLoaded", function () { // Also, update the button states based on the fetched status if (currentDemoStatus === "Blocked" || currentDemoStatus === "Replaced" || currentDemoStatus === "Inactive") { reportButton.disabled = true; - reportButton.innerHTML = ' Card Blocked'; + reportButton.innerHTML = ' Card Blocked'; reportButton.classList.add('opacity-50', 'cursor-not-allowed'); } if (currentDemoStatus === "Replaced") { diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js index 4521b28..ce382c2 100644 --- a/frontend/assets/js/dashboard.js +++ b/frontend/assets/js/dashboard.js @@ -22,10 +22,10 @@ document.addEventListener("DOMContentLoaded", function () { // --- Sidebar Logic --- if (sidebar && sidebarOverlay && toggleButton && openIcon && closeIcon && mainContent) { - + function toggleSidebar() { sidebar.classList.toggle('-translate-x-full'); - mainContent.classList.toggle('md:pl-64'); + mainContent.classList.toggle('md:pl-72'); openIcon.classList.toggle('hidden'); closeIcon.classList.toggle('hidden'); if (window.innerWidth < 768) { @@ -33,12 +33,12 @@ document.addEventListener("DOMContentLoaded", function () { } } - toggleButton.addEventListener('click', function(e) { + toggleButton.addEventListener('click', function (e) { e.stopPropagation(); toggleSidebar(); }); - sidebarOverlay.addEventListener('click', function() { + sidebarOverlay.addEventListener('click', function () { toggleSidebar(); }); @@ -47,28 +47,27 @@ document.addEventListener("DOMContentLoaded", function () { navLinks.forEach(link => { link.addEventListener('click', () => { if (window.innerWidth < 768 && !closeIcon.classList.contains('hidden')) { - toggleSidebar(); + toggleSidebar(); } }); }); - + } else { console.error("Sidebar elements not found. Make sure all IDs are correct."); } // --- Profile Dropdown Logic --- if (profileButton && profileMenu) { - - profileButton.addEventListener('click', function(event) { + + profileButton.addEventListener('click', function (event) { event.stopPropagation(); profileMenu.classList.toggle('hidden'); }); - document.addEventListener('click', function(event) { - if (!profileMenu.classList.contains('hidden') && - !profileButton.contains(event.target) && - !profileMenu.contains(event.target)) - { + document.addEventListener('click', function (event) { + if (!profileMenu.classList.contains('hidden') && + !profileButton.contains(event.target) && + !profileMenu.contains(event.target)) { profileMenu.classList.add('hidden'); } }); @@ -80,7 +79,7 @@ document.addEventListener("DOMContentLoaded", function () { // --- Logout Modal Logic --- // Check for all required modal elements const modalElementsExist = logoutModal && logoutModalContent && closeModalButton && cancelModalButton && confirmLogoutButton; - + if (modalElementsExist) { // Function to open the modal @@ -98,14 +97,14 @@ document.addEventListener("DOMContentLoaded", function () { logoutModalContent.classList.add('scale-95', 'opacity-0'); logoutModalContent.classList.remove('scale-100', 'opacity-100'); logoutModal.classList.remove('opacity-100'); - + setTimeout(() => { logoutModal.classList.add('hidden'); }, 300); } // --- UPDATED: Attach to all logout buttons --- - + // 1. Sidebar Logout Button if (logoutButton) { logoutButton.addEventListener('click', (e) => { @@ -113,7 +112,7 @@ document.addEventListener("DOMContentLoaded", function () { openLogoutModal(); }); } - + // 2. Profile Dropdown Logout Button if (profileLogoutButton) { profileLogoutButton.addEventListener('click', (e) => { @@ -127,7 +126,7 @@ document.addEventListener("DOMContentLoaded", function () { // Close modal buttons closeModalButton.addEventListener('click', closeLogoutModal); cancelModalButton.addEventListener('click', closeLogoutModal); - + // Also close if clicking on the background overlay logoutModal.addEventListener('click', (e) => { if (e.target === logoutModal) { @@ -208,7 +207,7 @@ document.addEventListener("DOMContentLoaded", function () { if (profileViewEmail) profileViewEmail.innerText = data.email || ""; if (profileViewPhone) profileViewPhone.innerText = data.phone || ""; if (profileViewUsername) profileViewUsername.innerText = data.username || ""; - + if (profileEditName) profileEditName.value = data.name || ""; if (profileEditEmail) profileEditEmail.value = data.email || ""; if (profileEditPhone) profileEditPhone.value = data.phone || ""; @@ -244,23 +243,31 @@ document.addEventListener("DOMContentLoaded", function () { if (data.recent_transactions && data.recent_transactions.length > 0) { data.recent_transactions.forEach(tx => { const tr = document.createElement("tr"); - const isPayment = tx.type === "Payment"; + const isPayment = tx.type && tx.type.toLowerCase() === "payment"; const colorClass = isPayment ? "text-red-600" : "text-green-600"; const sign = isPayment ? "-" : "+"; const amount = Number(tx.amount).toFixed(2); + const displayType = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : ""; + + tr.className = "hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-100"; + tr.onclick = function() { + openTxnModal(tx); + }; tr.innerHTML = ` - - \${tx.date} + +
${tx.date}
+
${tx.time}
- - \${tx.description} + +
${tx.description}
+
ID: ${tx.transaction_id || 'N/A'}
- \${tx.type} + ${displayType} - - \${sign}₱\${amount} + + ${sign}₱${amount} `; transactionsBody.appendChild(tr); @@ -294,4 +301,50 @@ document.addEventListener("DOMContentLoaded", function () { // Call fetch on load fetchDashboardData(); + // --- Modal Logic --- + const txnModal = document.getElementById("txnModal"); + const txnModalContent = document.getElementById("txnModalContent"); + const closeTxnModalBtn = document.getElementById("closeTxnModalBtn"); + const closeTxnModalBottomBtn = document.getElementById("closeTxnModalBottomBtn"); + + if (txnModal && closeTxnModalBtn) { + closeTxnModalBtn.onclick = closeTxnModal; + closeTxnModalBottomBtn.onclick = closeTxnModal; + txnModal.onclick = function(e) { + if (e.target === txnModal) closeTxnModal(); + }; + } + + function openTxnModal(tx) { + if (!txnModal) return; + + document.getElementById("modalTxnId").textContent = tx.transaction_id || 'N/A'; + document.getElementById("modalTxnMerchant").textContent = tx.description || 'N/A'; + document.getElementById("modalTxnTerminal").textContent = tx.terminal_id || 'N/A'; + document.getElementById("modalTxnDate").textContent = `${tx.date} at ${tx.time}`; + document.getElementById("modalTxnType").textContent = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : "N/A"; + + const isPayment = tx.type && tx.type.toLowerCase() === "payment"; + const sign = isPayment ? "-" : "+"; + const colorClass = isPayment ? "text-red-600" : "text-green-600"; + const amtEl = document.getElementById("modalTxnAmount"); + amtEl.textContent = `${sign}₱${Number(tx.amount).toFixed(2)}`; + amtEl.className = `font-bold text-lg ${colorClass}`; + + txnModal.classList.remove('hidden'); + setTimeout(() => { + txnModal.classList.add('opacity-100'); + txnModalContent.classList.add('scale-100', 'opacity-100'); + txnModalContent.classList.remove('scale-95', 'opacity-0'); + }, 10); + } + + function closeTxnModal() { + txnModalContent.classList.add('scale-95', 'opacity-0'); + txnModalContent.classList.remove('scale-100', 'opacity-100'); + txnModal.classList.remove('opacity-100'); + setTimeout(() => { + txnModal.classList.add('hidden'); + }, 300); + } }); \ No newline at end of file diff --git a/frontend/assets/js/signup.js b/frontend/assets/js/signup.js index c19573e..5ef2f72 100644 --- a/frontend/assets/js/signup.js +++ b/frontend/assets/js/signup.js @@ -204,7 +204,7 @@ document.addEventListener("DOMContentLoaded", function () { try { createAccountBtn.disabled = true; - createAccountBtn.innerHTML = ' Creating Account...'; + createAccountBtn.innerHTML = ' Creating Account...'; const response = await fetch('/v1/signupauth', { method: 'POST', diff --git a/frontend/assets/js/terminal_sim.js b/frontend/assets/js/terminal_sim.js new file mode 100644 index 0000000..70c152f --- /dev/null +++ b/frontend/assets/js/terminal_sim.js @@ -0,0 +1,103 @@ +document.addEventListener('DOMContentLoaded', () => { + const simForm = document.getElementById('simForm'); + const submitBtn = document.getElementById('submitBtn'); + const simMessage = document.getElementById('simMessage'); + const activityTable = document.getElementById('activityTable'); + const emptyState = document.getElementById('emptyState'); + const activityCount = document.getElementById('activityCount'); + + let txCount = 0; + + simForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const cardNumber = document.getElementById('cardNumber').value.trim(); + const type = document.querySelector('input[name="type"]:checked').value; + const amount = parseFloat(document.getElementById('amount').value); + const merchantId = document.getElementById('merchantId').value; + + if(!cardNumber || !amount || !merchantId) return; + + // Loading state + const originalBtnText = submitBtn.innerHTML; + submitBtn.innerHTML = ` Processing...`; + submitBtn.disabled = true; + simMessage.classList.add('hidden'); + + try { + const res = await fetch('/v1/terminal-sim/transact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ card_number: cardNumber, type, amount, merchant_id: merchantId }) + }); + + const data = await res.json(); + + simMessage.classList.remove('hidden'); + if(data.success) { + if(type === 'Refund') { + simMessage.className = 'text-sm rounded-lg p-3 text-center bg-amber-50 text-amber-700 border border-amber-200 mt-4'; + simMessage.textContent = `Success: Refunded ₱${amount.toFixed(2)} (Reversed Fee: ₱${(data.service_fee || 0).toFixed(2)})`; + } else { + simMessage.className = 'text-sm rounded-lg p-3 text-center bg-green-50 text-green-700 border border-green-200 mt-4'; + simMessage.textContent = `Success: Deducted ₱${amount.toFixed(2)} (Fee: ₱${(data.service_fee || 0).toFixed(2)})`; + } + + // Add to table + addTxToTable(type, amount, data.service_fee || 0, 'Success'); + simForm.reset(); + document.querySelector('input[name="type"][value="Fare"]').checked = true; // reset radio + } else { + simMessage.className = 'text-sm rounded-lg p-3 text-center bg-red-50 text-red-700 border border-red-200 mt-4'; + simMessage.textContent = data.message || 'Transaction failed'; + addTxToTable(type, amount, 0, 'Failed'); + } + } catch(err) { + console.error(err); + simMessage.classList.remove('hidden'); + simMessage.className = 'text-sm rounded-lg p-3 text-center bg-red-50 text-red-700 border border-red-200 mt-4'; + simMessage.textContent = 'Network error occurred.'; + addTxToTable(type, amount, 0, 'Error'); + } finally { + submitBtn.innerHTML = originalBtnText; + submitBtn.disabled = false; + } + }); + + function addTxToTable(type, amount, fee, status) { + emptyState.style.display = 'none'; + txCount++; + activityCount.textContent = `${txCount} transaction${txCount > 1 ? 's' : ''}`; + + const row = document.createElement('tr'); + row.className = "hover:bg-slate-50 transition"; + + const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second:'2-digit' }); + + let statusBadge = ''; + if(status === 'Success') { + statusBadge = `Success`; + } else { + statusBadge = `${status}`; + } + + let typeIcon = ''; + if (type === 'Fare') { + typeIcon = ``; + } else if (type === 'Refund') { + typeIcon = ``; + } else { + typeIcon = ``; + } + + row.innerHTML = ` + ${timeStr} + ${typeIcon} ${type} + ₱${amount.toFixed(2)} + ₱${fee.toFixed(2)} + ${statusBadge} + `; + + activityTable.prepend(row); // Add to top + } +}); diff --git a/frontend/assets/js/transaction.js b/frontend/assets/js/transaction.js index 051918d..8b4e88e 100644 --- a/frontend/assets/js/transaction.js +++ b/frontend/assets/js/transaction.js @@ -1,8 +1,42 @@ document.addEventListener("DOMContentLoaded", function () { const transactionsBody = document.getElementById("transactions-table-body"); + let allTransactions = []; + let currentPage = 1; + const itemsPerPage = 10; + + // DOM Elements + const searchTxn = document.getElementById("searchTxn"); + const filterType = document.getElementById("filterType"); + const filterStatus = document.getElementById("filterStatus"); + const sortOrder = document.getElementById("sortOrder"); + const pageStart = document.getElementById("pageStart"); + const pageEnd = document.getElementById("pageEnd"); + const totalItems = document.getElementById("totalItems"); + const prevPageBtn = document.getElementById("prevPageBtn"); + const nextPageBtn = document.getElementById("nextPageBtn"); + + // Event Listeners for Filters + if (searchTxn) searchTxn.addEventListener("input", () => { currentPage = 1; renderTransactions(); }); + if (filterType) filterType.addEventListener("change", () => { currentPage = 1; renderTransactions(); }); + if (filterStatus) filterStatus.addEventListener("change", () => { currentPage = 1; renderTransactions(); }); + if (sortOrder) sortOrder.addEventListener("change", () => { currentPage = 1; renderTransactions(); }); + if (prevPageBtn) prevPageBtn.addEventListener("click", () => { if (currentPage > 1) { currentPage--; renderTransactions(); } }); + if (nextPageBtn) nextPageBtn.addEventListener("click", () => { currentPage++; renderTransactions(); }); + function loadTransactions() { - fetch("/v1/user/transactions") + const pathSegments = window.location.pathname.split('/'); + let userId = null; + if (pathSegments.length >= 2 && pathSegments[1] !== '') { + userId = pathSegments[1]; + } + + let endpoint = "/v1/user/transactions"; + if (userId) { + endpoint = "/v1/user/" + encodeURIComponent(userId) + "/transactions"; + } + + fetch(endpoint) .then(response => { if (response.status === 401) { window.location.href = "/login"; @@ -15,48 +49,8 @@ document.addEventListener("DOMContentLoaded", function () { showError(); return; } - - transactionsBody.innerHTML = ""; - const txs = data.transactions; - - if (txs && txs.length > 0) { - txs.forEach(tx => { - const tr = document.createElement("tr"); - const isPayment = tx.type === "Payment"; - const colorClass = isPayment ? "text-red-600" : "text-green-600"; - const sign = isPayment ? "-" : "+"; - const amount = Number(tx.amount).toFixed(2); - - // We do not have a running balance per transaction from the DB currently, - // so we will just show a dash or N/A in the balance column for now. - tr.innerHTML = ` - - ${tx.date} - - - ${tx.description} - - - ${tx.type} - - - ${sign}₱${amount} - - - - - - `; - transactionsBody.appendChild(tr); - }); - } else { - transactionsBody.innerHTML = ` - - - No transactions found. - - - `; - } + allTransactions = data.transactions || []; + renderTransactions(); }) .catch(error => { console.error("Error loading transactions:", error); @@ -64,6 +58,128 @@ document.addEventListener("DOMContentLoaded", function () { }); } + function renderTransactions() { + if (!transactionsBody) return; + + let filtered = [...allTransactions]; + + // Apply Search + const searchVal = searchTxn ? searchTxn.value.toLowerCase() : ""; + if (searchVal) { + filtered = filtered.filter(tx => + (tx.description && tx.description.toLowerCase().includes(searchVal)) || + (tx.transaction_id && tx.transaction_id.toLowerCase().includes(searchVal)) || + (tx.terminal_id && tx.terminal_id.toLowerCase().includes(searchVal)) + ); + } + + // Apply Type Filter + const typeVal = filterType ? filterType.value.toLowerCase() : ""; + if (typeVal) { + filtered = filtered.filter(tx => tx.type && tx.type.toLowerCase() === typeVal); + } + + // Apply Status Filter + const statusVal = filterStatus ? filterStatus.value.toLowerCase() : ""; + if (statusVal) { + filtered = filtered.filter(tx => tx.status && tx.status.toLowerCase() === statusVal); + } + + // Apply Sort + const sortVal = sortOrder ? sortOrder.value : "desc"; + filtered.sort((a, b) => { + const dateA = new Date(a.date + " " + a.time).getTime() || 0; + const dateB = new Date(b.date + " " + b.time).getTime() || 0; + return sortVal === "asc" ? dateA - dateB : dateB - dateA; + }); + + // Pagination setup + const total = filtered.length; + const totalPages = Math.ceil(total / itemsPerPage) || 1; + if (currentPage > totalPages) currentPage = totalPages; + + const startIdx = (currentPage - 1) * itemsPerPage; + const endIdx = Math.min(startIdx + itemsPerPage, total); + const paginated = filtered.slice(startIdx, endIdx); + + // Update Pagination UI + if (pageStart) pageStart.textContent = total === 0 ? 0 : startIdx + 1; + if (pageEnd) pageEnd.textContent = endIdx; + if (totalItems) totalItems.textContent = total; + if (prevPageBtn) prevPageBtn.disabled = currentPage <= 1; + if (nextPageBtn) nextPageBtn.disabled = currentPage >= totalPages; + + transactionsBody.innerHTML = ""; + + if (paginated.length > 0) { + paginated.forEach(tx => { + const tr = document.createElement("tr"); + const isPayment = tx.type && tx.type.toLowerCase() === "payment"; + const colorClass = isPayment ? "text-red-600" : "text-green-600"; + const sign = isPayment ? "-" : "+"; + const amount = Number(tx.amount).toFixed(2); + const displayType = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : ""; + + let txDate = "N/A"; + let txTime = ""; + if (tx.date) { + const d = new Date(tx.date); + if (!isNaN(d.getTime())) { + txDate = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + txTime = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } else { + txDate = tx.date; + txTime = tx.time || ""; + } + } + const status = tx.status || "Completed"; + + let statusColor = "bg-green-100 text-green-800"; + if (status.toLowerCase() === "pending") { + statusColor = "bg-yellow-100 text-yellow-800"; + } else if (status.toLowerCase() === "failed") { + statusColor = "bg-red-100 text-red-800"; + } + + tr.className = "hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-100"; + tr.onclick = function() { + openTxnModal(tx, txDate, txTime); + }; + + tr.innerHTML = ` + +
${txDate}
+
${txTime}
+ + +
${tx.description}
+
ID: ${tx.transaction_id || 'N/A'}
+ + + ${displayType} + + + ${sign}₱${amount} + + + + ${status} + + + `; + transactionsBody.appendChild(tr); + }); + } else { + transactionsBody.innerHTML = ` + + + No transactions found. + + + `; + } + } + function showError() { if (transactionsBody) { transactionsBody.innerHTML = ` @@ -77,4 +193,51 @@ document.addEventListener("DOMContentLoaded", function () { } loadTransactions(); + + // --- Modal Logic --- + const txnModal = document.getElementById("txnModal"); + const txnModalContent = document.getElementById("txnModalContent"); + const closeTxnModalBtn = document.getElementById("closeTxnModalBtn"); + const closeTxnModalBottomBtn = document.getElementById("closeTxnModalBottomBtn"); + + if (txnModal && closeTxnModalBtn) { + closeTxnModalBtn.onclick = closeTxnModal; + closeTxnModalBottomBtn.onclick = closeTxnModal; + txnModal.onclick = function(e) { + if (e.target === txnModal) closeTxnModal(); + }; + } + + function openTxnModal(tx, txDate, txTime) { + if (!txnModal) return; + + document.getElementById("modalTxnId").textContent = tx.transaction_id || 'N/A'; + document.getElementById("modalTxnMerchant").textContent = tx.description || 'N/A'; + document.getElementById("modalTxnTerminal").textContent = tx.terminal_id || 'N/A'; + document.getElementById("modalTxnDate").textContent = `${txDate} at ${txTime}`; + document.getElementById("modalTxnType").textContent = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : "N/A"; + + const isPayment = tx.type && tx.type.toLowerCase() === "payment"; + const sign = isPayment ? "-" : "+"; + const colorClass = isPayment ? "text-red-600" : "text-green-600"; + const amtEl = document.getElementById("modalTxnAmount"); + amtEl.textContent = `${sign}₱${Number(tx.amount).toFixed(2)}`; + amtEl.className = `font-bold text-lg ${colorClass}`; + + txnModal.classList.remove('hidden'); + setTimeout(() => { + txnModal.classList.add('opacity-100'); + txnModalContent.classList.add('scale-100', 'opacity-100'); + txnModalContent.classList.remove('scale-95', 'opacity-0'); + }, 10); + } + + function closeTxnModal() { + txnModalContent.classList.add('scale-95', 'opacity-0'); + txnModalContent.classList.remove('scale-100', 'opacity-100'); + txnModal.classList.remove('opacity-100'); + setTimeout(() => { + txnModal.classList.add('hidden'); + }, 300); + } }); diff --git a/frontend/templates/admin/terminal_sim.html b/frontend/templates/admin/terminal_sim.html new file mode 100644 index 0000000..498340f --- /dev/null +++ b/frontend/templates/admin/terminal_sim.html @@ -0,0 +1,123 @@ + + + + + + Terminal Simulation - UniCard + + + + + + + + + +
+
+

Terminal Simulator

+

Test card taps, fare deductions, and retail payments without physical hardware.

+
+ +
+ +
+

+ + + + Simulate Tap +

+
+
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ + + + +
+
+ + +
+
+

Session Activity

+ 0 transactions +
+
+ + + + + + + + + + + + + +
TimeTypeAmountFeeStatus
+
+ + + +

No transactions yet

+
+
+
+
+
+ + + + diff --git a/frontend/templates/customer/card.html b/frontend/templates/customer/card.html index 7ae7e90..8531824 100644 --- a/frontend/templates/customer/card.html +++ b/frontend/templates/customer/card.html @@ -9,7 +9,6 @@ - +{{end}} diff --git a/frontend/templates/customer/dashboard.html b/frontend/templates/customer/dashboard.html index b994874..74287a5 100644 --- a/frontend/templates/customer/dashboard.html +++ b/frontend/templates/customer/dashboard.html @@ -9,126 +9,19 @@ - - +
- - - - - - - - - - - - - - + {{template "customer_sidebar" .}} -
+
-
- -
+
@@ -145,17 +38,17 @@ class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-xl border border-gray-200 z-50"> @@ -176,7 +69,7 @@

Dashboard

- +

Available Balance

@@ -187,7 +80,7 @@

Dashboard

- +

Loyalty Points
(Cashback)

@@ -198,7 +91,7 @@

Dashboard

- +

Account Type

@@ -214,23 +107,23 @@

Dashboard

Recent Transactions

See all - +
- +
- - - - @@ -238,7 +131,7 @@

Recent Transactions

@@ -256,9 +149,9 @@

Recent Transactions

+ + + - + diff --git a/frontend/templates/customer/forgot-password.html b/frontend/templates/customer/forgot-password.html index 526afad..3ade62d 100644 --- a/frontend/templates/customer/forgot-password.html +++ b/frontend/templates/customer/forgot-password.html @@ -14,7 +14,6 @@ - @@ -67,7 +66,7 @@

Forgot Passwor
- + Forgot Passwor
- + @@ -160,7 +159,7 @@

Forgot Passwor
- + @@ -180,23 +179,23 @@

Forgot Passwor
- + 8+ characters
- + Upper & lowercase
- + Number
- + Special char
- + Passwords match
diff --git a/frontend/templates/customer/profile.html b/frontend/templates/customer/profile.html index b9d3050..a8f079a 100644 --- a/frontend/templates/customer/profile.html +++ b/frontend/templates/customer/profile.html @@ -9,7 +9,6 @@ - @@ -57,17 +56,17 @@ class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-xl border border-gray-200 z-50"> @@ -92,8 +91,7 @@
  • - + Dashboard @@ -102,8 +100,7 @@
  • - + Transactions @@ -112,8 +109,7 @@
  • - + My Card @@ -122,8 +118,7 @@
  • - + Profile @@ -132,7 +127,7 @@
  • - + Top Up
  • @@ -141,8 +136,7 @@
  • - + Settings @@ -154,8 +148,7 @@
    @@ -173,7 +166,7 @@

    Settings

    - +

    Page Under Maintenance

    @@ -183,7 +176,7 @@

    Page Under Maintenance

    - + Go Back to Dashboard
    diff --git a/frontend/templates/customer/signup.html b/frontend/templates/customer/signup.html index c53fb6d..648a58c 100644 --- a/frontend/templates/customer/signup.html +++ b/frontend/templates/customer/signup.html @@ -9,7 +9,6 @@ - @@ -38,7 +37,7 @@

    Create your account

    - + Create your account
  • - + Create your account

    - + Create your account

    - + Create your account
    - + Create your account
    - + Create your account
    - + Create your account
    - + At least 8 characters
    - + Passwords match
    @@ -247,7 +246,7 @@

    Welcome to Unicard

    class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 hidden">
    - +

    Account Created!

    diff --git a/frontend/templates/customer/topup.html b/frontend/templates/customer/topup.html index 09c8601..e4048fb 100644 --- a/frontend/templates/customer/topup.html +++ b/frontend/templates/customer/topup.html @@ -9,7 +9,6 @@ - @@ -57,17 +56,17 @@ class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-xl border border-gray-200 z-50">

    @@ -91,8 +90,7 @@
  • - + Dashboard @@ -101,8 +99,7 @@
  • - + Transactions @@ -111,8 +108,7 @@
  • - + My Card @@ -121,8 +117,7 @@
  • - + Profile @@ -131,7 +126,7 @@
  • - + Top Up
  • @@ -140,8 +135,7 @@
  • - + Settings @@ -153,8 +147,7 @@
    @@ -272,7 +265,7 @@

    Select Payment Method

    Online Bank Transfer - +
    -->
  • diff --git a/frontend/templates/customer/transaction.html b/frontend/templates/customer/transaction.html index f4c494d..3494f97 100644 --- a/frontend/templates/customer/transaction.html +++ b/frontend/templates/customer/transaction.html @@ -9,125 +9,19 @@ - - +
    - - - - - - - - - - - - - - + {{template "customer_sidebar" .}} -
    +
    -
    -
    - +
    @@ -144,17 +38,17 @@ class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-xl border border-gray-200 z-50"> @@ -171,32 +65,34 @@

    Transaction History

    -
    - -
    - - +
    +
    + + + + + +
    - -
    - - + + + + + + + + -
    - -
    -
    @@ -209,7 +105,7 @@

    Transaction History

    @@ -261,23 +157,23 @@

    Transaction History

    @@ -327,6 +223,50 @@

    Confirm Logout

    + + + + diff --git a/tmp_app.exe b/tmp_app.exe new file mode 100644 index 0000000..e8b9053 Binary files /dev/null and b/tmp_app.exe differ
    + Date + Description + Type + Amount
    - Loading transactions... + Loading transactions...
    - Date & Time + Date @@ -225,7 +121,7 @@

    Transaction History

    - Balance + Status