diff --git a/.gitignore b/.gitignore
index f6c4657..dc928a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
#ignoring the enviroment
.env
-.unknown
\ No newline at end of file
+.unknown
+
+storage/*
+!storage/.gitkeep
+storage/**/*
+!storage/**/.gitkeep
\ No newline at end of file
diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go
index a740a94..7ad3c08 100644
--- a/backend/cmd/app/main.go
+++ b/backend/cmd/app/main.go
@@ -68,11 +68,17 @@ func main() {
fileServer := http.FileServer(http.Dir("./frontend/assets"))
mux.Handle("/assets/", http.StripPrefix("/assets/", fileServer))
+ // Serve storage directory for uploaded documents and images (locally stored)
+ storageServer := http.FileServer(http.Dir("./storage"))
+ mux.Handle("/storage/", http.StripPrefix("/storage/", storageServer))
+
// general endpoints
mux.HandleFunc("GET /login", authHandler.LoginView)
mux.HandleFunc("GET /signup", authHandler.SignupView)
mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint
mux.HandleFunc("POST /v1/signupauth", authHandler.SignupHandler)
+ mux.HandleFunc("GET /merchant-signup", authHandler.MerchantSignupView)
+ mux.HandleFunc("POST /v1/merchant-signup", authHandler.MerchantSignupHandler)
mux.HandleFunc("GET /admin-signup", authHandler.AdminSignupView)
mux.HandleFunc("POST /v1/admin-signup", authHandler.AdminSignupHandler)
mux.HandleFunc("POST /v1/signup/check-details", authHandler.CheckDetailsHandler)
@@ -102,8 +108,15 @@ func main() {
mux.HandleFunc("POST /v1/admin/{username}/terminals/add", adminHanlder.AddTerminalHandler)
mux.HandleFunc("GET /admin/{username}/settings", adminHanlder.SystemSettingsView)
mux.HandleFunc("POST /v1/admin/{username}/merchants/add", adminHanlder.AddMerchantHandler)
+ mux.HandleFunc("GET /admin/{username}/merchants/{id}", adminHanlder.MerchantInfoView)
+ mux.HandleFunc("GET /v1/admin/{username}/merchants/{id}/data", adminHanlder.MerchantInfoDataHandler)
+ mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/approve", adminHanlder.ApproveMerchantHandler)
+ mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/reject", adminHanlder.RejectMerchantHandler)
+ mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/suspend", adminHanlder.SuspendMerchantHandler)
+ mux.HandleFunc("DELETE /v1/admin/{username}/merchants/{id}/delete", adminHanlder.DeleteMerchantHandler)
mux.HandleFunc("GET /admin/{username}/card-inventory", adminHanlder.CardInventoryView)
mux.HandleFunc("GET /v1/admin/{username}/card-inventory-data", adminHanlder.CardInventoryDataHandler)
+ mux.HandleFunc("POST /v1/admin/{username}/cards/{id}/block", adminHanlder.BlockCardHandler)
mux.HandleFunc("GET /admin/{username}/addcard", adminHanlder.AddCardsView)
mux.HandleFunc("GET /admin/{username}/deactivatecard", adminHanlder.DeactivateView)
mux.HandleFunc("POST /v1/admin/{username}/addcardauth", adminHanlder.AddCardHandler)
diff --git a/backend/internal/admin/admin_dashboard.go b/backend/internal/admin/admin_dashboard.go
index 65d9db6..9fc38fb 100644
--- a/backend/internal/admin/admin_dashboard.go
+++ b/backend/internal/admin/admin_dashboard.go
@@ -96,35 +96,46 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
}
log.Println("Total cards row:", totalCards)
- // Display the number of merchants
- // Counting only 'active' merchants (excluding 'pending_approval' or 'suspended')
- row = h.DB.QueryRow("SELECT COUNT(*) FROM merchants WHERE status = 'active'")
-
- var totalMerchants int
- if err := row.Scan(&totalMerchants); err != nil {
- log.Println("Error scanning total merchants:", err)
+ // Display the number of merchants and breakdown
+ row = h.DB.QueryRow(`
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(CASE WHEN status = 'pending_approval' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN status = 'suspended' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0)
+ FROM merchants
+ `)
+
+ var totalMerchants, pendingMerchants, suspendedMerchants, rejectedMerchants int
+ if err := row.Scan(&totalMerchants, &pendingMerchants, &suspendedMerchants, &rejectedMerchants); err != nil {
+ log.Println("Error scanning merchants stats:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Internal server error",
})
return
}
- log.Println("Total merchants row:", totalMerchants)
-
- // Display the number of terminals
- // Counting 'active' ESP32 nodes (excluding 'offline' or 'suspended')
- row = h.DB.QueryRow("SELECT COUNT(*) FROM terminals WHERE status = 'active'")
-
- var totalTerminals int
- if err := row.Scan(&totalTerminals); err != nil {
- log.Println("Error scanning total terminals:", err)
+ log.Println("Total merchants:", totalMerchants, "Pending:", pendingMerchants)
+
+ // Display the number of terminals and breakdown
+ row = h.DB.QueryRow(`
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END), 0)
+ FROM terminals
+ `)
+
+ var totalTerminals, activeTerminals, inactiveTerminals int
+ if err := row.Scan(&totalTerminals, &activeTerminals, &inactiveTerminals); err != nil {
+ log.Println("Error scanning terminals stats:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Internal server error",
})
return
}
- log.Println("Total terminals row:", totalTerminals)
+ log.Println("Total terminals:", totalTerminals, "Active:", activeTerminals, "Inactive:", inactiveTerminals)
// Fetch recent merchants for the table (limit 5)
merchantQuery := "SELECT merchant_id, business_name, business_type, owner_name, business_email, business_phone, status, created_at FROM merchants ORDER BY created_at DESC LIMIT 5"
@@ -151,13 +162,18 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
// Return the data as JSON
response := structs.AdminDashboardData{
- GrossRevenue: grossRevenue,
- NetRevenue: netRevenue,
- TotalUsers: totalUsers,
- TotalCards: totalCards,
- ActiveMerchants: totalMerchants,
- ActiveTerminals: totalTerminals,
- Merchants: merchants,
+ GrossRevenue: grossRevenue,
+ NetRevenue: netRevenue,
+ TotalUsers: totalUsers,
+ TotalCards: totalCards,
+ TotalMerchants: totalMerchants,
+ PendingMerchants: pendingMerchants,
+ SuspendedMerchants: suspendedMerchants,
+ RejectedMerchants: rejectedMerchants,
+ TotalTerminals: totalTerminals,
+ ActiveTerminals: activeTerminals,
+ InactiveTerminals: inactiveTerminals,
+ Merchants: merchants,
}
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go
index a18ce76..23f2561 100644
--- a/backend/internal/admin/admin_merchant.go
+++ b/backend/internal/admin/admin_merchant.go
@@ -1,13 +1,19 @@
package admin
import (
+ "database/sql"
+ "encoding/json"
"fmt"
"log"
"net/http"
+ "os"
"strconv"
"strings"
jsonwrite "unicard-go/backend/internal/pkg/handler"
+ smtp "unicard-go/backend/internal/pkg/smtpbody"
structs "unicard-go/backend/internal/pkg/structs"
+
+ "gopkg.in/gomail.v2"
)
func (h *Handler) MerchantManagementView(w http.ResponseWriter, r *http.Request) {
@@ -83,6 +89,10 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R
orderClause := " ORDER BY created_at DESC"
if strings.ToLower(sortOrder) == "asc" {
orderClause = " ORDER BY created_at ASC"
+ } else if strings.ToLower(sortOrder) == "name_asc" {
+ orderClause = " ORDER BY business_name ASC"
+ } else if strings.ToLower(sortOrder) == "name_desc" {
+ orderClause = " ORDER BY business_name DESC"
}
query := `SELECT merchant_id, business_name, business_type, owner_name, business_email, business_phone, status, created_at ` +
@@ -178,3 +188,447 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R
})
log.Println("MerchantManagementDataHandler finished")
}
+
+type ApproveMerchantRequest struct {
+ CommissionRate string `json:"commissionRate" validate:"required"`
+ TerminalSn string `json:"terminalSn" validate:"required"`
+ DeviceName string `json:"deviceName"`
+}
+
+func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) {
+ merchantID := r.PathValue("id")
+ adminUsername := r.PathValue("username")
+
+ if merchantID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"})
+ return
+ }
+
+ var req ApproveMerchantRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request payload"})
+ return
+ }
+
+ // Begin TX
+ tx, err := h.DB.Begin()
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
+ return
+ }
+ defer tx.Rollback()
+
+ // Get admin user_id
+ var adminUserID string
+ err = tx.QueryRow("SELECT user_id FROM users WHERE username = ?", adminUsername).Scan(&adminUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Admin user not found"})
+ return
+ }
+
+ // Get merchant user_id, email, and owner_name for the notification email
+ var merchantUserID, merchantEmail, ownerName string
+ err = tx.QueryRow("SELECT user_id, business_email, owner_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"})
+ return
+ }
+
+ // Update merchants table
+ // Force commission_rate to 2.00 as per requirements
+ _, err = tx.Exec(`
+ UPDATE merchants
+ SET status = 'active',
+ commission_rate = 2.00,
+ approved_by = ?,
+ approved_at = CURRENT_TIMESTAMP
+ WHERE merchant_id = ?`,
+ adminUserID, merchantID)
+
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update merchant"})
+ return
+ }
+
+ // Update users table
+ _, err = tx.Exec("UPDATE users SET status = 'active' WHERE user_id = ?", merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update user status"})
+ return
+ }
+
+ // Update terminals table
+ // First get business address for location details
+ var businessAddress string
+ _ = tx.QueryRow("SELECT business_address FROM merchants WHERE merchant_id = ?", merchantID).Scan(&businessAddress)
+
+ _, err = tx.Exec("UPDATE terminals SET merchant_id = ?, location_details = ?, status = 'active' WHERE terminal_sn = ?", merchantUserID, businessAddress, req.TerminalSn)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to assign terminal"})
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize approval"})
+ return
+ }
+
+ // Send approval email to merchant
+ go func(email, name string) {
+ smtpHost := os.Getenv("SMTP_HOST")
+ smtpPort := 587
+ smtpEmail := os.Getenv("SMTP_EMAIL")
+ smtpSender := os.Getenv("SMTP_SENDER")
+ smtpPass := os.Getenv("SMTP_PASSWORD")
+ if smtpHost == "" || smtpEmail == "" {
+ log.Println("SMTP credentials not configured, skipping approval email")
+ return
+ }
+
+ m := gomail.NewMessage()
+ m.SetHeader("From", smtpSender+" <"+smtpEmail+">")
+ m.SetHeader("To", email)
+ m.SetHeader("Subject", "Unicard Application Approved")
+
+ loginURL := "http://0.0.0.0:3000" // Adjust if there's an env var for frontend URL
+ htmlBody := fmt.Sprintf(smtp.MerchantApprovedEmail(), name, loginURL)
+ m.SetBody("text/html", htmlBody)
+
+ d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass)
+ if err := d.DialAndSend(m); err != nil {
+ log.Printf("Failed to send approval email to %s: %v", email, err)
+ } else {
+ log.Printf("Approval email sent successfully to %s", email)
+ }
+ }(merchantEmail, ownerName)
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant approved successfully"})
+}
+
+func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) {
+ merchantID := r.PathValue("id")
+ if merchantID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"})
+ return
+ }
+
+ var req struct {
+ Reason string `json:"reason"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request body"})
+ return
+ }
+ if req.Reason == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Rejection reason is required"})
+ return
+ }
+
+ tx, err := h.DB.Begin()
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
+ return
+ }
+ defer tx.Rollback()
+
+ var merchantUserID, merchantEmail, ownerName string
+ err = tx.QueryRow("SELECT user_id, business_email, owner_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"})
+ return
+ }
+
+ _, err = tx.Exec("UPDATE merchants SET status = 'rejected' WHERE merchant_id = ?", merchantID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reject merchant"})
+ return
+ }
+
+ _, err = tx.Exec("UPDATE users SET status = 'inactive' WHERE user_id = ?", merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to deactivate user"})
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize rejection"})
+ return
+ }
+
+ // Send rejection email to merchant
+ go func(email, name, reason string) {
+ smtpHost := os.Getenv("SMTP_HOST")
+ smtpPort := 587
+ smtpEmail := os.Getenv("SMTP_EMAIL")
+ smtpSender := os.Getenv("SMTP_SENDER")
+ smtpPass := os.Getenv("SMTP_PASSWORD")
+ if smtpHost == "" || smtpEmail == "" {
+ log.Println("SMTP credentials not configured, skipping rejection email")
+ return
+ }
+
+ m := gomail.NewMessage()
+ m.SetHeader("From", smtpSender+" <"+smtpEmail+">")
+ m.SetHeader("To", email)
+ m.SetHeader("Subject", "Unicard Application Update")
+
+ htmlBody := fmt.Sprintf(smtp.MerchantRejectedEmail(), name, reason)
+ m.SetBody("text/html", htmlBody)
+
+ d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass)
+ if err := d.DialAndSend(m); err != nil {
+ log.Printf("Failed to send rejection email to %s: %v", email, err)
+ } else {
+ log.Printf("Rejection email sent successfully to %s", email)
+ }
+ }(merchantEmail, ownerName, req.Reason)
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant rejected successfully"})
+}
+
+func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request) {
+ merchantID := r.PathValue("id")
+ if merchantID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"})
+ return
+ }
+
+ var req struct {
+ Reason string `json:"reason"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request body"})
+ return
+ }
+ if req.Reason == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Suspension reason is required"})
+ return
+ }
+
+ tx, err := h.DB.Begin()
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
+ return
+ }
+ defer tx.Rollback()
+
+ var merchantUserID, merchantEmail, ownerName string
+ err = tx.QueryRow("SELECT user_id, business_email, owner_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"})
+ return
+ }
+
+ _, err = tx.Exec("UPDATE merchants SET status = 'suspended' WHERE merchant_id = ?", merchantID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to suspend merchant"})
+ return
+ }
+
+ _, err = tx.Exec("UPDATE users SET status = 'inactive' WHERE user_id = ?", merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to deactivate user"})
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize suspension"})
+ return
+ }
+
+ // Send suspension email to merchant
+ go func(email, name, reason string) {
+ smtpHost := os.Getenv("SMTP_HOST")
+ smtpPort := 587
+ smtpEmail := os.Getenv("SMTP_EMAIL")
+ smtpSender := os.Getenv("SMTP_SENDER")
+ smtpPass := os.Getenv("SMTP_PASSWORD")
+ if smtpHost == "" || smtpEmail == "" {
+ log.Println("SMTP credentials not configured, skipping suspension email")
+ return
+ }
+
+ m := gomail.NewMessage()
+ m.SetHeader("From", smtpSender+" <"+smtpEmail+">")
+ m.SetHeader("To", email)
+ m.SetHeader("Subject", "Unicard Account Suspended")
+
+ htmlBody := fmt.Sprintf(smtp.MerchantSuspendedEmail(), name, reason)
+ m.SetBody("text/html", htmlBody)
+
+ d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass)
+ if err := d.DialAndSend(m); err != nil {
+ log.Printf("Failed to send suspension email to %s: %v", email, err)
+ } else {
+ log.Printf("Suspension email sent successfully to %s", email)
+ }
+ }(merchantEmail, ownerName, req.Reason)
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant suspended successfully"})
+}
+
+func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) {
+ merchantID := r.PathValue("id")
+ if merchantID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"})
+ return
+ }
+
+ tx, err := h.DB.Begin()
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
+ return
+ }
+ defer tx.Rollback()
+
+ var merchantUserID string
+ err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"})
+ return
+ }
+
+ // Update terminals assigned to this merchant
+ _, err = tx.Exec("UPDATE terminals SET merchant_id = NULL, location_details = '', status = 'inactive' WHERE merchant_id = ?", merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reset terminals"})
+ return
+ }
+
+ // Delete from merchants
+ _, err = tx.Exec("DELETE FROM merchants WHERE merchant_id = ?", merchantID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete merchant"})
+ return
+ }
+
+ // Delete from users
+ _, err = tx.Exec("DELETE FROM users WHERE user_id = ?", merchantUserID)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete user"})
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize deletion"})
+ return
+ }
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant deleted successfully"})
+}
+
+type MerchantDetailsData struct {
+ MerchantID string
+ UserID string
+ BusinessName string
+ BusinessType string
+ RegistrationNum string
+ BusinessAddress string
+ OwnerName string
+ BusinessEmail string
+ BusinessPhone string
+ Status string
+ CommissionRate float64
+ SettlementBank string
+ SettlementName string
+ SettlementAcct string
+ CreatedAt string
+ DtiDocument string
+ BirDocument string
+ OtherDocument string
+}
+
+type MerchantInfoViewData struct {
+ Page string
+ Username string
+}
+
+func (h *Handler) MerchantInfoView(w http.ResponseWriter, r *http.Request) {
+ username := r.PathValue("username")
+
+ data := MerchantInfoViewData{
+ Page: "merchants",
+ Username: username,
+ }
+
+ err := h.Tpl.ExecuteTemplate(w, "merchant_info.html", data)
+ if err != nil {
+ fmt.Printf("Template execution error: %v\n", err)
+ }
+}
+
+func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request) {
+ merchantID := r.PathValue("id")
+
+ if merchantID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Merchant ID required",
+ })
+ return
+ }
+
+ var m MerchantDetailsData
+ var commRate sql.NullFloat64
+ var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc sql.NullString
+
+ err := h.DB.QueryRow(`
+ SELECT merchant_id, user_id, business_name, business_type, business_registration_number,
+ business_address, owner_name, business_email, business_phone, status,
+ commission_rate, settlement_bank_name, settlement_account_name,
+ settlement_account_number, created_at,
+ dti_document, bir_document, other_document
+ FROM merchants WHERE merchant_id = ?`, merchantID).Scan(
+ &m.MerchantID, &m.UserID, &m.BusinessName, &m.BusinessType, ®Num,
+ &m.BusinessAddress, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status,
+ &commRate, &setBank, &setName, &setAcct, &m.CreatedAt,
+ &dtiDoc, &birDoc, &otherDoc,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Merchant not found",
+ })
+ return
+ }
+ log.Println("Error querying merchant details:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Database error",
+ })
+ return
+ }
+
+ if regNum.Valid {
+ m.RegistrationNum = regNum.String
+ }
+
+ if commRate.Valid {
+ m.CommissionRate = commRate.Float64
+ }
+ if setBank.Valid {
+ m.SettlementBank = setBank.String
+ }
+ if setName.Valid {
+ m.SettlementName = setName.String
+ }
+ if setAcct.Valid {
+ m.SettlementAcct = setAcct.String
+ }
+ if dtiDoc.Valid {
+ m.DtiDocument = dtiDoc.String
+ }
+ if birDoc.Valid {
+ m.BirDocument = birDoc.String
+ }
+ if otherDoc.Valid {
+ m.OtherDocument = otherDoc.String
+ }
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
+ Success: true,
+ Data: m,
+ })
+}
diff --git a/backend/internal/admin/card_inventory.go b/backend/internal/admin/card_inventory.go
index 7b2c8e1..9c844a5 100644
--- a/backend/internal/admin/card_inventory.go
+++ b/backend/internal/admin/card_inventory.go
@@ -1,6 +1,7 @@
package admin
import (
+ "database/sql"
"fmt"
"net/http"
jsonwrite "unicard-go/backend/internal/pkg/handler"
@@ -8,13 +9,14 @@ import (
// AdminCard represents a card entry in the admin database
type AdminCard struct {
- UserID string `json:"user_id" db:"user_id"`
- CardNumber string `json:"card_number" db:"card_number"`
- CardType string `json:"card_type" db:"card_type"`
- Balance float64 `json:"initial_amount" db:"balance"`
- ExpiryDate string `json:"expiry_date" db:"expiry_date"`
- Status string `json:"status" db:"status"`
- CreatedAt string `json:"created_at" db:"created_at"`
+ UserID sql.NullString `json:"user_id" db:"user_id"`
+ CardUID string `json:"card_uid" db:"card_uid"`
+ CardNumber string `json:"card_number" db:"card_number"`
+ CardType string `json:"card_type" db:"card_type"`
+ Balance float64 `json:"initial_amount" db:"balance"`
+ ExpiryDate string `json:"expiry_date" db:"expiry_date"`
+ Status string `json:"status" db:"status"`
+ CreatedAt string `json:"created_at" db:"created_at"`
}
// AdminCardInventoryStats contains statistics about cards
@@ -33,7 +35,7 @@ func (h *Handler) CardInventoryView(w http.ResponseWriter, r *http.Request) {
Page: "card-inventory",
Username: r.PathValue("username"),
}
- err := h.Tpl.ExecuteTemplate(w, "admin_dashboard.html", data)
+ err := h.Tpl.ExecuteTemplate(w, "admin_card_inventory.html", data)
if err != nil {
fmt.Printf("Template execution error: %v\n", err)
}
@@ -53,7 +55,7 @@ func (h *Handler) CardInventoryDataHandler(w http.ResponseWriter, r *http.Reques
// Fetch Cards
rows, err := h.DB.Query(`
- SELECT user_id, card_number, card_type, balance, status, expiry_date, created_at
+ SELECT user_id, card_uid, card_number, card_type, balance, status, expiry_date, created_at
FROM cards
ORDER BY created_at DESC
`)
@@ -72,6 +74,7 @@ func (h *Handler) CardInventoryDataHandler(w http.ResponseWriter, r *http.Reques
var c AdminCard
err := rows.Scan(
&c.UserID,
+ &c.CardUID,
&c.CardNumber,
&c.CardType,
&c.Balance,
@@ -97,3 +100,45 @@ func (h *Handler) CardInventoryDataHandler(w http.ResponseWriter, r *http.Reques
jsonwrite.WriteJSON(w, http.StatusOK, resp)
}
+
+// BlockCardHandler blocks a card from the inventory page
+func (h *Handler) BlockCardHandler(w http.ResponseWriter, r *http.Request) {
+ fmt.Println("BlockCardHandler running...")
+ cardID := r.PathValue("id")
+ if cardID == "" {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Card ID is required",
+ })
+ return
+ }
+
+ result, err := h.DB.Exec(`
+ UPDATE cards
+ SET status = 'Blocked'
+ WHERE card_number = ? OR card_uid = ?
+ `, cardID, cardID)
+
+ if err != nil {
+ fmt.Printf("Error blocking card: %v\n", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to block card",
+ })
+ return
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil || rows == 0 {
+ jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Card not found or could not be blocked",
+ })
+ return
+ }
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
+ Success: true,
+ Message: "Card blocked successfully",
+ })
+}
diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go
new file mode 100644
index 0000000..896806f
--- /dev/null
+++ b/backend/internal/auth/merchant_signup.go
@@ -0,0 +1,228 @@
+package authentication
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "math/big"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicard-go/backend/internal/pkg/account"
+ jsonwrite "unicard-go/backend/internal/pkg/handler"
+
+ "github.com/go-playground/validator/v10"
+)
+
+type MerchantSignupRequest struct {
+ BusinessName string `json:"businessName" validate:"required"`
+ BusinessType string `json:"businessType" validate:"required"`
+ BusinessAddress string `json:"businessAddress" validate:"required"`
+ OwnerName string `json:"ownerName" validate:"required"`
+ BusinessPhone string `json:"businessPhone" validate:"required"`
+ BusinessEmail string `json:"businessEmail" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=6"`
+ DtiDocument string `json:"dtiDocument"`
+ BirDocument string `json:"birDocument"`
+ OtherDocument string `json:"otherDocument"`
+}
+
+// Helper to save base64 to file
+func saveBase64ToFile(b64data, merchantID, docType string) string {
+ if b64data == "" {
+ return ""
+ }
+ parts := strings.SplitN(b64data, ",", 2)
+ if len(parts) != 2 {
+ return ""
+ }
+ ext := ".png"
+ if strings.Contains(parts[0], "application/pdf") {
+ ext = ".pdf"
+ } else if strings.Contains(parts[0], "image/jpeg") {
+ ext = ".jpg"
+ }
+ data, err := base64.StdEncoding.DecodeString(parts[1])
+ if err != nil {
+ return ""
+ }
+ // Create directory if not exists
+ os.MkdirAll("./storage/documents", os.ModePerm)
+ fileName := fmt.Sprintf("%s_%s_%d%s", merchantID, docType, time.Now().Unix(), ext)
+ filePath := filepath.Join("./storage/documents", fileName)
+
+ err = os.WriteFile(filePath, data, 0644)
+ if err != nil {
+ return ""
+ }
+ return "/storage/documents/" + fileName
+}
+
+func (h *Handler) MerchantSignupView(w http.ResponseWriter, r *http.Request) {
+ log.Printf("Merchant Signup view is running...")
+ h.Tpl.ExecuteTemplate(w, "merchant_signup.html", nil)
+}
+
+func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) {
+ var req MerchantSignupRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Printf("Error decoding merchant signup JSON: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to parse JSON request",
+ })
+ return
+ }
+
+ // Clean inputs
+ req.BusinessName = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessName)))
+ req.BusinessAddress = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessAddress)))
+ req.OwnerName = strings.Title(strings.ToLower(strings.TrimSpace(req.OwnerName)))
+ req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail))
+ req.BusinessPhone = strings.TrimSpace(req.BusinessPhone)
+ req.BusinessType = strings.TrimSpace(req.BusinessType)
+ req.Password = strings.TrimSpace(req.Password)
+
+ // Validate inputs
+ err := Validate.Struct(req)
+ if err != nil {
+ log.Printf("Validation failed: %v", err)
+ errorMessage := "Invalid input provided."
+ var validationErrs validator.ValidationErrors
+ if errors.As(err, &validationErrs) {
+ errorMap := map[string]string{
+ "BusinessName": "Business name is required.",
+ "BusinessType": "Business type is required.",
+ "BusinessAddress": "Business address is required.",
+ "OwnerName": "Owner name is required.",
+ "BusinessPhone": "Business phone is required.",
+ "BusinessEmail": "Please provide a valid business email address.",
+ "Password": "Password must be at least 6 characters long.",
+ }
+ if msg, ok := errorMap[validationErrs[0].Field()]; ok {
+ errorMessage = msg
+ }
+ }
+
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: errorMessage,
+ })
+ return
+ }
+
+ // Check if email already exists
+ exists, err := account.IsEmailExist(h.DB, req.BusinessEmail)
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Database error",
+ })
+ return
+ }
+ if exists {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Email already registered",
+ })
+ return
+ }
+
+ // Hash password
+ hashedPassword, err := account.HashPassword(req.Password)
+ if err != nil {
+ log.Printf("Error hashing password: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "System error processing password.",
+ })
+ return
+ }
+
+ // Begin transaction
+ ctx := r.Context()
+ tx, err := h.DB.BeginTx(ctx, nil)
+ if err != nil {
+ log.Printf("Error starting transaction: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "System error starting transaction.",
+ })
+ return
+ }
+ defer tx.Rollback()
+
+ // Generate IDs (Format: YYMMminsecxxxxx)
+ timestamp := time.Now().Format("01020605") // MMDDYYss
+
+ nUser, _ := rand.Int(rand.Reader, big.NewInt(10000))
+ userID := fmt.Sprintf("UNI-%s%04d", timestamp, nUser.Int64())
+
+ nMerchant, _ := rand.Int(rand.Reader, big.NewInt(10000))
+ merchantID := fmt.Sprintf("MCH-%s%04d", timestamp, nMerchant.Int64())
+
+ // Insert User
+ username := req.BusinessEmail // using email as username
+ userStmt := `INSERT INTO users (user_id, username, name, email, phone_number, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
+ _, err = tx.ExecContext(ctx, userStmt, userID, username, req.OwnerName, req.BusinessEmail, req.BusinessPhone, string(hashedPassword), "merchant_admin", "active")
+ if err != nil {
+ log.Printf("Error creating user: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to create user account. Email or phone might already exist.",
+ })
+ return
+ }
+
+ // Generate registration number (UCBZ-MMDDss-xxxxxxxxxx)
+ nReg, _ := rand.Int(rand.Reader, big.NewInt(10000000000))
+ regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64())
+
+ // Save Documents
+ dtiPath := saveBase64ToFile(req.DtiDocument, merchantID, "DTI")
+ birPath := saveBase64ToFile(req.BirDocument, merchantID, "BIR")
+ otherPath := saveBase64ToFile(req.OtherDocument, merchantID, "OTHER")
+
+ // Insert Merchant with placeholder 'PENDING' for settlement fields
+ fixedCommissionRate := 2.00
+ merchStmt := `INSERT INTO merchants (
+ merchant_id, business_name, business_type, business_registration_number, business_address,
+ user_id, owner_name, business_email, business_phone, commission_rate,
+ status, dti_document, bir_document, other_document
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+
+ _, err = tx.ExecContext(ctx, merchStmt,
+ merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress,
+ userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, fixedCommissionRate,
+ "pending approval",
+ dtiPath, birPath, otherPath,
+ )
+
+ if err != nil {
+ log.Printf("Error creating merchant: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to create merchant profile.",
+ })
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ log.Printf("Error committing tx: %v", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to finalize account creation",
+ })
+ return
+ }
+
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
+ Success: true,
+ Message: "Merchant application submitted successfully",
+ })
+}
diff --git a/backend/internal/pkg/smtpbody/smtp.go b/backend/internal/pkg/smtpbody/smtp.go
index 3051a7d..f005d85 100644
--- a/backend/internal/pkg/smtpbody/smtp.go
+++ b/backend/internal/pkg/smtpbody/smtp.go
@@ -48,3 +48,124 @@ func OTPCode() string {
func Year() string {
return fmt.Sprintf("%d", time.Now().Year())
}
+
+func MerchantApprovedEmail() string {
+ return `
+
+
+
+ Unicard Application Approved
+
+
+
+
+
+
+
Hello %s,
+
Great news! Your merchant application has been verified and approved.
+
You can now log in to your Unicard dashboard to set up your settlement bank information and start accepting payments.
+
+
If you have any questions, feel free to contact our support team.
+
Thank you, The Unicard Team
+
+
+
+
+`
+}
+
+func MerchantRejectedEmail() string {
+ return `
+
+
+
+ Unicard Application Update
+
+
+
+
+
+
+
Hello %s,
+
Thank you for applying to become a Unicard merchant. Unfortunately, we are unable to approve your application at this time.
+
+ Reason:
+ %s
+
+
If you believe this is an error or if you have the necessary information to address this issue, please contact our support team.
+
Thank you, The Unicard Team
+
+
+
+
+`
+}
+
+func MerchantSuspendedEmail() string {
+ return `
+
+
+
+ Unicard Account Suspended
+
+
+
+
+
+
+
Hello %s,
+
This is a notice that your Unicard merchant account has been suspended.
+
+ Reason for Suspension:
+ %s
+
+
During this suspension, you will not be able to log in or process transactions. If you wish to appeal this decision or require further clarification, please contact our support team immediately.
+
Thank you, The Unicard Team
+
+
+
+
+`
+}
diff --git a/backend/internal/pkg/structs/struct.go b/backend/internal/pkg/structs/struct.go
index 963a153..39c3d7a 100644
--- a/backend/internal/pkg/structs/struct.go
+++ b/backend/internal/pkg/structs/struct.go
@@ -25,13 +25,18 @@ type Merchant struct {
// AdminDashboardData struct represents the data to be displayed on the admin dashboard
type AdminDashboardData struct {
- GrossRevenue float64 `json:"grossRevenue"`
- NetRevenue float64 `json:"netRevenue"`
- TotalUsers int `json:"totalUsers"`
- TotalCards int `json:"totalCards"`
- ActiveMerchants int `json:"activeMerchants"`
- ActiveTerminals int `json:"activeTerminals"`
- Merchants []Merchant `json:"merchants"`
+ GrossRevenue float64 `json:"grossRevenue"`
+ NetRevenue float64 `json:"netRevenue"`
+ TotalUsers int `json:"totalUsers"`
+ TotalCards int `json:"totalCards"`
+ TotalMerchants int `json:"totalMerchants"`
+ PendingMerchants int `json:"pendingMerchants"`
+ SuspendedMerchants int `json:"suspendedMerchants"`
+ RejectedMerchants int `json:"rejectedMerchants"`
+ TotalTerminals int `json:"totalTerminals"`
+ ActiveTerminals int `json:"activeTerminals"`
+ InactiveTerminals int `json:"inactiveTerminals"`
+ Merchants []Merchant `json:"merchants"`
}
// Terminal represents a hardware device in the terminal registry
diff --git a/docs/unicardv1.sql b/docs/unicardv1.sql
index 9f70d93..f727aa5 100644
--- a/docs/unicardv1.sql
+++ b/docs/unicardv1.sql
@@ -46,11 +46,14 @@ CREATE TABLE merchants (
business_phone VARCHAR(20) NOT NULL UNIQUE COMMENT 'Official telephone or mobile number for merchant support and emergency updates',
commission_rate DECIMAL(5, 2) DEFAULT 2.00 COMMENT 'Percentage cut taken by UniCard per processed card transaction (e.g., 2.50 = 2.5%)',
- settlement_account_name VARCHAR(100) NOT NULL COMMENT 'The name on the merchant bank account or mobile wallet for payouts',
- settlement_account_number VARCHAR(50) NOT NULL COMMENT 'The actual bank account number or mobile number (GCash/Maya) for payouts',
- settlement_bank_name VARCHAR(100) NOT NULL COMMENT 'The target bank or e-wallet company name (e.g., BDO, BPI, GCash, Maya)',
+ settlement_account_name VARCHAR(100) NULL COMMENT 'The name on the merchant bank account or mobile wallet for payouts',
+ settlement_account_number VARCHAR(50) NULL COMMENT 'The actual bank account number or mobile number (GCash/Maya) for payouts',
+ settlement_bank_name VARCHAR(100) NULL COMMENT 'The target bank or e-wallet company name (e.g., BDO, BPI, GCash, Maya)',
- status ENUM('pending_approval', 'active', 'suspended') DEFAULT 'pending_approval' COMMENT 'Operational state of the merchant ecosystem tenancy',
+ status ENUM('pending approval', 'approved', 'rejected', 'active', 'suspended') DEFAULT 'pending approval' COMMENT 'Operational state of the merchant ecosystem tenancy',
+ dti_document VARCHAR(255) NULL COMMENT 'File path for the uploaded DTI registration document',
+ bir_document VARCHAR(255) NULL COMMENT 'File path for the uploaded BIR registration document',
+ other_document VARCHAR(255) NULL COMMENT 'File path for any other uploaded business documents',
approved_by VARCHAR(50) NULL COMMENT 'The user_id of the Super Admin who verified and activated this company profile',
approved_at TIMESTAMP NULL COMMENT 'The specific date and timestamp when the business was activated',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated date and time record of the initial registration request',
diff --git a/frontend/assets/admin/add_card.js b/frontend/assets/admin/add_card.js
index 945cda7..aeaf7eb 100644
--- a/frontend/assets/admin/add_card.js
+++ b/frontend/assets/admin/add_card.js
@@ -14,6 +14,30 @@ document.addEventListener("DOMContentLoaded", function () {
const successText = document.getElementById("success-text");
if (form) {
+ // Dynamic Card Preview Listeners
+ const uidInput = document.getElementById("cardUID");
+ const amountInput = document.getElementById("initialAmount");
+ const previewUid = document.getElementById("preview-uid");
+ const previewBalance = document.getElementById("preview-balance");
+
+ if(uidInput && previewUid) {
+ uidInput.addEventListener('input', (e) => {
+ const val = e.target.value.trim();
+ previewUid.textContent = val || 'A346F101';
+ });
+ }
+
+ if(amountInput && previewBalance) {
+ amountInput.addEventListener('input', (e) => {
+ const val = parseFloat(e.target.value);
+ if (!isNaN(val)) {
+ previewBalance.textContent = val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ } else {
+ previewBalance.textContent = '0.00';
+ }
+ });
+ }
+
form.addEventListener("submit", function (e) {
e.preventDefault();
if (errorAlert) errorAlert.classList.add("hidden");
diff --git a/frontend/assets/admin/admin_card_inventory.js b/frontend/assets/admin/admin_card_inventory.js
new file mode 100644
index 0000000..0b78075
--- /dev/null
+++ b/frontend/assets/admin/admin_card_inventory.js
@@ -0,0 +1,193 @@
+document.addEventListener("DOMContentLoaded", function () {
+ const adminUsername = window.location.pathname.split('/')[2];
+ let allCards = [];
+
+ // Fetch initial data
+ fetch(`/v1/admin/${adminUsername}/card-inventory-data`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.success || data.stats) {
+ // Populate Statistics
+ document.getElementById('totalCards').textContent = data.stats.total.toLocaleString();
+ document.getElementById('activeCards').textContent = data.stats.active.toLocaleString();
+ document.getElementById('inactiveCards').textContent = data.stats.inactive.toLocaleString();
+ document.getElementById('blockedCards').textContent = data.stats.blocked.toLocaleString();
+ document.getElementById('lostCards').textContent = data.stats.lost.toLocaleString();
+
+ allCards = data.cards || [];
+ renderTable(allCards);
+ } else {
+ console.error("Failed to load card inventory data", data.message);
+ }
+ })
+ .catch(error => console.error("Error fetching card inventory:", error));
+
+ // Render table
+ function renderTable(cards) {
+ const tbody = document.getElementById('cardsTableBody');
+ tbody.innerHTML = '';
+
+ if (cards.length > 0) {
+ cards.forEach(c => {
+ const tr = document.createElement('tr');
+ tr.className = 'hover:bg-gray-50 cursor-pointer transition duration-150';
+
+ // Status badge styling
+ let statusColor = 'bg-gray-100 text-gray-800';
+ const statusLower = c.status.toLowerCase();
+ if (statusLower === 'active') {
+ statusColor = 'bg-green-100 text-green-800';
+ } else if (statusLower === 'inactive') {
+ statusColor = 'bg-gray-100 text-gray-800';
+ } else if (statusLower === 'blocked') {
+ statusColor = 'bg-red-100 text-red-800';
+ } else if (statusLower === 'lost') {
+ statusColor = 'bg-orange-100 text-orange-800';
+ }
+
+ // Safely handle user ID (could be null from database)
+ const userIdDisplay = (c.user_id && c.user_id.Valid) ? c.user_id.String : 'Unassigned ';
+
+ // Row click listener to open modal
+ tr.onclick = () => {
+ document.getElementById('modalCardNumber').textContent = c.card_number;
+ document.getElementById('modalCardUID').textContent = c.card_uid;
+ document.getElementById('modalUserID').innerHTML = userIdDisplay;
+ document.getElementById('modalCardType').textContent = c.card_type.replace(/_/g, ' ');
+ document.getElementById('modalBalance').textContent = '₱' + c.initial_amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+
+ const statusEl = document.getElementById('modalStatus');
+ statusEl.textContent = c.status;
+ statusEl.className = 'capitalize px-2 py-1 text-xs font-medium rounded-full ' + statusColor;
+
+ document.getElementById('modalExpiryDate').textContent = c.expiry_date.split('T')[0];
+ document.getElementById('modalCreatedAt').textContent = new Date(c.created_at).toLocaleString();
+
+ document.getElementById('cardDetailsModal').classList.remove('hidden');
+ };
+
+ tr.innerHTML = `
+
+ ${c.card_number}
+ UID: ${c.card_uid}
+
+
+ ${userIdDisplay}
+
+
+ ${c.card_type.replace(/_/g, ' ')}
+
+
+ ₱${c.initial_amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+ ${c.status}
+
+
+
+ ${new Date(c.created_at).toLocaleDateString()}
+
+
+ ${statusLower !== 'blocked' ? `Block ` : `Blocked `}
+
+ `;
+ tbody.appendChild(tr);
+ });
+
+ // Add listener to block buttons
+ document.querySelectorAll('.block-btn').forEach(btn => {
+ btn.onclick = (e) => {
+ e.stopPropagation(); // Prevent opening the modal
+ const cardId = e.target.getAttribute('data-card-id');
+ if(confirm("Are you sure you want to block this card? This action cannot be undone immediately.")) {
+ fetch(`/v1/admin/${adminUsername}/cards/${cardId}/block`, { method: 'POST' })
+ .then(res => res.json())
+ .then(data => {
+ if (data.success) {
+ alert("Card blocked successfully.");
+ // Update local state and re-render
+ const cardIndex = allCards.findIndex(c => c.card_number === cardId);
+ if(cardIndex !== -1) {
+ allCards[cardIndex].status = "Blocked";
+ applyFiltersAndRender();
+ }
+ } else {
+ alert("Failed to block card: " + data.message);
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ alert("An error occurred while blocking the card.");
+ });
+ }
+ };
+ });
+
+ } else {
+ tbody.innerHTML = `
+
+
+
+
+
No cards found
+
There are currently no cards matching your criteria.
+
+
+
+ `;
+ }
+
+ // Update footer stats
+ document.getElementById('pageStart').textContent = cards.length > 0 ? 1 : 0;
+ document.getElementById('pageEnd').textContent = cards.length;
+ document.getElementById('totalItems').textContent = cards.length;
+ }
+
+ // Filter and Sort Logic
+ const searchInput = document.getElementById('searchInput');
+ const filterStatus = document.getElementById('filterStatus');
+ const filterType = document.getElementById('filterType');
+ const sortOrder = document.getElementById('sortOrder');
+
+ function applyFiltersAndRender() {
+ const term = searchInput ? searchInput.value.toLowerCase().trim() : "";
+ const statusVal = filterStatus ? filterStatus.value.toLowerCase() : "all";
+ const typeVal = filterType ? filterType.value.toLowerCase() : "all";
+ const sortVal = sortOrder ? sortOrder.value : "date_desc";
+
+ // 1. Filter
+ let filtered = allCards.filter(c => {
+ const matchesSearch = !term ||
+ c.card_number.toLowerCase().includes(term) ||
+ c.card_uid.toLowerCase().includes(term) ||
+ (c.user_id && c.user_id.Valid && c.user_id.String.toLowerCase().includes(term));
+
+ const matchesStatus = (statusVal === "all") || (c.status.toLowerCase() === statusVal);
+ const matchesType = (typeVal === "all") || (c.card_type.toLowerCase() === typeVal);
+
+ return matchesSearch && matchesStatus && matchesType;
+ });
+
+ // 2. Sort
+ filtered.sort((a, b) => {
+ if (sortVal === 'date_desc') {
+ return new Date(b.created_at) - new Date(a.created_at);
+ } else if (sortVal === 'date_asc') {
+ return new Date(a.created_at) - new Date(b.created_at);
+ } else if (sortVal === 'balance_desc') {
+ return b.initial_amount - a.initial_amount;
+ } else if (sortVal === 'balance_asc') {
+ return a.initial_amount - b.initial_amount;
+ }
+ return 0;
+ });
+
+ renderTable(filtered);
+ }
+
+ if (searchInput) searchInput.addEventListener('input', applyFiltersAndRender);
+ if (filterStatus) filterStatus.addEventListener('change', applyFiltersAndRender);
+ if (filterType) filterType.addEventListener('change', applyFiltersAndRender);
+ if (sortOrder) sortOrder.addEventListener('change', applyFiltersAndRender);
+});
diff --git a/frontend/assets/admin/admin_dashboard.js b/frontend/assets/admin/admin_dashboard.js
index db28033..a0e89ef 100644
--- a/frontend/assets/admin/admin_dashboard.js
+++ b/frontend/assets/admin/admin_dashboard.js
@@ -8,81 +8,83 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById('netRevenue').textContent = '₱' + data.data.netRevenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
document.getElementById('totalUsers').textContent = data.data.totalUsers.toLocaleString();
document.getElementById('totalCards').textContent = data.data.totalCards.toLocaleString();
- document.getElementById('activeMerchants').textContent = data.data.activeMerchants.toLocaleString();
+ document.getElementById('totalMerchants').textContent = data.data.totalMerchants.toLocaleString();
+ document.getElementById('pendingMerchants').textContent = data.data.pendingMerchants.toLocaleString();
+ document.getElementById('suspendedMerchants').textContent = data.data.suspendedMerchants.toLocaleString();
+ document.getElementById('rejectedMerchants').textContent = data.data.rejectedMerchants.toLocaleString();
+
+ document.getElementById('totalTerminals').textContent = data.data.totalTerminals.toLocaleString();
document.getElementById('activeTerminals').textContent = data.data.activeTerminals.toLocaleString();
+ document.getElementById('inactiveTerminals').textContent = data.data.inactiveTerminals.toLocaleString();
// Populate Merchants Table
+ window.dashboardMerchants = data.data.merchants || [];
const tbody = document.getElementById('merchantsTableBody');
- tbody.innerHTML = '';
- if (data.data.merchants && data.data.merchants.length > 0) {
- data.data.merchants.forEach(m => {
- const tr = document.createElement('tr');
- tr.className = 'hover:bg-gray-50 cursor-pointer transition duration-150';
- tr.onclick = () => {
- document.getElementById('modalBusinessName').textContent = m.business_name;
- document.getElementById('modalMerchantId').textContent = m.merchant_id;
- document.getElementById('modalBusinessType').textContent = m.business_type.replace(/_/g, ' ');
- document.getElementById('modalOwnerName').textContent = m.owner_name;
- document.getElementById('modalContactEmail').textContent = m.business_email;
- document.getElementById('modalContactPhone').textContent = m.business_phone;
-
- const statusEl = document.getElementById('modalStatus');
- statusEl.textContent = m.status.replace(/_/g, ' ');
- statusEl.className = 'capitalize px-2 py-1 text-xs font-medium rounded-full';
- if (m.status === 'active') statusEl.classList.add('bg-green-100', 'text-green-800');
- else if (m.status === 'pending_approval') statusEl.classList.add('bg-yellow-100', 'text-yellow-800');
- else statusEl.classList.add('bg-red-100', 'text-red-800');
+ function renderDashboardMerchants(merchantsToRender) {
+ tbody.innerHTML = '';
+ if (merchantsToRender && merchantsToRender.length > 0) {
+ merchantsToRender.forEach(m => {
+ const tr = document.createElement('tr');
+ tr.className = 'hover:bg-gray-50 cursor-pointer transition duration-150';
- document.getElementById('modalCreatedAt').textContent = new Date(m.created_at).toLocaleDateString();
- document.getElementById('merchantDetailsModal').classList.remove('hidden');
- };
+ tr.onclick = () => {
+ window.location.href = `/admin/${adminUsername}/merchants/${m.merchant_id}`;
+ };
- // Status badge styling
- let statusColor = 'bg-gray-100 text-gray-800';
- if (m.status === 'active') {
- statusColor = 'bg-green-100 text-green-800';
- } else if (m.status === 'pending_approval') {
- statusColor = 'bg-yellow-100 text-yellow-800';
- } else if (m.status === 'suspended') {
- statusColor = 'bg-red-100 text-red-800';
- }
+ // Status badge styling
+ let statusColor = 'bg-gray-100 text-gray-800';
+ if (m.status === 'active') {
+ statusColor = 'bg-green-100 text-green-800';
+ } else if (m.status === 'pending_approval' || m.status === 'pending approval') {
+ statusColor = 'bg-yellow-100 text-yellow-800';
+ } else if (m.status === 'suspended') {
+ statusColor = 'bg-orange-100 text-orange-800';
+ } else if (m.status === 'rejected') {
+ statusColor = 'bg-red-100 text-red-800';
+ } else if (m.status === 'approved') {
+ statusColor = 'bg-blue-100 text-blue-800';
+ }
- tr.innerHTML = `
-
-
-
${m.business_name}
-
ID: ${m.merchant_id}
-
-
- ${m.business_type.replace(/_/g, ' ')}
-
- ${m.owner_name}
- ${m.business_email}
-
- ${m.business_phone}
-
-
- ${m.status.replace(/_/g, ' ')}
-
-
- ${new Date(m.created_at).toLocaleDateString()}
+ tr.innerHTML = `
+
+
+
${m.business_name}
+
ID: ${m.merchant_id}
+
+
+ ${m.business_type.replace(/_/g, ' ')}
+
+ ${m.owner_name}
+ ${m.business_email}
+
+ ${m.business_phone}
+
+
+ ${m.status.replace(/_/g, ' ')}
+
+
+ ${new Date(m.created_at).toLocaleDateString()}
+ `;
+ tbody.appendChild(tr);
+ });
+ } else {
+ tbody.innerHTML = `
+
+
+
+
+
No merchants found
+
Try a different search term or add merchants.
+
+
+
`;
- tbody.appendChild(tr);
- });
- } else {
- tbody.innerHTML = `
-
-
-
-
-
No merchants registered yet
-
When merchants are added, they will appear here.
-
-
-
- `;
+ }
}
+
+ // Initial render
+ renderDashboardMerchants(window.dashboardMerchants);
} else {
console.error("Failed to load dashboard data:", data.message);
}
diff --git a/frontend/assets/admin/admin_merchant.js b/frontend/assets/admin/admin_merchant.js
index 4a7e13f..61f2262 100644
--- a/frontend/assets/admin/admin_merchant.js
+++ b/frontend/assets/admin/admin_merchant.js
@@ -8,7 +8,7 @@ let totalItemsCount = 0;
let currentMerchants = [];
let unassignedTerminals = [];
-window.renderAssignedTerminals = function(terminals) {
+window.renderAssignedTerminals = function (terminals) {
if (!terminals || terminals.length === 0) {
return 'No terminals ';
}
@@ -79,20 +79,20 @@ function highlightText(text, queryTerms) {
div.innerText = text;
return div.innerHTML;
}
-
+
const escapedTerms = queryTerms.filter(t => t.length > 0).map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (escapedTerms.length === 0) {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
}
-
+
const regex = new RegExp(`(${escapedTerms.join('|')})`, 'gi');
-
+
const div = document.createElement('div');
div.innerText = text;
const safeText = div.innerHTML;
-
+
return safeText.replace(regex, '$1 ');
}
@@ -113,7 +113,7 @@ function renderTable() {
currentMerchants.forEach(merchant => {
const tr = document.createElement('tr');
-
+
const highlightedBusinessName = highlightText(merchant.business_name, queryTerms);
const highlightedMerchantId = highlightText(merchant.merchant_id, queryTerms);
const highlightedOwnerName = highlightText(merchant.owner_name, queryTerms);
@@ -122,24 +122,20 @@ function renderTable() {
tr.className = 'hover:bg-gray-50 cursor-pointer transition duration-150';
tr.onclick = (e) => {
if (e.target.closest('button')) return; // Ignore button clicks
- document.getElementById('modalBusinessName').textContent = merchant.business_name;
- document.getElementById('modalMerchantId').textContent = merchant.merchant_id;
- document.getElementById('modalBusinessType').textContent = merchant.business_type.replace(/_/g, ' ');
- document.getElementById('modalOwnerName').textContent = merchant.owner_name;
- document.getElementById('modalContactEmail').textContent = merchant.business_email;
- document.getElementById('modalContactPhone').textContent = merchant.business_phone;
-
- const statusEl = document.getElementById('modalStatus');
- statusEl.textContent = merchant.status.replace(/_/g, ' ');
- statusEl.className = 'capitalize px-2 py-1 text-xs font-medium rounded-full';
- if (merchant.status.toLowerCase() === 'active') statusEl.classList.add('bg-green-100', 'text-green-800');
- else if (merchant.status.toLowerCase() === 'pending_approval') statusEl.classList.add('bg-yellow-100', 'text-yellow-800');
- else statusEl.classList.add('bg-red-100', 'text-red-800');
-
- document.getElementById('modalCreatedAt').textContent = new Date(merchant.created_at).toLocaleDateString();
- document.getElementById('merchantDetailsModal').classList.remove('hidden');
+ const adminUsername = window.location.pathname.split('/')[2];
+ window.location.href = `/admin/${adminUsername}/merchants/${merchant.merchant_id}`;
};
+ let statusClass = 'bg-gray-100 text-gray-800';
+ switch (merchant.status.toLowerCase()) {
+ case 'active': statusClass = 'bg-green-100 text-green-800'; break;
+ case 'pending approval':
+ case 'pending_approval': statusClass = 'bg-yellow-100 text-yellow-800'; break;
+ case 'suspended': statusClass = 'bg-orange-100 text-orange-800'; break;
+ case 'rejected': statusClass = 'bg-red-100 text-red-800'; break;
+ case 'approved': statusClass = 'bg-blue-100 text-blue-800'; break;
+ }
+
tr.innerHTML = `
${highlightedBusinessName}
@@ -156,10 +152,11 @@ function renderTable() {
- ${merchant.status}
+ ${merchant.status.replace(/_/g, ' ')}
Edit
+ Delete
`;
tbody.appendChild(tr);
@@ -206,13 +203,67 @@ function renderPagination() {
paginationControls.appendChild(nextBtn);
}
+let currentDeleteMerchantId = null;
+
+function deleteMerchant(merchantId, businessName) {
+ currentDeleteMerchantId = merchantId;
+ document.getElementById('deleteModalBusinessName').textContent = businessName || 'this merchant';
+ document.getElementById('deleteMerchantModal').classList.remove('hidden');
+}
+
document.addEventListener('DOMContentLoaded', () => {
+ const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
+ if (confirmDeleteBtn) {
+ confirmDeleteBtn.addEventListener('click', () => {
+ if (!currentDeleteMerchantId) return;
+
+ const adminUsername = window.location.pathname.split('/')[2];
+ const alertBox = document.getElementById('deleteFormAlert');
+ alertBox.classList.add('hidden');
+
+ confirmDeleteBtn.disabled = true;
+ confirmDeleteBtn.textContent = 'Deleting...';
+
+ fetch(`/v1/admin/${adminUsername}/merchants/${currentDeleteMerchantId}/delete`, {
+ method: 'DELETE'
+ })
+ .then(res => res.json())
+ .then(result => {
+ alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600');
+ if (result.success) {
+ alertBox.classList.add('bg-green-50', 'text-green-600');
+ alertBox.textContent = result.message || "Merchant deleted successfully";
+ setTimeout(() => {
+ document.getElementById('deleteMerchantModal').classList.add('hidden');
+ fetchMerchants();
+ fetchUnassignedTerminals();
+ confirmDeleteBtn.disabled = false;
+ confirmDeleteBtn.textContent = 'Delete Merchant';
+ }, 1000);
+ } else {
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = result.message || "Failed to delete merchant";
+ confirmDeleteBtn.disabled = false;
+ confirmDeleteBtn.textContent = 'Delete Merchant';
+ }
+ })
+ .catch(error => {
+ console.error("Error deleting merchant:", error);
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "An error occurred while deleting the merchant.";
+ confirmDeleteBtn.disabled = false;
+ confirmDeleteBtn.textContent = 'Delete Merchant';
+ });
+ });
+ }
+
fetchMerchants();
fetchUnassignedTerminals();
-
+
// Delegate change event for terminal selection
const container = document.getElementById('merchantBlocksContainer');
- container.addEventListener('change', function(e) {
+ container.addEventListener('change', function (e) {
if (e.target.classList.contains('terminal-sn-select')) {
const selectedOption = e.target.options[e.target.selectedIndex];
const block = e.target.closest('.merchant-block');
@@ -226,7 +277,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Auto-format fields on focus out to trim spaces and format as Title Case
- container.addEventListener('focusout', function(e) {
+ container.addEventListener('focusout', function (e) {
if (e.target.tagName === 'INPUT') {
let val = e.target.value;
if (e.target.type === 'text' && !['businessPhone', 'commissionRate', 'registrationNum', 'deviceName'].includes(e.target.name)) {
@@ -283,7 +334,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (sortOrder) sortOrder.value = 'desc';
if (filterCategory) filterCategory.value = '';
if (filterStatus) filterStatus.value = '';
-
+
currentSearchQuery = '';
currentSortOrder = 'desc';
currentCategory = '';
@@ -297,7 +348,7 @@ document.getElementById('addAnotherMerchantBtn').addEventListener('click', () =>
const container = document.getElementById('merchantBlocksContainer');
const firstBlock = container.querySelector('.merchant-block');
const newBlock = firstBlock.cloneNode(true);
-
+
// Clear inputs (keep default commission rate)
const inputs = newBlock.querySelectorAll('input, select');
inputs.forEach(input => {
@@ -305,17 +356,17 @@ document.getElementById('addAnotherMerchantBtn').addEventListener('click', () =>
input.value = '';
}
});
-
+
const blockCount = container.querySelectorAll('.merchant-block').length + 1;
newBlock.querySelector('.merchant-title').textContent = `Merchant #${blockCount}`;
-
+
const removeBtn = newBlock.querySelector('.remove-merchant-btn');
removeBtn.classList.remove('hidden');
- removeBtn.addEventListener('click', function() {
+ removeBtn.addEventListener('click', function () {
newBlock.remove();
updateMerchantTitles();
});
-
+
container.appendChild(newBlock);
});
@@ -328,10 +379,10 @@ function updateMerchantTitles() {
document.getElementById('onboardForm').addEventListener('submit', function (e) {
e.preventDefault();
-
+
const blocks = document.querySelectorAll('.merchant-block');
const merchantsData = [];
-
+
blocks.forEach(block => {
const inputs = block.querySelectorAll('input, select');
const merchantObj = {};
diff --git a/frontend/assets/admin/admin_terminal.js b/frontend/assets/admin/admin_terminal.js
index 5d3616d..de3beda 100644
--- a/frontend/assets/admin/admin_terminal.js
+++ b/frontend/assets/admin/admin_terminal.js
@@ -185,8 +185,14 @@ document.addEventListener("DOMContentLoaded", () => {
addTerminalForm.addEventListener("submit", (e) => {
e.preventDefault();
const formData = new FormData(addTerminalForm);
+
+ let terminalSn = formData.get("terminalSnPrefix");
+ if (terminalSn && !terminalSn.startsWith("UC-TRM-")) {
+ terminalSn = "UC-TRM-" + terminalSn;
+ }
+
const data = {
- terminalSn: formData.get("terminalSn"),
+ terminalSn: terminalSn,
deviceName: formData.get("deviceName")
};
diff --git a/frontend/assets/admin/merchant_info.js b/frontend/assets/admin/merchant_info.js
new file mode 100644
index 0000000..c531643
--- /dev/null
+++ b/frontend/assets/admin/merchant_info.js
@@ -0,0 +1,413 @@
+let unassignedTerminals = [];
+
+function fetchUnassignedTerminals() {
+ const adminUsername = window.location.pathname.split('/')[2];
+ fetch(`/v1/admin/${adminUsername}/terminals/unassigned`)
+ .then(res => res.json())
+ .then(result => {
+ if (result.success && result.data) {
+ unassignedTerminals = result.data;
+ document.querySelectorAll('.terminal-sn-select').forEach(populateTerminalDropdown);
+ }
+ })
+ .catch(error => console.error("Error fetching unassigned terminals", error));
+}
+
+function populateTerminalDropdown(selectElement) {
+ selectElement.innerHTML = 'Select a terminal ';
+ unassignedTerminals.forEach(t => {
+ const opt = document.createElement('option');
+ opt.value = t.terminal_sn;
+ opt.textContent = t.terminal_sn;
+ opt.dataset.deviceName = t.device_name;
+ selectElement.appendChild(opt);
+ });
+}
+
+function fetchMerchantData() {
+ const pathParts = window.location.pathname.split('/');
+ const adminUsername = pathParts[2];
+ const merchantId = pathParts[4];
+
+ fetch(`/v1/admin/${adminUsername}/merchants/${merchantId}/data`)
+ .then(res => res.json())
+ .then(result => {
+ if (result.success && result.data) {
+ populateMerchantUI(result.data);
+ } else {
+ console.error("Failed to load merchant data:", result.message);
+ alert("Failed to load merchant data: " + (result.message || "Unknown error"));
+ }
+ })
+ .catch(error => {
+ console.error("Error fetching merchant data:", error);
+ alert("Network error while loading merchant data.");
+ });
+}
+
+function populateMerchantUI(merchant) {
+ document.getElementById('businessName').textContent = merchant.BusinessName;
+ document.getElementById('merchantId').textContent = merchant.MerchantID;
+ document.getElementById('businessType').textContent = merchant.BusinessType.replace(/_/g, ' ');
+ document.getElementById('registrationNum').textContent = merchant.RegistrationNum || 'N/A';
+ document.getElementById('businessAddress').textContent = merchant.BusinessAddress;
+ document.getElementById('createdAt').textContent = new Date(merchant.CreatedAt).toLocaleDateString();
+
+ document.getElementById('ownerName').textContent = merchant.OwnerName;
+ document.getElementById('businessEmail').textContent = merchant.BusinessEmail;
+ document.getElementById('businessPhone').textContent = merchant.BusinessPhone;
+
+ const renderDoc = (url, elId) => {
+ const el = document.getElementById(elId);
+ if (url) {
+ el.innerHTML = `
+
+ View Document
+ `;
+ } else {
+ el.textContent = "Not provided";
+ }
+ };
+
+ renderDoc(merchant.DtiDocument, 'dtiDocumentLink');
+ renderDoc(merchant.BirDocument, 'birDocumentLink');
+ renderDoc(merchant.OtherDocument, 'otherDocumentLink');
+
+ const statusEl = document.getElementById('merchantStatus');
+ const statusLower = merchant.Status.toLowerCase();
+ statusEl.textContent = merchant.Status;
+ statusEl.className = 'capitalize px-4 py-2 text-sm font-semibold rounded-full';
+
+ if (statusLower === 'active') {
+ statusEl.classList.add('bg-green-100', 'text-green-800');
+ } else if (statusLower === 'pending_approval' || statusLower === 'pending approval') {
+ statusEl.classList.add('bg-yellow-100', 'text-yellow-800');
+ } else {
+ statusEl.classList.add('bg-red-100', 'text-red-800');
+ }
+
+ // Show settlement details regardless of status
+ document.getElementById('settlementDetailsContainer').classList.remove('hidden');
+ document.getElementById('commissionRate').textContent = merchant.CommissionRate + '%';
+ document.getElementById('settlementBank').textContent = merchant.SettlementBank || 'N/A';
+ document.getElementById('settlementName').textContent = merchant.SettlementName || 'N/A';
+ document.getElementById('settlementAcct').textContent = merchant.SettlementAcct || 'N/A';
+
+ const actionButtons = document.getElementById('actionButtons');
+ const btnApprove = document.getElementById('btnApproveMerchant');
+ const btnReject = document.getElementById('btnRejectMerchant');
+ const btnSuspend = document.getElementById('btnSuspendMerchant');
+ const btnDelete = document.getElementById('btnDeleteMerchant');
+
+ // Always show action buttons so delete is always available
+ actionButtons.classList.remove('hidden');
+ actionButtons.dataset.merchantId = merchant.MerchantID;
+ actionButtons.dataset.businessName = merchant.BusinessName;
+
+ if (statusLower === 'active') {
+ btnSuspend.classList.remove('hidden');
+ btnApprove.classList.add('hidden');
+ btnReject.classList.add('hidden');
+ btnDelete.classList.remove('hidden');
+ } else if (statusLower === 'pending_approval' || statusLower === 'pending approval') {
+ btnSuspend.classList.add('hidden');
+ btnApprove.classList.remove('hidden');
+ btnReject.classList.remove('hidden');
+ btnDelete.classList.remove('hidden');
+ } else {
+ btnSuspend.classList.add('hidden');
+ btnApprove.classList.add('hidden');
+ btnReject.classList.add('hidden');
+ btnDelete.classList.remove('hidden');
+ }
+}
+
+window.openDocumentViewer = function(url) {
+ const modal = document.getElementById('documentViewerModal');
+ const iframe = document.getElementById('documentViewerFrame');
+ const downloadBtn = document.getElementById('downloadDocumentBtn');
+
+ if (modal && iframe && downloadBtn) {
+ iframe.src = url;
+ downloadBtn.href = url;
+ modal.classList.remove('hidden');
+ }
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ fetchUnassignedTerminals();
+ fetchMerchantData();
+
+ const btnApprove = document.getElementById('btnApproveMerchant');
+ const btnReject = document.getElementById('btnRejectMerchant');
+ const actionButtons = document.getElementById('actionButtons');
+ const approveModal = document.getElementById('approveMerchantModal');
+
+ if (btnApprove && actionButtons) {
+ btnApprove.addEventListener('click', () => {
+ const businessName = actionButtons.dataset.businessName;
+ const merchantId = actionButtons.dataset.merchantId;
+ document.getElementById('approveModalBusinessName').textContent = businessName;
+ document.getElementById('approveMerchantId').value = merchantId;
+
+ const terminalSelect = document.getElementById('approveTerminalSn');
+ populateTerminalDropdown(terminalSelect);
+ approveModal.classList.remove('hidden');
+ });
+ }
+
+ const rejectModal = document.getElementById('rejectMerchantModal');
+ const confirmRejectBtn = document.getElementById('confirmRejectBtn');
+ let rejectMerchantId = null;
+
+ if (btnReject && actionButtons) {
+ btnReject.addEventListener('click', () => {
+ const merchantId = actionButtons.dataset.merchantId;
+ const businessName = actionButtons.dataset.businessName;
+ if (!merchantId) return;
+
+ document.getElementById('rejectModalBusinessName').textContent = businessName;
+ rejectMerchantId = merchantId;
+ rejectModal.classList.remove('hidden');
+ });
+ }
+
+ if (confirmRejectBtn) {
+ confirmRejectBtn.addEventListener('click', () => {
+ if (!rejectMerchantId) return;
+
+ const adminUsername = window.location.pathname.split('/')[2];
+ const alertBox = document.getElementById('rejectFormAlert');
+ alertBox.classList.add('hidden');
+
+ const reason = document.getElementById('rejectReason').value;
+ if (!reason) {
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Please provide a reason for rejection.";
+ return;
+ }
+
+ confirmRejectBtn.disabled = true;
+ confirmRejectBtn.textContent = 'Rejecting...';
+
+ fetch(`/v1/admin/${adminUsername}/merchants/${rejectMerchantId}/reject`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reason })
+ })
+ .then(res => res.json())
+ .then(result => {
+ alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600');
+ if (result.success) {
+ alertBox.classList.add('bg-green-50', 'text-green-600');
+ alertBox.textContent = result.message || "Merchant application rejected successfully.";
+ setTimeout(() => {
+ window.location.reload();
+ }, 1500);
+ } else {
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = result.message || "Failed to reject merchant.";
+ confirmRejectBtn.disabled = false;
+ confirmRejectBtn.textContent = 'Reject Application';
+ }
+ })
+ .catch(err => {
+ console.error("Error rejecting merchant:", err);
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Network error. Please try again.";
+ confirmRejectBtn.disabled = false;
+ confirmRejectBtn.textContent = 'Reject Application';
+ });
+ });
+ }
+
+ const btnSuspend = document.getElementById('btnSuspendMerchant');
+ const suspendModal = document.getElementById('suspendMerchantModal');
+ const confirmSuspendBtn = document.getElementById('confirmSuspendBtn');
+ let suspendMerchantId = null;
+
+ if (btnSuspend && actionButtons) {
+ btnSuspend.addEventListener('click', () => {
+ const merchantId = actionButtons.dataset.merchantId;
+ const businessName = actionButtons.dataset.businessName;
+ if (!merchantId) return;
+
+ document.getElementById('suspendModalBusinessName').textContent = businessName;
+ suspendMerchantId = merchantId;
+ suspendModal.classList.remove('hidden');
+ });
+ }
+
+ if (confirmSuspendBtn) {
+ confirmSuspendBtn.addEventListener('click', () => {
+ if (!suspendMerchantId) return;
+
+ const adminUsername = window.location.pathname.split('/')[2];
+ const alertBox = document.getElementById('suspendFormAlert');
+ alertBox.classList.add('hidden');
+
+ const reason = document.getElementById('suspendReason').value;
+ if (!reason) {
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Please provide a reason for suspension.";
+ return;
+ }
+
+ confirmSuspendBtn.disabled = true;
+ confirmSuspendBtn.textContent = 'Suspending...';
+
+ fetch(`/v1/admin/${adminUsername}/merchants/${suspendMerchantId}/suspend`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reason })
+ })
+ .then(res => res.json())
+ .then(result => {
+ alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600');
+ if (result.success) {
+ alertBox.classList.add('bg-green-50', 'text-green-600');
+ alertBox.textContent = result.message || "Merchant account suspended successfully.";
+ setTimeout(() => {
+ window.location.reload();
+ }, 1500);
+ } else {
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = result.message || "Failed to suspend merchant.";
+ confirmSuspendBtn.disabled = false;
+ confirmSuspendBtn.textContent = 'Suspend Account';
+ }
+ })
+ .catch(err => {
+ console.error("Error suspending merchant:", err);
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Network error. Please try again.";
+ confirmSuspendBtn.disabled = false;
+ confirmSuspendBtn.textContent = 'Suspend Account';
+ });
+ });
+ }
+
+ // Terminal selection auto-fill for approve form
+ const approveTerminalSn = document.getElementById('approveTerminalSn');
+ const approveDeviceName = document.getElementById('approveDeviceName');
+ if (approveTerminalSn && approveDeviceName) {
+ approveTerminalSn.addEventListener('change', (e) => {
+ const selectedOption = e.target.options[e.target.selectedIndex];
+ if (selectedOption && selectedOption.dataset.deviceName) {
+ approveDeviceName.value = selectedOption.dataset.deviceName;
+ } else {
+ approveDeviceName.value = '';
+ }
+ });
+ }
+
+ const approveForm = document.getElementById('approveForm');
+ if (approveForm) {
+ approveForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+
+ const merchantId = document.getElementById('approveMerchantId').value;
+ const commissionRate = document.getElementById('approveCommissionRate').value;
+ const terminalSn = document.getElementById('approveTerminalSn').value;
+ const deviceName = document.getElementById('approveDeviceName').value;
+
+ const payload = {
+ commissionRate,
+ terminalSn,
+ deviceName
+ };
+
+ const alertBox = document.getElementById('approveFormAlert');
+ alertBox.classList.add('hidden');
+
+ const adminUsername = window.location.pathname.split('/')[2];
+ fetch(`/v1/admin/${adminUsername}/merchants/${merchantId}/approve`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ })
+ .then(res => res.json())
+ .then(result => {
+ alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600');
+ if (result.success) {
+ alertBox.classList.add('bg-green-50', 'text-green-600');
+ alertBox.textContent = result.message || "Merchant approved successfully!";
+ setTimeout(() => {
+ window.location.reload();
+ }, 1500);
+ } else {
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = result.message || "An error occurred.";
+ }
+ })
+ .catch(err => {
+ console.error("Error approving merchant:", err);
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Network error. Please try again.";
+ });
+ });
+ }
+
+ const btnDelete = document.getElementById('btnDeleteMerchant');
+ const deleteModal = document.getElementById('deleteMerchantModal');
+ const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
+ let deleteMerchantId = null;
+
+ if (btnDelete && actionButtons) {
+ btnDelete.addEventListener('click', () => {
+ const merchantId = actionButtons.dataset.merchantId;
+ const businessName = actionButtons.dataset.businessName;
+ if (!merchantId) return;
+
+ document.getElementById('deleteModalBusinessName').textContent = businessName;
+ deleteMerchantId = merchantId;
+ deleteModal.classList.remove('hidden');
+ });
+ }
+
+ if (confirmDeleteBtn) {
+ confirmDeleteBtn.addEventListener('click', () => {
+ if (!deleteMerchantId) return;
+
+ const adminUsername = window.location.pathname.split('/')[2];
+ const alertBox = document.getElementById('deleteFormAlert');
+ alertBox.classList.add('hidden');
+
+ confirmDeleteBtn.disabled = true;
+ confirmDeleteBtn.textContent = 'Deleting...';
+
+ fetch(`/v1/admin/${adminUsername}/merchants/${deleteMerchantId}/delete`, {
+ method: 'DELETE'
+ })
+ .then(res => res.json())
+ .then(result => {
+ alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600');
+ if (result.success) {
+ alertBox.classList.add('bg-green-50', 'text-green-600');
+ alertBox.textContent = result.message || "Merchant deleted successfully.";
+ setTimeout(() => {
+ window.location.href = `/admin/${adminUsername}/merchants`;
+ }, 1500);
+ } else {
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = result.message || "Failed to delete merchant.";
+ confirmDeleteBtn.disabled = false;
+ confirmDeleteBtn.textContent = 'Delete Merchant';
+ }
+ })
+ .catch(err => {
+ console.error("Error deleting merchant:", err);
+ alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600');
+ alertBox.classList.add('bg-red-50', 'text-red-600');
+ alertBox.textContent = "Network error. Please try again.";
+ confirmDeleteBtn.disabled = false;
+ confirmDeleteBtn.textContent = 'Delete Merchant';
+ });
+ });
+ }
+});
diff --git a/frontend/assets/images/hero.png b/frontend/assets/images/hero.png
new file mode 100644
index 0000000..486a33e
Binary files /dev/null and b/frontend/assets/images/hero.png differ
diff --git a/frontend/assets/js/merchant_signup.js b/frontend/assets/js/merchant_signup.js
new file mode 100644
index 0000000..e72b40d
--- /dev/null
+++ b/frontend/assets/js/merchant_signup.js
@@ -0,0 +1,131 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const signupForm = document.getElementById("merchantSignupForm");
+ const formAlert = document.getElementById("formAlert");
+ const submitBtn = document.getElementById("submitBtn");
+
+ if (!signupForm) return;
+
+ signupForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ hideAlert();
+
+ const businessName = document.getElementById("businessName").value.trim();
+ const businessType = document.getElementById("businessType").value;
+ const businessAddress = document.getElementById("businessAddress").value.trim();
+ const ownerName = document.getElementById("ownerName").value.trim();
+ const businessPhone = document.getElementById("businessPhone").value.trim();
+ const businessEmail = document.getElementById("businessEmail").value.trim();
+ const password = document.getElementById("password").value;
+ const confirmPassword = document.getElementById("confirmPassword").value;
+
+ // Basic validation
+ if (password !== confirmPassword) {
+ showAlert("Passwords do not match.", "error");
+ return;
+ }
+
+ if (password.length < 6) {
+ showAlert("Password must be at least 6 characters long.", "error");
+ return;
+ }
+
+ // Disable button to prevent double submission
+ setLoading(true);
+
+ try {
+ // Function to convert file to base64
+ const toBase64 = file => new Promise((resolve, reject) => {
+ if (!file) {
+ resolve(null);
+ return;
+ }
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = error => reject(error);
+ });
+
+ const dtiFile = document.getElementById("dtiDocument").files[0];
+ const birFile = document.getElementById("birDocument").files[0];
+ const otherFile = document.getElementById("otherDocument").files[0];
+
+ const dtiDocument = await toBase64(dtiFile);
+ const birDocument = await toBase64(birFile);
+ const otherDocument = await toBase64(otherFile);
+
+ // Prepare payload
+ const payload = {
+ businessName,
+ businessType,
+ businessAddress,
+ ownerName,
+ businessPhone,
+ businessEmail,
+ password,
+ dtiDocument,
+ birDocument,
+ otherDocument
+ };
+
+ const response = await fetch("/v1/merchant-signup", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ showAlert("Application submitted successfully! Please wait for admin approval before logging in.", "success");
+ signupForm.reset();
+ // Optionally redirect to login page after a delay
+ setTimeout(() => {
+ window.location.href = "/login";
+ }, 3000);
+ } else {
+ showAlert(data.message || "Failed to submit application. Please try again.", "error");
+ }
+ } catch (error) {
+ console.error("Signup error:", error);
+ showAlert("A network error occurred. Please try again later.", "error");
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ function showAlert(message, type) {
+ formAlert.textContent = message;
+ formAlert.classList.remove("hidden", "bg-red-100", "text-red-700", "border-red-400", "bg-green-100", "text-green-700", "border-green-400", "border");
+
+ if (type === "error") {
+ formAlert.classList.add("bg-red-100", "text-red-700", "border-red-400", "border");
+ } else {
+ formAlert.classList.add("bg-green-100", "text-green-700", "border-green-400", "border");
+ }
+ }
+
+ function hideAlert() {
+ formAlert.classList.add("hidden");
+ formAlert.textContent = "";
+ }
+
+ function setLoading(isLoading) {
+ if (isLoading) {
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = `
+
+
+
+
+ Submitting...
+ `;
+ submitBtn.classList.add("opacity-75", "cursor-not-allowed");
+ } else {
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = "Submit Application";
+ submitBtn.classList.remove("opacity-75", "cursor-not-allowed");
+ }
+ }
+});
diff --git a/frontend/templates/admin/addCards.html b/frontend/templates/admin/addCards.html
index beaefe6..6995073 100644
--- a/frontend/templates/admin/addCards.html
+++ b/frontend/templates/admin/addCards.html
@@ -7,175 +7,173 @@
UniCard Admin - Add Card
-
+
+
-
-
-
- {{template "admin_sidebar" .}}
-
-
-