From 54dd4a5ca7601233d598d5c0f59fa410f440f84d Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:36:00 +0800 Subject: [PATCH 1/7] feat: implement merchant dashboard, transaction history, and account management features --- backend/cmd/app/main.go | 2 + backend/internal/admin/admin_merchant.go | 137 +++-- backend/internal/auth/merchant_signup.go | 38 +- backend/internal/merchant/account.go | 178 ++++++- backend/internal/pkg/smtpbody/smtp.go | 39 ++ docs/unicard.sql | 15 +- frontend/assets/admin/merchant_info.js | 95 +++- frontend/assets/js/merchant_account.js | 497 ++++++++++++++++-- frontend/assets/js/merchant_dashboard.js | 29 + frontend/assets/js/merchant_signup.js | 6 +- frontend/assets/js/merchant_transactions.js | 29 + frontend/templates/admin/addCards.html | 16 +- frontend/templates/admin/admin_sidebar.html | 64 +-- frontend/templates/admin/merchant_info.html | 37 +- frontend/templates/admin/transactions.html | 12 +- frontend/templates/auth/merchant_signup.html | 4 +- .../templates/merchant/merchant_account.html | 70 ++- 17 files changed, 1082 insertions(+), 186 deletions(-) diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index a52703e..5ff529f 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -125,6 +125,8 @@ func main() { mux.HandleFunc("GET /v1/merchant/{username}/incomes", merchantHandler.IncomeHandler) mux.HandleFunc("GET /merchant/{username}/account", merchantHandler.MerchantAccountView) mux.HandleFunc("GET /v1/merchant/{username}/account", merchantHandler.MerchantAccountDataHandler) + mux.HandleFunc("POST /v1/merchant/{username}/update-bank", merchantHandler.UpdateBankDetails) + mux.HandleFunc("POST /v1/merchant/{username}/upload-document", merchantHandler.UploadDocument) mux.HandleFunc("POST /v1/merchant/{username}/withdraw", merchantHandler.WithdrawHandler) // super admin endpoints diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go index 29ceb2c..ec1761f 100644 --- a/backend/internal/admin/admin_merchant.go +++ b/backend/internal/admin/admin_merchant.go @@ -9,6 +9,7 @@ import ( "os" "strconv" "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" smtp "unicard-go/backend/internal/pkg/smtpbody" structs "unicard-go/backend/internal/pkg/structs" @@ -190,9 +191,9 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R } type ApproveMerchantRequest struct { - CommissionRate string `json:"commissionRate" validate:"required"` - TerminalSn string `json:"terminalSn" validate:"required"` - DeviceName string `json:"deviceName"` + 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) { @@ -226,9 +227,9 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) 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) + // Get merchant user_id, email, owner_name, and business_name for the notification email and welcome tx + var merchantUserID, merchantEmail, ownerName, businessName string + err = tx.QueryRow("SELECT user_id, business_email, owner_name, business_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName, &businessName) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -239,6 +240,8 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) _, err = tx.Exec(` UPDATE merchants SET status = 'active', + document_status = 'approved', + message = 'Congratulations! Your UniCard Merchant Account is now fully active.', commission_rate = 2.00, approved_by = ?, approved_at = CURRENT_TIMESTAMP @@ -268,6 +271,24 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Format business name safely for transaction ID (remove spaces, uppercase) + /*safeBusinessName := strings.ToUpper(strings.ReplaceAll(businessName, " ", "")) + if len(safeBusinessName) > 15 { + safeBusinessName = safeBusinessName[:15] + }*/ + + // Insert welcome transaction + welcomeTxnID := fmt.Sprintf("Welcome %s-%d", businessName, time.Now().UnixMilli()) + _, err = tx.Exec(` + INSERT INTO transactions + (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) + VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Welcome to UniCard! Your merchant account is now approved and ready to accept transactions.')`, + welcomeTxnID, merchantID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to create welcome transaction"}) + return + } + if err := tx.Commit(); err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize approval"}) return @@ -338,7 +359,7 @@ func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'rejected' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'rejected', document_status = 'rejected', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reject merchant"}) return @@ -419,7 +440,7 @@ func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'suspended' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'suspended', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to suspend merchant"}) return @@ -481,8 +502,8 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) } defer tx.Rollback() - var merchantUserID string - err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID) + var merchantUserID, ownerName, merchantEmail string + err = tx.QueryRow("SELECT user_id, owner_name, business_email FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &ownerName, &merchantEmail) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -495,15 +516,15 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } - // Delete from merchants - _, err = tx.Exec("DELETE FROM merchants WHERE merchant_id = ?", merchantID) + // Soft delete from merchants: Set status to 'deleted' and update message + _, err = tx.Exec("UPDATE merchants SET status = 'deleted', message = 'Your merchant account has been permanently deleted.' 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) + // Soft delete from users: Set status to 'inactive' + _, 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 delete user"}) return @@ -514,28 +535,59 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Send deletion 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 deletion email") + return + } + + m := gomail.NewMessage() + m.SetHeader("From", fmt.Sprintf("%s <%s>", smtpSender, smtpEmail)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Unicard Account Deleted") + + htmlBody := fmt.Sprintf(smtp.MerchantDeletedEmail(), name) + m.SetBody("text/html", htmlBody) + + d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass) + if err := d.DialAndSend(m); err != nil { + log.Printf("Failed to send deletion email to %s: %v", email, err) + } else { + log.Printf("Deletion email sent successfully to %s", email) + } + }(merchantEmail, ownerName) + 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 + MerchantID string + UserID string + BusinessName string + BusinessType string + RegistrationNum string + BusinessAddress string + City string + PostalCode string + OwnerName string + BusinessEmail string + BusinessPhone string + Status string + CommissionRate float64 + SettlementBank string + SettlementName string + SettlementAcct string + CreatedAt string + BusinessDocument string + BirDocument string + OtherDocument string + DocumentStatus string } type MerchantInfoViewData struct { @@ -570,19 +622,19 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request var m MerchantDetailsData var commRate sql.NullFloat64 - var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc sql.NullString + var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc, city, postal, docStatus 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, + business_address, city, postal_code, 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 + business_document, bir_document, other_document, document_status 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, + &m.BusinessAddress, &city, &postal, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, &commRate, &setBank, &setName, &setAcct, &m.CreatedAt, - &dtiDoc, &birDoc, &otherDoc, + &dtiDoc, &birDoc, &otherDoc, &docStatus, ) if err != nil { @@ -604,6 +656,12 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if regNum.Valid { m.RegistrationNum = regNum.String } + if city.Valid { + m.City = city.String + } + if postal.Valid { + m.PostalCode = postal.String + } if commRate.Valid { m.CommissionRate = commRate.Float64 @@ -618,7 +676,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request m.SettlementAcct = setAcct.String } if dtiDoc.Valid { - m.DtiDocument = dtiDoc.String + m.BusinessDocument = dtiDoc.String } if birDoc.Valid { m.BirDocument = birDoc.String @@ -626,6 +684,9 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if otherDoc.Valid { m.OtherDocument = otherDoc.String } + if docStatus.Valid { + m.DocumentStatus = docStatus.String + } jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go index 896806f..25058e4 100644 --- a/backend/internal/auth/merchant_signup.go +++ b/backend/internal/auth/merchant_signup.go @@ -20,20 +20,20 @@ import ( ) 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"` + 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"` + BusinessDocument string `json:"businessDocument"` + BirDocument string `json:"birDocument"` + OtherDocument string `json:"otherDocument"` } // Helper to save base64 to file -func saveBase64ToFile(b64data, merchantID, docType string) string { +func saveBase64ToFile(b64data string) string { if b64data == "" { return "" } @@ -53,9 +53,9 @@ func saveBase64ToFile(b64data, merchantID, docType string) string { } // Create directory if not exists os.MkdirAll("./storage/documents", os.ModePerm) - fileName := fmt.Sprintf("%s_%s_%d%s", merchantID, docType, time.Now().Unix(), ext) + fileName := fmt.Sprintf("%d%s", time.Now().Unix(), ext) filePath := filepath.Join("./storage/documents", fileName) - + err = os.WriteFile(filePath, data, 0644) if err != nil { return "" @@ -184,23 +184,23 @@ func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) 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") + bizDocPath := saveBase64ToFile(req.BusinessDocument) + birPath := saveBase64ToFile(req.BirDocument) + otherPath := saveBase64ToFile(req.OtherDocument) // 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 + status, business_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, + bizDocPath, birPath, otherPath, ) if err != nil { diff --git a/backend/internal/merchant/account.go b/backend/internal/merchant/account.go index 7e47b67..541c8fb 100644 --- a/backend/internal/merchant/account.go +++ b/backend/internal/merchant/account.go @@ -1,8 +1,15 @@ package merchant import ( + "encoding/json" + "fmt" + "io" "log" "net/http" + "os" + "path/filepath" + "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" ) @@ -34,6 +41,8 @@ type BusinessBankDetails struct { type AccountSummary struct { MerchantID string `json:"merchant_id"` AccountStatus string `json:"account_status"` + DocumentStatus string `json:"document_status"` + AccountMessage string `json:"account_message"` MemberSince string `json:"member_since"` BusinessDetails BusinessDetails `json:"business_details"` BusinessBankDetails BusinessBankDetails `json:"business_bank_details"` @@ -71,7 +80,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ var ( merchantID, accountStatus, businessName, businessType, businessStructure, businessEmail, businessPhone, businessAddress, city, - postalCode, accName, bankName, accNumber, businessDoc, birDoc, + postalCode, accName, bankName, accNumber, businessDoc, birDoc, otherDoc, docStatus, docMessage, createdAtStr string ) @@ -103,6 +112,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ COALESCE(m.business_structure, ''), COALESCE(m.business_document, ''), COALESCE(m.bir_document, ''), + COALESCE(m.other_document, ''), COALESCE(m.document_status, ''), COALESCE(m.message, '') @@ -127,6 +137,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ &businessStructure, &businessDoc, &birDoc, + &otherDoc, &docStatus, &docMessage, ) @@ -155,12 +166,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ // Business Registration (DTI or SEC) if businessDoc != "" { - registrationLabel := "DTI Registration" - - // NOW checking the correct column! - if businessStructure == "corporation" || businessStructure == "partnership" { - registrationLabel = "SEC Registration" - } + registrationLabel := "DTI/SEC Registration" documents = append(documents, BusinessDocument{ DocumentType: registrationLabel, @@ -175,17 +181,29 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ if birDoc != "" { documents = append(documents, BusinessDocument{ DocumentType: "BIR Certificate", - Status: docStatus, // Quotes removed! - Message: docMessage, // Quotes removed! + Status: docStatus, + Message: docMessage, DocumentURL: birDoc, }) } + // Other Document + if otherDoc != "" { + documents = append(documents, BusinessDocument{ + DocumentType: "Other Document", + Status: docStatus, + Message: docMessage, + DocumentURL: otherDoc, + }) + } + // 5. Construct the final struct responseData := AccountSummary{ - MerchantID: merchantID, - AccountStatus: accountStatus, - MemberSince: memberSince, + MerchantID: merchantID, + AccountStatus: accountStatus, + DocumentStatus: docStatus, + AccountMessage: docMessage, + MemberSince: memberSince, BusinessDetails: BusinessDetails{ BusinessName: businessName, @@ -213,3 +231,139 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ Data: responseData, }) } + +func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { + log.Println("UpdateBankDetails running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + var req struct { + BankName string `json:"bank_name"` + AccountHolderName string `json:"account_holder_name"` + AccountNumber string `json:"account_number"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request payload"}) + return + } + + var merchantID string + err := h.DB.QueryRow("SELECT merchant_id FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", username).Scan(&merchantID) + if err != nil { + log.Println("Error finding merchant for update:", err) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) + return + } + + _, err = h.DB.Exec("UPDATE merchants SET settlement_bank_name=?, settlement_account_name=?, settlement_account_number=? WHERE merchant_id = ?", req.BankName, req.AccountHolderName, req.AccountNumber, merchantID) + + if err != nil { + log.Println("Update error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update bank details"}) + return + } + + // Insert a system transaction to log the update + sysTxnID := fmt.Sprintf("SYS-SETTLE-%d", time.Now().UnixMilli()) + _, _ = h.DB.Exec(` + INSERT INTO transactions + (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) + VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Settlement bank details were updated by the merchant.')`, + sysTxnID, merchantID) + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Bank details updated"}) +} + +func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { + log.Println("UploadDocument running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + err := r.ParseMultipartForm(4 << 20) // Limit memory to 4MB + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large"}) + return + } + + docType := r.FormValue("document_type") + file, handler, err := r.FormFile("document") + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Failed to read file"}) + return + } + defer file.Close() + + if handler.Size > 4*1024*1024 { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large. Maximum size is 4MB."}) + return + } + + ext := strings.ToLower(filepath.Ext(handler.Filename)) + validExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".pdf": true, + } + if !validExts[ext] { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid file format. Only pictures, PDF, and Word docs are allowed."}) + return + } + + // Ensure uploads directory exists + uploadDir := "storage/documents" + os.MkdirAll(uploadDir, os.ModePerm) + + // Save file with auto-generated filename to eliminate raw filename + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + dst, err := os.Create(filePath) + if err != nil { + log.Println("File create error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + log.Println("File copy error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + + dbPath := "/" + strings.ReplaceAll(filePath, "\\", "/") + + col := "business_document" + if docType == "BIR Certificate" { + col = "bir_document" + } else if docType == "Other Document" { + col = "other_document" + } + + // Remove old file if it exists + var oldDbPath *string + qOld := fmt.Sprintf("SELECT %s FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) + if err := h.DB.QueryRow(qOld, username).Scan(&oldDbPath); err == nil && oldDbPath != nil { + oldFile := strings.TrimPrefix(*oldDbPath, "/") + if oldFile != "" { + os.Remove(oldFile) // Best effort delete + } + } + + query := fmt.Sprintf("UPDATE merchants SET %s=?, document_status='Pending' WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) + _, err = h.DB.Exec(query, dbPath, username) + if err != nil { + log.Println("DB update error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update DB"}) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "File uploaded successfully"}) +} diff --git a/backend/internal/pkg/smtpbody/smtp.go b/backend/internal/pkg/smtpbody/smtp.go index caec117..668f5d1 100644 --- a/backend/internal/pkg/smtpbody/smtp.go +++ b/backend/internal/pkg/smtpbody/smtp.go @@ -285,6 +285,45 @@ func EmailVerificationBody() string { + +` +} + +func MerchantDeletedEmail() string { + return ` + + + + Unicard Account Deleted + + + +
+
+

Unicard

+
+
+

Hello %s,

+

This is a formal notice that your Unicard merchant account has been permanently deleted by an administrator.

+
+ Note:
+ All terminals associated with your account have been deactivated and unregistered. Your account access has been revoked. +
+

If you have any pending settlements or questions regarding this action, please contact our support team immediately.

+

Thank you,
The Unicard Team

+
+
diff --git a/docs/unicard.sql b/docs/unicard.sql index 221349a..6e4f29f 100644 --- a/docs/unicard.sql +++ b/docs/unicard.sql @@ -45,6 +45,8 @@ CREATE TABLE merchants ( business_type ENUM('retail', 'transportation', 'food_and_beverage', 'services', 'other') NOT NULL COMMENT 'Industry category for transaction filtering and analytics', business_registration_number VARCHAR(100) NULL UNIQUE COMMENT 'Official government tracking number (e.g., DTI, SEC, or BIR TIN)', business_address TEXT NOT NULL COMMENT 'Physical location of the main store or corporate headquarters', + city VARCHAR(100) NULL COMMENT 'City or municipality where the primary business is physically located', + postal_code VARCHAR(20) NULL COMMENT 'Postal or ZIP code of the primary business address location', user_id VARCHAR(50) NOT NULL COMMENT 'Links to the user_id in the users table who owns this business account', owner_name VARCHAR(100) NOT NULL COMMENT 'Full name of the principal owner or authorized business representative', business_email VARCHAR(100) NOT NULL UNIQUE COMMENT 'Official company contact email address for corporate updates and billing statements', @@ -53,14 +55,11 @@ CREATE TABLE merchants ( 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', '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', + status ENUM('pending approval', 'approved', 'rejected', 'active', 'suspended', 'deleted') DEFAULT 'pending approval' COMMENT 'Operational state of the merchant ecosystem tenancy', + business_document VARCHAR(255) NULL COMMENT 'Primary business registration document path (DTI or SEC depending on business_structure)', 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', - business_document VARCHAR(255) NULL COMMENT 'Primary business registration document path (DTI or SEC depending on business_structure)', business_structure ENUM('sole_proprietorship', 'partnership', 'corporation', 'cooperative') NULL COMMENT 'Legal structure of the business entity used to determine required registration documents', - city VARCHAR(100) NULL COMMENT 'City or municipality where the primary business is physically located', - postal_code VARCHAR(20) NULL COMMENT 'Postal or ZIP code of the primary business address location', document_status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending' COMMENT 'Verification state of the submitted business registration and compliance documents', message TEXT NULL COMMENT 'Admin-written note or feedback message regarding document review or account status changes', approved_by VARCHAR(50) NULL COMMENT 'The user_id of the Super Admin who verified and activated this company profile', @@ -116,9 +115,9 @@ CREATE TABLE transactions ( merchant_id VARCHAR(50) NULL COMMENT 'Identifies vendor company collecting the payment token via merchants.merchant_id', terminal_id VARCHAR(50) NULL COMMENT 'Identifies physical ESP32 or terminal node hardware unit triggering the capture via terminals.terminal_id', transaction_type ENUM('payment', 'refund', 'reversal', 'topup', 'withdrawal') DEFAULT 'payment' COMMENT 'Categorizes ledger records to process standard deductions or transaction void mappings cleanly', - amount DECIMAL(10, 2) NOT NULL COMMENT 'Total Gross fiat amount captured from the card wallet balance tracking column', - points_earned DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Total points earned from the transaction', - service_fee DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Platform revenue slice collected by UniCard ecosystem engine per tap processing action', + amount DECIMAL(10, 2) NULL COMMENT 'Total Gross fiat amount captured from the card wallet balance tracking column', + points_earned DECIMAL(10, 2) NULL DEFAULT NULL COMMENT 'Total points earned from the transaction', + service_fee DECIMAL(10, 2) NULL DEFAULT NULL COMMENT 'Platform revenue slice collected by UniCard ecosystem engine per tap processing action', net_merchant_payout DECIMAL(10, 2) GENERATED ALWAYS AS (amount - service_fee) STORED COMMENT 'Automatically calculated column tracking exactly how much money goes to the merchant after our platform cut', processed_by VARCHAR(50) NULL COMMENT 'Public string identifier users.user_id capturing the identity of the physical staff member operating the payment client terminal', status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'Lifecycle state of the transaction for tracking settlement', diff --git a/frontend/assets/admin/merchant_info.js b/frontend/assets/admin/merchant_info.js index c531643..b052243 100644 --- a/frontend/assets/admin/merchant_info.js +++ b/frontend/assets/admin/merchant_info.js @@ -51,6 +51,8 @@ function populateMerchantUI(merchant) { document.getElementById('businessType').textContent = merchant.BusinessType.replace(/_/g, ' '); document.getElementById('registrationNum').textContent = merchant.RegistrationNum || 'N/A'; document.getElementById('businessAddress').textContent = merchant.BusinessAddress; + document.getElementById('businessCity').textContent = merchant.City || 'N/A'; + document.getElementById('businessPostalCode').textContent = merchant.PostalCode || 'N/A'; document.getElementById('createdAt').textContent = new Date(merchant.CreatedAt).toLocaleDateString(); document.getElementById('ownerName').textContent = merchant.OwnerName; @@ -60,7 +62,8 @@ function populateMerchantUI(merchant) { const renderDoc = (url, elId) => { const el = document.getElementById(elId); if (url) { - el.innerHTML = ``; @@ -69,7 +72,7 @@ function populateMerchantUI(merchant) { } }; - renderDoc(merchant.DtiDocument, 'dtiDocumentLink'); + renderDoc(merchant.BusinessDocument, 'businessDocumentLink'); renderDoc(merchant.BirDocument, 'birDocumentLink'); renderDoc(merchant.OtherDocument, 'otherDocumentLink'); @@ -109,7 +112,7 @@ function populateMerchantUI(merchant) { btnApprove.classList.add('hidden'); btnReject.classList.add('hidden'); btnDelete.classList.remove('hidden'); - } else if (statusLower === 'pending_approval' || statusLower === 'pending approval') { + } else if (statusLower === 'pending_approval' || statusLower === 'pending approval' || (statusLower === 'rejected' && merchant.DocumentStatus && merchant.DocumentStatus.toLowerCase() === 'pending')) { btnSuspend.classList.add('hidden'); btnApprove.classList.remove('hidden'); btnReject.classList.remove('hidden'); @@ -122,19 +125,97 @@ function populateMerchantUI(merchant) { } } +let currentZoom = 1; +let isDragging = false; +let startX = 0, startY = 0, translateX = 0, translateY = 0; + window.openDocumentViewer = function(url) { const modal = document.getElementById('documentViewerModal'); const iframe = document.getElementById('documentViewerFrame'); - const downloadBtn = document.getElementById('downloadDocumentBtn'); + const img = document.getElementById('documentViewerImage'); + const zoomControls = document.getElementById('imageZoomControls'); - if (modal && iframe && downloadBtn) { - iframe.src = url; - downloadBtn.href = url; + if (modal && iframe && img) { + const isImage = url.match(/\.(jpeg|jpg|gif|png|webp)$/i); + if (isImage) { + iframe.classList.add('hidden'); + img.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.remove('hidden'); + img.src = url; + resetZoom(); + } else { + img.classList.add('hidden'); + iframe.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.add('hidden'); + iframe.src = url + '#toolbar=0&navpanes=0&scrollbar=0'; + } modal.classList.remove('hidden'); } }; +function resetZoom() { + currentZoom = 1; + translateX = 0; + translateY = 0; + updateZoomTransform(); +} + +function updateZoomTransform() { + const img = document.getElementById('documentViewerImage'); + const zoomLevelEl = document.getElementById('zoomLevel'); + if (img) { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentZoom})`; + } + if (zoomLevelEl) { + zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%'; + } +} + document.addEventListener('DOMContentLoaded', () => { + // Zoom Controls + const zoomInBtn = document.getElementById('zoomInBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + const zoomResetBtn = document.getElementById('zoomResetBtn'); + const img = document.getElementById('documentViewerImage'); + + if (zoomInBtn) zoomInBtn.addEventListener('click', () => { currentZoom = Math.min(currentZoom + 0.25, 4); updateZoomTransform(); }); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => { currentZoom = Math.max(currentZoom - 0.25, 0.5); updateZoomTransform(); }); + if (zoomResetBtn) zoomResetBtn.addEventListener('click', resetZoom); + + if (img) { + img.parentElement.addEventListener('wheel', (e) => { + if (img.classList.contains('hidden')) return; + e.preventDefault(); + if (e.deltaY < 0) { + currentZoom = Math.min(currentZoom + 0.1, 4); + } else { + currentZoom = Math.max(currentZoom - 0.1, 0.5); + } + updateZoomTransform(); + }); + + // Drag to pan + img.parentElement.addEventListener('mousedown', (e) => { + if (img.classList.contains('hidden')) return; + isDragging = true; + img.parentElement.style.cursor = 'grabbing'; + startX = e.clientX - translateX; + startY = e.clientY - translateY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + translateX = e.clientX - startX; + translateY = e.clientY - startY; + updateZoomTransform(); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + if (img) img.parentElement.style.cursor = 'default'; + }); + } + fetchUnassignedTerminals(); fetchMerchantData(); diff --git a/frontend/assets/js/merchant_account.js b/frontend/assets/js/merchant_account.js index c450c80..9ba0544 100644 --- a/frontend/assets/js/merchant_account.js +++ b/frontend/assets/js/merchant_account.js @@ -28,6 +28,23 @@ document.addEventListener("DOMContentLoaded", () => { statusEl.innerHTML = `${data.account_status}`; } + // Document Status and Message + const docStatusEl = document.getElementById('displayDocumentStatus'); + if (docStatusEl && data.document_status) { + const ds = data.document_status.toLowerCase(); + let dsColor = 'text-amber-600 bg-amber-100'; + if (ds === 'approved') dsColor = 'text-emerald-600 bg-emerald-100'; + else if (ds === 'rejected') dsColor = 'text-red-600 bg-red-100'; + docStatusEl.innerHTML = `Docs: ${data.document_status}`; + } + + const msgContainer = document.getElementById('docMessageContainer'); + const msgEl = document.getElementById('displayAccountMessage'); + if (msgContainer && msgEl && data.account_message && data.account_message.trim() !== '') { + msgEl.textContent = data.account_message; + msgContainer.classList.remove('hidden'); + } + // Business Details Form if (document.getElementById('bizName')) document.getElementById('bizName').value = details.business_name || ''; if (document.getElementById('bizType')) document.getElementById('bizType').value = details.business_type || ''; @@ -42,50 +59,367 @@ document.addEventListener("DOMContentLoaded", () => { if (document.getElementById('bankHolder')) document.getElementById('bankHolder').value = bank.account_holder_name || ''; if (document.getElementById('bankAccount')) document.getElementById('bankAccount').value = bank.account_number || ''; + const isOk = data.account_status.toLowerCase() === 'active' || data.account_status.toLowerCase() === 'approved'; + if (!isOk) { + const actionContainer = document.getElementById('bankDetailsActionContainer'); + const editBtn = document.getElementById('editBankDetailsBtn'); + const saveBtn = document.getElementById('saveBankDetailsBtn'); + const cancelBtn = document.getElementById('bankDetailsCancelBtn'); + if (actionContainer) actionContainer.classList.remove('hidden'); + + const initialBankDetails = { + bankName: bank.bank_name || '', + bankHolder: bank.account_holder_name || '', + bankAccount: bank.account_number || '' + }; + + function checkEnableBankSave() { + if (!saveBtn) return; + const current = { + bankName: document.getElementById('bankName').value, + bankHolder: document.getElementById('bankHolder').value, + bankAccount: document.getElementById('bankAccount').value + }; + const isChanged = current.bankName !== initialBankDetails.bankName || + current.bankHolder !== initialBankDetails.bankHolder || + current.bankAccount !== initialBankDetails.bankAccount; + if (isChanged) { + saveBtn.disabled = false; + saveBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } else { + saveBtn.disabled = true; + saveBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } + } + + if (editBtn) { + editBtn.addEventListener('click', () => { + ['bankName', 'bankHolder', 'bankAccount'].forEach(id => { + const el = document.getElementById(id); + if (el) { + el.removeAttribute('readonly'); + el.removeAttribute('disabled'); + el.classList.remove('bg-gray-50', 'text-gray-600', 'cursor-not-allowed'); + el.classList.add('bg-white', 'text-gray-900', 'ring-2', 'ring-indigo-100', 'cursor-pointer'); + el.addEventListener('input', checkEnableBankSave); + el.addEventListener('change', checkEnableBankSave); + } + }); + editBtn.classList.add('hidden'); + saveBtn.classList.remove('hidden'); + if (cancelBtn) cancelBtn.classList.remove('hidden'); + checkEnableBankSave(); + }); + } + if (cancelBtn) { + cancelBtn.addEventListener('click', () => window.location.reload()); + } + + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const newBank = { + bank_name: document.getElementById('bankName').value, + account_holder_name: document.getElementById('bankHolder').value, + account_number: document.getElementById('bankAccount').value + }; + saveBtn.textContent = 'Saving...'; + saveBtn.disabled = true; + try { + const res = await fetch(`/v1/merchant/${window.CURRENT_USERNAME}/update-bank`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newBank) + }); + const j = await res.json(); + if (j.success) { + alert('Bank details updated successfully!'); + window.location.reload(); + } else { + alert('Failed: ' + j.message); + saveBtn.textContent = 'Save Changes'; + saveBtn.disabled = false; + } + } catch (e) { + console.error(e); + alert('Network error'); + saveBtn.textContent = 'Save Changes'; + saveBtn.disabled = false; + } + }); + } + } + // Documents const docsContainer = document.getElementById('documentsContainer'); if (docsContainer) { - if (docs.length === 0) { - docsContainer.innerHTML = '

No documents uploaded.

'; - } else { - docsContainer.innerHTML = ''; - docs.forEach(doc => { - const isApproved = doc.status.toLowerCase() === 'approved'; - const isPending = doc.status.toLowerCase() === 'pending'; - let statusColor = 'text-red-600 bg-red-50'; - let icon = ''; - if (isApproved) { - statusColor = 'text-emerald-600 bg-emerald-50'; - icon = ''; - } else if (isPending) { - statusColor = 'text-amber-600 bg-amber-50'; - icon = ''; - } + docsContainer.innerHTML = ''; + + const structure = details.business_structure || ''; + const regLabel = 'DTI/SEC Registration'; + + const expectedDocs = [ + { type: regLabel, key: 'business_document' }, + { type: 'BIR Certificate', key: 'bir_document' }, + { type: 'Other Document', key: 'other_document' } + ]; + + expectedDocs.forEach(ed => { + // Find if merchant has uploaded this doc type + const found = docs.find(d => d.document_type === ed.type); + + let docType = ed.type; + let status = found ? found.status : 'Missing'; + let message = found ? found.message : 'Please upload this document'; + let docUrl = found ? found.document_url : ''; + + const isApproved = status.toLowerCase() === 'approved'; + const isPending = status.toLowerCase() === 'pending'; + const isMissing = status.toLowerCase() === 'missing'; + + let statusColor = 'text-red-600 bg-red-50'; + let icon = ''; + if (isApproved) { + statusColor = 'text-emerald-600 bg-emerald-50'; + icon = ''; + } else if (isPending) { + statusColor = 'text-amber-600 bg-amber-50'; + icon = ''; + } + + const isImage = docUrl && docUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i); + const defaultIconHtml = ``; + const previewHtml = isImage ? `${docType} Preview` : defaultIconHtml; - const docDiv = document.createElement('div'); - docDiv.className = 'flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50/50 hover:bg-gray-50 transition-colors'; - docDiv.innerHTML = ` -
-
- -
-
-

${doc.document_type}

- ${doc.message ? `

${doc.message}

` : ''} -
+ const docDiv = document.createElement('div'); + docDiv.className = 'flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50/50 hover:bg-gray-50 transition-colors'; + docDiv.innerHTML = ` +
+
+ ${previewHtml}
-
- - ${icon} - ${doc.status} - - ${doc.document_url ? `` : ''} +
+

${docType}

+ ${message ? `

${message}

` : ''}
- `; - docsContainer.appendChild(docDiv); - }); +
+
+ + ${icon} + ${status} + + ${docUrl ? `` : ''} + ${!isApproved ? ` +
+ +
+ ` : ''} +
+ `; + docsContainer.appendChild(docDiv); + }); + } + + // Store actual selected files securely so native cancel doesn't erase them + const selectedFilesMap = new Map(); + const globalDocBtn = document.getElementById('globalDocUploadBtn'); + let isDocEditMode = false; + + function checkEnableSave() { + const hasFiles = selectedFilesMap.size > 0; + if (globalDocBtn) { + globalDocBtn.disabled = !hasFiles; + if (hasFiles) { + globalDocBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + globalDocBtn.classList.add('hover:bg-indigo-700'); + } else { + globalDocBtn.classList.add('opacity-50', 'cursor-not-allowed'); + globalDocBtn.classList.remove('hover:bg-indigo-700'); + } } } + + const globalDocCancelBtn = document.getElementById('globalDocCancelBtn'); + + if (globalDocCancelBtn) { + globalDocCancelBtn.addEventListener('click', () => window.location.reload()); + } + + if (globalDocBtn) { + globalDocBtn.addEventListener('click', async () => { + if (!isDocEditMode) { + // Enter edit mode + isDocEditMode = true; + globalDocBtn.textContent = 'Save Changes'; + globalDocBtn.classList.remove('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200'); + globalDocBtn.classList.add('bg-indigo-600', 'text-white', 'opacity-50', 'cursor-not-allowed'); + globalDocBtn.disabled = true; + + if (globalDocCancelBtn) globalDocCancelBtn.classList.remove('hidden'); + + // Enable dropzones + document.querySelectorAll('.doc-dropzone').forEach(dz => { + dz.classList.remove('pointer-events-none', 'opacity-60'); + dz.classList.add('cursor-pointer', 'hover:bg-slate-100'); + const input = dz.querySelector('input'); + if (input) input.disabled = false; + }); + } else { + // Save Changes mode (Upload) + if (selectedFilesMap.size === 0) return; + + globalDocBtn.textContent = 'Uploading...'; + globalDocBtn.disabled = true; + globalDocBtn.classList.add('opacity-70'); + + let successCount = 0; + let failCount = 0; + + for (const [docType, file] of selectedFilesMap.entries()) { + const formData = new FormData(); + formData.append('document', file); + formData.append('document_type', docType); + + try { + const res = await fetch(`/v1/merchant/${window.CURRENT_USERNAME}/upload-document`, { + method: 'POST', + body: formData + }); + const j = await res.json(); + if (j.success) successCount++; + else { + failCount++; + console.error(j.message); + } + } catch (err) { + console.error(err); + failCount++; + } + } + + if (failCount === 0) { + alert('Documents uploaded successfully!'); + } else { + alert(`Uploaded ${successCount} documents. Failed ${failCount}.`); + } + window.location.reload(); + } + }); + } + + document.querySelectorAll('.doc-dropzone').forEach(dropzone => { + const input = dropzone.querySelector('input'); + const text = dropzone.querySelector('.dropzone-text'); + const hint = dropzone.querySelector('.dropzone-hint'); + const svgIcon = dropzone.querySelector('svg'); + + const originalSvg = ''; + const successSvg = ''; + + function resetDropzone() { + text.textContent = 'Click or drag & drop'; + text.classList.remove('text-emerald-600', 'font-bold'); + hint.textContent = 'PDF, JPG, PNG (Max 4MB)'; + hint.classList.remove('text-emerald-500', 'font-medium'); + if(svgIcon) { + svgIcon.innerHTML = originalSvg; + svgIcon.classList.remove('text-emerald-500'); + svgIcon.classList.add('text-slate-400'); + } + dropzone.classList.remove('border-indigo-400', 'bg-indigo-50', 'border-emerald-400', 'bg-emerald-50'); + } + + function setSuccessDropzone(filename) { + text.textContent = filename; + text.title = filename; + text.classList.add('text-emerald-600', 'font-bold'); + hint.textContent = 'Ready to save'; + hint.classList.add('text-emerald-500', 'font-medium'); + if(svgIcon) { + svgIcon.innerHTML = successSvg; + svgIcon.classList.remove('text-slate-400'); + svgIcon.classList.add('text-emerald-500'); + } + dropzone.classList.remove('border-indigo-400', 'bg-indigo-50'); + dropzone.classList.add('border-emerald-400', 'bg-emerald-50'); + } + + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + if (input && !input.disabled) { + dropzone.classList.add('bg-indigo-50', 'border-indigo-300'); + } + }); + + dropzone.addEventListener('dragleave', (e) => { + e.preventDefault(); + if (input && !input.disabled && !selectedFilesMap.has(input.dataset.doctype)) { + dropzone.classList.remove('bg-indigo-50', 'border-indigo-300'); + } + }); + + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + if (input && !input.disabled && e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; + processFile(file, input.dataset.doctype); + } else if (!selectedFilesMap.has(input.dataset.doctype)) { + dropzone.classList.remove('bg-indigo-50', 'border-indigo-300'); + } + }); + + if (input) { + input.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) { + // Ignore native cancel event to preserve previously stored file + return; + } + processFile(file, input.dataset.doctype); + // Reset input so the same file can trigger a change event again if needed + e.target.value = ''; + }); + } + + function updatePreview(file) { + const previewContainer = dropzone.closest('.flex.items-center.justify-between').querySelector('.doc-preview'); + if (previewContainer) { + if (file && file.type.startsWith('image/')) { + const url = URL.createObjectURL(file); + previewContainer.innerHTML = `New Preview`; + } else { + previewContainer.innerHTML = ``; + } + } + } + + function processFile(file, docType) { + const maxSize = 4 * 1024 * 1024; + if (file.size > maxSize) { + alert('File is too large. Please upload a file smaller than 4MB.'); + if (!selectedFilesMap.has(docType)) resetDropzone(); + return; + } + + const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + if (!validTypes.includes(file.type)) { + alert('Invalid file format. Please upload an image, PDF, or Word document.'); + if (!selectedFilesMap.has(docType)) resetDropzone(); + return; + } + + selectedFilesMap.set(docType, file); + setSuccessDropzone(file.name); + checkEnableSave(); + updatePreview(file); + } + }); // Remove all animate-pulse classes to reveal inputs document.querySelectorAll('.animate-pulse').forEach(el => { @@ -116,3 +450,94 @@ document.addEventListener("DOMContentLoaded", () => { fetchAccountData(); }); + +let currentZoom = 1; +let isDragging = false; +let startX = 0, startY = 0, translateX = 0, translateY = 0; + +window.openDocumentViewer = function(url) { + const modal = document.getElementById('documentViewerModal'); + const iframe = document.getElementById('documentViewerFrame'); + const img = document.getElementById('documentViewerImage'); + const zoomControls = document.getElementById('imageZoomControls'); + + if (modal && iframe && img) { + const isImage = url.match(/\.(jpeg|jpg|gif|png|webp)$/i); + if (isImage) { + iframe.classList.add('hidden'); + img.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.remove('hidden'); + img.src = url; + resetZoom(); + } else { + img.classList.add('hidden'); + iframe.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.add('hidden'); + iframe.src = url + '#toolbar=0&navpanes=0&scrollbar=0'; + } + modal.classList.remove('hidden'); + } +}; + +function resetZoom() { + currentZoom = 1; + translateX = 0; + translateY = 0; + updateZoomTransform(); +} + +function updateZoomTransform() { + const img = document.getElementById('documentViewerImage'); + const zoomLevelEl = document.getElementById('zoomLevel'); + if (img) { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentZoom})`; + } + if (zoomLevelEl) { + zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%'; + } +} + +document.addEventListener('DOMContentLoaded', () => { + // Zoom Controls setup (will attach if elements exist) + const zoomInBtn = document.getElementById('zoomInBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + const zoomResetBtn = document.getElementById('zoomResetBtn'); + const img = document.getElementById('documentViewerImage'); + + if (zoomInBtn) zoomInBtn.addEventListener('click', () => { currentZoom = Math.min(currentZoom + 0.25, 4); updateZoomTransform(); }); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => { currentZoom = Math.max(currentZoom - 0.25, 0.5); updateZoomTransform(); }); + if (zoomResetBtn) zoomResetBtn.addEventListener('click', resetZoom); + + if (img) { + img.parentElement.addEventListener('wheel', (e) => { + if (img.classList.contains('hidden')) return; + e.preventDefault(); + if (e.deltaY < 0) { + currentZoom = Math.min(currentZoom + 0.1, 4); + } else { + currentZoom = Math.max(currentZoom - 0.1, 0.5); + } + updateZoomTransform(); + }); + + img.parentElement.addEventListener('mousedown', (e) => { + if (img.classList.contains('hidden')) return; + isDragging = true; + img.parentElement.style.cursor = 'grabbing'; + startX = e.clientX - translateX; + startY = e.clientY - translateY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + translateX = e.clientX - startX; + translateY = e.clientY - startY; + updateZoomTransform(); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + if (img) img.parentElement.style.cursor = 'default'; + }); + } +}); diff --git a/frontend/assets/js/merchant_dashboard.js b/frontend/assets/js/merchant_dashboard.js index 857a001..c719e59 100644 --- a/frontend/assets/js/merchant_dashboard.js +++ b/frontend/assets/js/merchant_dashboard.js @@ -192,6 +192,35 @@ document.addEventListener("DOMContentLoaded", () => { netAmtEl.textContent = `${sign}₱${netValue.toFixed(2)}`; netAmtEl.className = `font-bold text-lg ${colorClass}`; + const isSystemEvent = grossAmt === 0 && Number(tx.service_fee || 0) === 0; + + if (isSystemEvent) { + let sysType = "System Notification"; + if (tx.transaction_id && tx.transaction_id.toLowerCase().startsWith("welcome")) { + sysType = "Account Approval"; + } else if (tx.description && tx.description.toLowerCase().includes("username")) { + sysType = "Profile Update"; + } else if (tx.description && tx.description.toLowerCase().includes("settlement")) { + sysType = "Bank Update"; + } else if (tx.transaction_type) { + sysType = tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1); + } + + document.getElementById("modalTxnType").textContent = sysType; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.add("hidden"); + } else { + document.getElementById("modalTxnType").textContent = tx.transaction_type ? tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1) : "N/A"; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.remove("hidden"); + } + txnModal.classList.remove('hidden'); setTimeout(() => { txnModal.classList.add('opacity-100'); diff --git a/frontend/assets/js/merchant_signup.js b/frontend/assets/js/merchant_signup.js index e72b40d..264a78b 100644 --- a/frontend/assets/js/merchant_signup.js +++ b/frontend/assets/js/merchant_signup.js @@ -45,11 +45,11 @@ document.addEventListener("DOMContentLoaded", () => { reader.onerror = error => reject(error); }); - const dtiFile = document.getElementById("dtiDocument").files[0]; + const bizDocFile = document.getElementById("businessDocument").files[0]; const birFile = document.getElementById("birDocument").files[0]; const otherFile = document.getElementById("otherDocument").files[0]; - const dtiDocument = await toBase64(dtiFile); + const businessDocument = await toBase64(bizDocFile); const birDocument = await toBase64(birFile); const otherDocument = await toBase64(otherFile); @@ -62,7 +62,7 @@ document.addEventListener("DOMContentLoaded", () => { businessPhone, businessEmail, password, - dtiDocument, + businessDocument, birDocument, otherDocument }; diff --git a/frontend/assets/js/merchant_transactions.js b/frontend/assets/js/merchant_transactions.js index 4294bb8..8218367 100644 --- a/frontend/assets/js/merchant_transactions.js +++ b/frontend/assets/js/merchant_transactions.js @@ -204,6 +204,35 @@ document.addEventListener("DOMContentLoaded", () => { netAmtEl.textContent = `${sign}${formatCurrency(Math.abs(netValue))}`; netAmtEl.className = `font-bold text-lg ${colorClass}`; + const isSystemEvent = grossAmt === 0 && Number(tx.service_fee || 0) === 0; + + if (isSystemEvent) { + let sysType = "System Notification"; + if (tx.transaction_id && tx.transaction_id.toLowerCase().startsWith("welcome")) { + sysType = "Account Approval"; + } else if (tx.description && tx.description.toLowerCase().includes("username")) { + sysType = "Profile Update"; + } else if (tx.description && tx.description.toLowerCase().includes("settlement")) { + sysType = "Bank Update"; + } else if (tx.transaction_type) { + sysType = tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1); + } + + document.getElementById("modalTxnType").textContent = sysType; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.add("hidden"); + } else { + document.getElementById("modalTxnType").textContent = tx.transaction_type ? tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1) : "N/A"; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.remove("hidden"); + } + txnModal.classList.remove('hidden'); setTimeout(() => { txnModal.classList.add('opacity-100'); diff --git a/frontend/templates/admin/addCards.html b/frontend/templates/admin/addCards.html index 16ff446..fe77f22 100644 --- a/frontend/templates/admin/addCards.html +++ b/frontend/templates/admin/addCards.html @@ -7,12 +7,11 @@ UniCard Admin - Add Card - + - - - {{template "admin_sidebar" .}} + +
+ + {{template "admin_sidebar" .}} - -
+ +
@@ -173,7 +173,7 @@

UniCard

- +
diff --git a/frontend/templates/admin/admin_sidebar.html b/frontend/templates/admin/admin_sidebar.html index 4f89d5b..8ce39a3 100644 --- a/frontend/templates/admin/admin_sidebar.html +++ b/frontend/templates/admin/admin_sidebar.html @@ -1,25 +1,27 @@ {{define "admin_sidebar"}} -