Some checks failed
Build and Deploy LittleShop / Build TeleBot Docker Image (push) Failing after 11s
Build and Deploy LittleShop / Build LittleShop Docker Image (push) Failing after 15s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Has been skipped
Major Feature Additions: - Customer management: Full CRUD with data export and privacy compliance - Payment management: Centralized payment tracking and administration - Push notification subscriptions: Manage and track web push subscriptions Security Enhancements: - IP whitelist middleware for administrative endpoints - Data retention service with configurable policies - Enhanced push notification security documentation - Security fixes progress tracking (2025-11-14) UI/UX Improvements: - Enhanced navigation with improved mobile responsiveness - Updated admin dashboard with order status counts - Improved product CRUD forms - New customer and payment management interfaces Backend Improvements: - Extended customer service with data export capabilities - Enhanced order service with status count queries - Improved crypto payment service with better error handling - Updated validators and configuration Documentation: - DEPLOYMENT_NGINX_GUIDE.md: Nginx deployment instructions - IP_STORAGE_ANALYSIS.md: IP storage security analysis - PUSH_NOTIFICATION_SECURITY.md: Push notification security guide - UI_UX_IMPROVEMENT_PLAN.md: Planned UI/UX enhancements - UI_UX_IMPROVEMENTS_COMPLETED.md: Completed improvements Cleanup: - Removed temporary database WAL files - Removed stale commit message file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
446 lines
19 KiB
Plaintext
446 lines
19 KiB
Plaintext
@model LittleShop.DTOs.CustomerDto
|
|
@{
|
|
ViewData["Title"] = $"Customer: {Model.TelegramDisplayName}";
|
|
var customerOrders = ViewData["CustomerOrders"] as List<LittleShop.DTOs.OrderDto> ?? new List<LittleShop.DTOs.OrderDto>();
|
|
}
|
|
|
|
<div class="row mb-3">
|
|
<div class="col">
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Dashboard")">Dashboard</a></li>
|
|
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Customers")">Customers</a></li>
|
|
<li class="breadcrumb-item active">@Model.TelegramDisplayName</li>
|
|
</ol>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customer Header -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-8">
|
|
<h1>
|
|
<i class="fas fa-user-circle"></i> @Model.TelegramDisplayName
|
|
@if (Model.IsBlocked)
|
|
{
|
|
<span class="badge bg-danger ms-2">
|
|
<i class="fas fa-ban"></i> BLOCKED
|
|
</span>
|
|
}
|
|
else if (!Model.IsActive)
|
|
{
|
|
<span class="badge bg-secondary ms-2">
|
|
<i class="fas fa-trash"></i> DELETED
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-success ms-2">
|
|
<i class="fas fa-check-circle"></i> ACTIVE
|
|
</span>
|
|
}
|
|
</h1>
|
|
<p class="text-muted mb-0">
|
|
Telegram: @@<strong>@Model.TelegramUsername</strong> | ID: @Model.TelegramUserId
|
|
</p>
|
|
</div>
|
|
<div class="col-md-4 text-end">
|
|
<!-- GDPR Data Export Buttons -->
|
|
<div class="btn-group me-2" role="group">
|
|
<a href="@Url.Action("ExportJson", "Customers", new { id = Model.Id })"
|
|
class="btn btn-info"
|
|
title="Export all customer data as JSON (GDPR Right to Data Portability)">
|
|
<i class="fas fa-file-code"></i> Export JSON
|
|
</a>
|
|
<a href="@Url.Action("ExportCsv", "Customers", new { id = Model.Id })"
|
|
class="btn btn-success"
|
|
title="Export all customer data as CSV (GDPR Right to Data Portability)">
|
|
<i class="fas fa-file-csv"></i> Export CSV
|
|
</a>
|
|
</div>
|
|
<a href="@Url.Action("Index", "Customers")" class="btn btn-secondary">
|
|
<i class="fas fa-arrow-left"></i> Back to List
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customer Information Cards -->
|
|
<div class="row mb-4">
|
|
<!-- Basic Information -->
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header bg-primary text-white">
|
|
<h5 class="mb-0"><i class="fas fa-id-card"></i> Customer Information</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<table class="table table-sm mb-0">
|
|
<tr>
|
|
<th style="width: 40%;">Display Name:</th>
|
|
<td>@Model.TelegramDisplayName</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Full Name:</th>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(Model.TelegramFirstName) || !string.IsNullOrEmpty(Model.TelegramLastName))
|
|
{
|
|
@Model.TelegramFirstName @Model.TelegramLastName
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not provided</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Telegram Username:</th>
|
|
<td>@@<strong>@Model.TelegramUsername</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th>Telegram User ID:</th>
|
|
<td><code>@Model.TelegramUserId</code></td>
|
|
</tr>
|
|
<tr>
|
|
<th>Language:</th>
|
|
<td>@Model.Language.ToUpper()</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Timezone:</th>
|
|
<td>@Model.Timezone</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Customer Since:</th>
|
|
<td>@Model.CreatedAt.ToString("MMMM dd, yyyy")</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Last Active:</th>
|
|
<td>
|
|
@if (Model.LastActiveAt > DateTime.MinValue)
|
|
{
|
|
var daysAgo = (DateTime.UtcNow - Model.LastActiveAt).Days;
|
|
@Model.LastActiveAt.ToString("MMMM dd, yyyy HH:mm")
|
|
@if (daysAgo <= 1)
|
|
{
|
|
<span class="badge bg-success">Active today</span>
|
|
}
|
|
else if (daysAgo <= 7)
|
|
{
|
|
<span class="badge bg-info">@daysAgo days ago</span>
|
|
}
|
|
else if (daysAgo <= 30)
|
|
{
|
|
<span class="badge bg-warning">@daysAgo days ago</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-danger">@daysAgo days ago</span>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Never</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Risk Score & Metrics -->
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header bg-warning text-dark">
|
|
<h5 class="mb-0"><i class="fas fa-shield-alt"></i> Risk Assessment</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
@{
|
|
var riskLevel = Model.RiskScore >= 80 ? "Very High Risk" :
|
|
Model.RiskScore >= 50 ? "High Risk" :
|
|
Model.RiskScore >= 30 ? "Medium Risk" :
|
|
"Low Risk";
|
|
var riskClass = Model.RiskScore >= 80 ? "danger" :
|
|
Model.RiskScore >= 50 ? "warning" :
|
|
Model.RiskScore >= 30 ? "info" :
|
|
"success";
|
|
}
|
|
<div class="text-center mb-3">
|
|
<h1 class="display-4 text-@riskClass mb-0">
|
|
<i class="fas fa-exclamation-triangle"></i> @Model.RiskScore
|
|
</h1>
|
|
<p class="text-@riskClass fw-bold mb-0">@riskLevel</p>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<table class="table table-sm mb-0">
|
|
<tr>
|
|
<th style="width: 60%;">Total Orders:</th>
|
|
<td class="text-end"><strong>@Model.TotalOrders</strong></td>
|
|
</tr>
|
|
<tr class="table-success">
|
|
<th>Successful Orders:</th>
|
|
<td class="text-end text-success"><strong>@Model.SuccessfulOrders</strong></td>
|
|
</tr>
|
|
<tr class="table-warning">
|
|
<th>Cancelled Orders:</th>
|
|
<td class="text-end text-warning"><strong>@Model.CancelledOrders</strong></td>
|
|
</tr>
|
|
<tr class="table-danger">
|
|
<th>Disputed Orders:</th>
|
|
<td class="text-end text-danger"><strong>@Model.DisputedOrders</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th>Success Rate:</th>
|
|
<td class="text-end">
|
|
@if (Model.TotalOrders > 0)
|
|
{
|
|
var successRate = (Model.SuccessfulOrders * 100.0) / Model.TotalOrders;
|
|
@successRate.ToString("F1")<text>%</text>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">N/A</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div class="mt-3">
|
|
<form method="post" action="@Url.Action("RefreshRiskScore", new { id = Model.Id })" class="d-inline">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-sm btn-outline-warning w-100">
|
|
<i class="fas fa-sync-alt"></i> Recalculate Risk Score
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Spending Metrics -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card bg-primary text-white">
|
|
<div class="card-body text-center">
|
|
<h6 class="mb-2">Total Spent</h6>
|
|
<h3 class="mb-0">£@Model.TotalSpent.ToString("N2")</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-success text-white">
|
|
<div class="card-body text-center">
|
|
<h6 class="mb-2">Average Order Value</h6>
|
|
<h3 class="mb-0">£@Model.AverageOrderValue.ToString("N2")</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-info text-white">
|
|
<div class="card-body text-center">
|
|
<h6 class="mb-2">First Order</h6>
|
|
<h3 class="mb-0" style="font-size: 1.2rem;">
|
|
@if (Model.FirstOrderDate > DateTime.MinValue)
|
|
{
|
|
@Model.FirstOrderDate.ToString("MMM dd, yyyy")
|
|
}
|
|
else
|
|
{
|
|
<span>No orders</span>
|
|
}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-warning text-dark">
|
|
<div class="card-body text-center">
|
|
<h6 class="mb-2">Last Order</h6>
|
|
<h3 class="mb-0" style="font-size: 1.2rem;">
|
|
@if (Model.LastOrderDate > DateTime.MinValue)
|
|
{
|
|
@Model.LastOrderDate.ToString("MMM dd, yyyy")
|
|
}
|
|
else
|
|
{
|
|
<span>No orders</span>
|
|
}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customer Management Actions -->
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<div class="card @(Model.IsBlocked ? "border-danger" : "")">
|
|
<div class="card-header @(Model.IsBlocked ? "bg-danger text-white" : "bg-light")">
|
|
<h5 class="mb-0"><i class="fas fa-cog"></i> Customer Management Actions</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
@if (Model.IsBlocked)
|
|
{
|
|
<div class="alert alert-danger">
|
|
<h5><i class="fas fa-ban"></i> Customer is Blocked</h5>
|
|
<p class="mb-0"><strong>Reason:</strong> @Model.BlockReason</p>
|
|
</div>
|
|
|
|
<form method="post" action="@Url.Action("Unblock", new { id = Model.Id })" class="d-inline">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-success" onclick="return confirm('Are you sure you want to unblock this customer?')">
|
|
<i class="fas fa-check-circle"></i> Unblock Customer
|
|
</button>
|
|
</form>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#blockModal">
|
|
<i class="fas fa-ban"></i> Block Customer
|
|
</button>
|
|
}
|
|
|
|
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
|
<i class="fas fa-trash"></i> Delete Customer
|
|
</button>
|
|
|
|
@if (!string.IsNullOrEmpty(Model.CustomerNotes))
|
|
{
|
|
<hr>
|
|
<h6>Admin Notes:</h6>
|
|
<p class="text-muted mb-0">@Model.CustomerNotes</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Order History -->
|
|
<div class="row">
|
|
<div class="col">
|
|
<div class="card">
|
|
<div class="card-header bg-light">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-shopping-cart"></i> Order History
|
|
<span class="badge bg-primary">@customerOrders.Count</span>
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
@if (customerOrders.Any())
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Order ID</th>
|
|
<th>Date</th>
|
|
<th>Status</th>
|
|
<th class="text-end">Items</th>
|
|
<th class="text-end">Total</th>
|
|
<th class="text-center">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var order in customerOrders)
|
|
{
|
|
<tr>
|
|
<td><code>@order.Id.ToString().Substring(0, 8)</code></td>
|
|
<td>@order.CreatedAt.ToString("MMM dd, yyyy HH:mm")</td>
|
|
<td>
|
|
@{
|
|
var statusClass = order.Status.ToString() == "Delivered" ? "success" :
|
|
order.Status.ToString() == "Cancelled" ? "danger" :
|
|
order.Status.ToString() == "PendingPayment" ? "warning" :
|
|
"info";
|
|
}
|
|
<span class="badge bg-@statusClass">@order.Status</span>
|
|
</td>
|
|
<td class="text-end">@order.Items.Sum(i => i.Quantity)</td>
|
|
<td class="text-end"><strong>£@order.TotalAmount.ToString("N2")</strong></td>
|
|
<td class="text-center">
|
|
<a href="@Url.Action("Details", "Orders", new { id = order.Id })" class="btn btn-sm btn-primary">
|
|
<i class="fas fa-eye"></i> View
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-info mb-0">
|
|
<i class="fas fa-info-circle"></i> No orders yet for this customer.
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Block Customer Modal -->
|
|
<div class="modal fade" id="blockModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form method="post" action="@Url.Action("Block", new { id = Model.Id })">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-header bg-danger text-white">
|
|
<h5 class="modal-title"><i class="fas fa-ban"></i> Block Customer</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<strong>Warning:</strong> Blocking this customer will prevent them from placing new orders.
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="blockReason" class="form-label">
|
|
<strong>Reason for blocking:</strong> <span class="text-danger">*</span>
|
|
</label>
|
|
<textarea name="reason" id="blockReason" class="form-control" rows="3"
|
|
placeholder="Enter the reason for blocking this customer..." required></textarea>
|
|
<small class="text-muted">This reason will be visible to administrators.</small>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-danger">
|
|
<i class="fas fa-ban"></i> Block Customer
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Customer Modal -->
|
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form method="post" action="@Url.Action("Delete", new { id = Model.Id })">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-header bg-warning text-dark">
|
|
<h5 class="modal-title"><i class="fas fa-trash"></i> Delete Customer</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<strong>Note:</strong> This is a soft delete. Customer data will be retained but marked as inactive.
|
|
</div>
|
|
<p>Are you sure you want to delete customer <strong>@Model.TelegramDisplayName</strong>?</p>
|
|
<p class="text-muted mb-0">The customer record and order history will be preserved but hidden from normal views.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-warning">
|
|
<i class="fas fa-trash"></i> Delete Customer
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|