Dashboard
Available Balance
@@ -187,7 +80,7 @@Dashboard
Loyalty Points
(Cashback)
Dashboard
| + | Date | -+ | Description | -+ | Type | -+ | Amount |
|---|---|---|---|---|---|---|---|
| - Loading transactions... + Loading transactions... | |||||||
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 = ` -
Test card taps, fare deductions, and retail payments without physical hardware.
+| Time | +Type | +Amount | +Fee | +Status | +
|---|
No transactions yet
+Available Balance
@@ -187,7 +80,7 @@Loyalty Points
(Cashback)
| + | Date | -+ | Description | -+ | Type | -+ | Amount |
|---|---|---|---|---|---|---|---|
| - Loading transactions... + Loading transactions... | |||||||