From 2bd34a87d45323d8bb288875e9cda067f0a79bf2 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:29:22 +0800 Subject: [PATCH] feat: implement user dashboard and card management system with status toggling and replacement requests --- backend/cmd/app/main.go | 30 +++++++- backend/internal/user/customer_card.go | 86 +++++++++++++++++++++- backend/internal/user/dashboard.go | 8 +- backend/internal/user/transaction.go | 11 ++- frontend/assets/js/customer_card.js | 25 ++++++- frontend/assets/js/dashboard.js | 12 +++ frontend/templates/customer/dashboard.html | 20 ++++- go.mod | 2 +- 8 files changed, 179 insertions(+), 15 deletions(-) diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index 74e3e9c..c31a61f 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -97,6 +97,7 @@ func main() { mux.Handle("GET /u/{username}/dashboard", requireCustomer(http.HandlerFunc(userHandler.DashboardView))) mux.Handle("GET /u/{username}/card", requireCustomer(http.HandlerFunc(userHandler.CardView))) mux.Handle("POST /v1/user/{username}/card/status", requireCustomer(http.HandlerFunc(userHandler.UpdateCardStatus))) + mux.Handle("POST /v1/user/{username}/card/replace", requireCustomer(http.HandlerFunc(userHandler.RequestReplacement))) mux.Handle("GET /u/{username}/settings", requireCustomer(http.HandlerFunc(userHandler.SettingsView))) mux.Handle("GET /u/{username}/topup", requireCustomer(http.HandlerFunc(userHandler.TopUpView))) // Your frontend calls this to get the Xendit URL @@ -176,9 +177,36 @@ func main() { mux.ServeHTTP(w, r) }) + // Wrap with CORS middleware + handler := corsMiddleware(customHandler) + // Start Server fmt.Println("Server started on: http://" + serverAddress + ":" + port) - if err := http.ListenAndServe(serverAddress+":"+port, customHandler); err != nil { + if err := http.ListenAndServe(serverAddress+":"+port, handler); err != nil { log.Fatal(err) } } +func corsMiddleware(next http.Handler) http.Handler { + allowedOrigins := map[string]bool{ + os.Getenv("CORS_ALLOWED_ORIGINS"): true, // Load from .env + //"http://localhost:5173": true, // Vue dev + //"http://localhost:3001": true, // Go dev + //"https://unicard.app": true, // production + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if allowedOrigins[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/user/customer_card.go b/backend/internal/user/customer_card.go index a86d178..d30ecd7 100644 --- a/backend/internal/user/customer_card.go +++ b/backend/internal/user/customer_card.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "time" + jsonwrite "unicard-go/backend/internal/pkg/handler" ) func (h *Handler) CardView(w http.ResponseWriter, r *http.Request) { @@ -21,7 +23,7 @@ func (h *Handler) CardView(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") - + var req struct { Status string `json:"status"` } @@ -52,3 +54,85 @@ func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) } + +func (h *Handler) RequestReplacement(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + + var userID string + err := h.Store.QueryRow("SELECT user_id FROM users WHERE username = ?", username).Scan(&userID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{ + Success: false, + Message: "User not found", + }) + return + } + + var balance float64 + var cardNumber string + err = h.Store.QueryRow("SELECT balance, card_number FROM cards WHERE user_id = ?", userID).Scan(&balance, &cardNumber) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{ + Success: false, + Message: "Card not found", + }) + return + } + + if balance < 150.0 { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Insufficient balance. Replacement fee is 150 PHP.", + }) + return + } + + // Deduct balance and set card to locked (represented as 'blocked') + // Using ExecTx if available, or just manual Begin + tx, err := h.Store.Begin() + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Database error", + }) + return + } + defer tx.Rollback() + + _, err = tx.Exec("UPDATE cards SET balance = balance - 150.0, status = 'blocked' WHERE user_id = ?", userID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to update card", + }) + return + } + + // Insert transaction record for the fee + txnID := fmt.Sprintf("REP-%s-%d", cardNumber, time.Now().Unix()) + _, err = tx.Exec(` + INSERT INTO transactions (transaction_id, card_number, transaction_type, amount, status, description) + VALUES (?, ?, 'payment', 150.0, 'completed', 'Card Replacement Fee') + `, txnID, cardNumber) + + if err != nil { + 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, jsonwrite.APIResponse{ + Success: true, + Message: "Card replacement requested successfully. Card is now locked.", + }) +} diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go index ea79804..e3ab11d 100644 --- a/backend/internal/user/dashboard.go +++ b/backend/internal/user/dashboard.go @@ -42,6 +42,7 @@ type DashboardUser struct { CardNumber string `json:"card_number"` CardExpiry string `json:"card_expiry"` CardStatus string `json:"card_status"` + UserStatus string `json:"user_status"` RecentTransactions []Transaction `json:"recent_transactions"` // Add recent transactions to the dashboard response } @@ -99,13 +100,15 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { COALESCE(c.loyalty_points, 0), COALESCE(c.card_number, ''), COALESCE(c.expiry_date, ''), - COALESCE(c.status, '') + COALESCE(c.status, ''), + COALESCE(u.status, '') FROM users u LEFT JOIN cards c ON u.user_id = c.user_id WHERE u.username = ? ` - err := h.Store.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus) + var userStatus string + err := h.Store.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus, &userStatus) if err != nil { if err == sql.ErrNoRows { fmt.Printf("User %s not found in DB\n", userID) @@ -223,6 +226,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { CardNumber: cardNumber, CardExpiry: expiryStr, CardStatus: cardStatus, + UserStatus: userStatus, RecentTransactions: transactions, } diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go index 560eb85..1b3fd20 100644 --- a/backend/internal/user/transaction.go +++ b/backend/internal/user/transaction.go @@ -39,8 +39,7 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request SELECT t.transaction_id, COALESCE(t.terminal_id, ''), - DATE(t.created_at) as date, - TIME(t.created_at) as time, + t.created_at, COALESCE(t.transaction_type, ''), t.amount, COALESCE(t.status, ''), @@ -86,11 +85,17 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request var pointsEarned decimal.Decimal var cardNumber string - err := rows.Scan(&t.TransactionID, &t.TerminalID, &t.Date, &t.Time, &t.Type, &t.Amount, &t.Status, &description, &businessName, &merchantId, &pointsEarned, &cardNumber) + var createdAt string + + err := rows.Scan(&t.TransactionID, &t.TerminalID, &createdAt, &t.Type, &t.Amount, &t.Status, &description, &businessName, &merchantId, &pointsEarned, &cardNumber) if err != nil { fmt.Printf("Error scanning transaction row: %v\n", err) continue } + + t.Date = formatDate(createdAt) + t.Time = formatTime(createdAt) + t.Description = description if businessName != "" { diff --git a/frontend/assets/js/customer_card.js b/frontend/assets/js/customer_card.js index cdd0180..e9521ad 100644 --- a/frontend/assets/js/customer_card.js +++ b/frontend/assets/js/customer_card.js @@ -72,6 +72,26 @@ document.addEventListener("DOMContentLoaded", function () { }); } + function requestReplacementAPI(onSuccess) { + if (!userId) return; + fetch(`/v1/user/${encodeURIComponent(userId)}/card/replace`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(res => res.json().then(data => ({ status: res.status, ok: res.ok, data }))) + .then(res => { + if (res.ok && res.data.success) { + onSuccess(); + } else { + alert(res.data.message || "Failed to request replacement. Please try again."); + } + }) + .catch(err => { + console.error(err); + alert("Error requesting replacement."); + }); + } + // --- Card Toggle & Copy Logic --- const toggleCardBtn = document.getElementById("toggle-card-btn"); const copyCardBtn = document.getElementById("copy-card-btn"); @@ -242,8 +262,7 @@ document.addEventListener("DOMContentLoaded", function () { if (confirmReplacementButton) { confirmReplacementButton.addEventListener('click', () => { - // "Request Replacement will turn the card into status 'lost'" - updateCardStatusAPI('lost', () => { + requestReplacementAPI(() => { reportButton.disabled = true; reportButton.innerHTML = ' Card Blocked'; reportButton.classList.add('opacity-50', 'cursor-not-allowed'); @@ -252,7 +271,7 @@ document.addEventListener("DOMContentLoaded", function () { replacementButton.textContent = 'Replacement Requested'; replacementButton.classList.add('opacity-50', 'cursor-not-allowed'); - setCardStatus("Lost"); + setCardStatus("Blocked"); closeModal(replacementModal, replacementModalContent); }); }); diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js index 963afa3..2aa2b16 100644 --- a/frontend/assets/js/dashboard.js +++ b/frontend/assets/js/dashboard.js @@ -278,6 +278,15 @@ document.addEventListener("DOMContentLoaded", function () { const amount = Number(tx.amount).toFixed(2); const displayType = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : ""; + let statusHtml = ""; + if (tx.status) { + const statusVal = tx.status.toLowerCase(); + const statusColor = statusVal === "completed" ? "bg-green-100 text-green-800" : + statusVal === "pending" ? "bg-yellow-100 text-yellow-800" : + "bg-red-100 text-red-800"; + statusHtml = `${tx.status}`; + } + tr.className = "hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-100"; tr.onclick = function () { openTxnModal(tx); @@ -298,6 +307,9 @@ document.addEventListener("DOMContentLoaded", function () {
| Date | + class="w-[30%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"> Description | + class="w-[15%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"> Type | + class="w-[15%] px-6 py-3.5 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"> Amount | ++ Status + | + | + + |
|---|---|---|---|---|
| + | + + | |||
| + | + + |