Add SMS Agreements admin page and update help docs
- Add /SmsAgreements SuperAdmin page listing per-company SMS terms acceptance status, with stats cards, filter/search, and a full acceptance history modal (terms version, accepted by, timestamp, IP, user agent) - Add SMS Agreements nav link under Tenants & Billing in the platform sidebar - Update HelpKnowledgeBase and Help docs (Quotes, Settings) to document quote approval via SMS and the reuse of existing approval tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// SuperAdmin view of per-company SMS terms agreement history.
|
||||
/// Shows which companies have accepted the current SMS terms, who accepted them,
|
||||
/// and the full acceptance log for each company.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class SmsAgreementsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<SmsAgreementsController> _logger;
|
||||
|
||||
public SmsAgreementsController(IUnitOfWork unitOfWork, ILogger<SmsAgreementsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists every company with its current SMS agreement status and full acceptance history.
|
||||
/// Uses IgnoreQueryFilters on both queries so deleted/inactive companies and all historical
|
||||
/// agreement records are included in the audit view.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(string? search = null, string? filter = null)
|
||||
{
|
||||
var companies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
|
||||
var allAgreements = await _unitOfWork.CompanySmsAgreements.GetAllAsync(ignoreQueryFilters: true);
|
||||
|
||||
var agreementsByCompany = allAgreements
|
||||
.GroupBy(a => a.CompanyId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.AgreedAt).ToList());
|
||||
|
||||
var rows = companies
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c =>
|
||||
{
|
||||
var agreements = agreementsByCompany.TryGetValue(c.Id, out var list) ? list : [];
|
||||
var current = agreements.FirstOrDefault(a => a.TermsVersion == AppConstants.SmsTermsVersion);
|
||||
return new CompanySmsRow
|
||||
{
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName ?? "(unnamed)",
|
||||
SmsEnabled = c.SmsEnabled,
|
||||
SmsDisabledByAdmin = c.SmsDisabledByAdmin,
|
||||
CurrentAgreement = current,
|
||||
LatestAgreement = agreements.FirstOrDefault(),
|
||||
AllAgreements = agreements,
|
||||
IsDeleted = c.IsDeleted
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Filter
|
||||
rows = filter switch
|
||||
{
|
||||
"accepted" => rows.Where(r => r.CurrentAgreement != null).ToList(),
|
||||
"pending" => rows.Where(r => r.CurrentAgreement == null).ToList(),
|
||||
"enabled" => rows.Where(r => r.SmsEnabled).ToList(),
|
||||
"disabled" => rows.Where(r => r.SmsDisabledByAdmin).ToList(),
|
||||
_ => rows
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
rows = rows.Where(r => r.CompanyName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.Filter = filter ?? "all";
|
||||
ViewBag.CurrentTermsVersion = AppConstants.SmsTermsVersion;
|
||||
|
||||
// Stats (pre-filter totals)
|
||||
var all = companies.ToList();
|
||||
ViewBag.TotalCompanies = all.Count(c => !c.IsDeleted);
|
||||
ViewBag.AcceptedCount = agreementsByCompany.Count(kvp =>
|
||||
kvp.Value.Any(a => a.TermsVersion == AppConstants.SmsTermsVersion) &&
|
||||
!all.FirstOrDefault(c => c.Id == kvp.Key)?.IsDeleted == true);
|
||||
ViewBag.SmsEnabledCount = all.Count(c => !c.IsDeleted && c.SmsEnabled);
|
||||
|
||||
return View(rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>View model for one company row on the SMS agreements page.</summary>
|
||||
public class CompanySmsRow
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool SmsDisabledByAdmin { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public PowderCoating.Core.Entities.CompanySmsAgreement? CurrentAgreement { get; set; }
|
||||
public PowderCoating.Core.Entities.CompanySmsAgreement? LatestAgreement { get; set; }
|
||||
public List<PowderCoating.Core.Entities.CompanySmsAgreement> AllAgreements { get; set; } = [];
|
||||
}
|
||||
@@ -1083,6 +1083,10 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**What events send an SMS:**
|
||||
- Job completed — notifies the customer their job is done and ready for pickup.
|
||||
- Quote approval request — sends the customer a link to review and approve or decline their quote.
|
||||
|
||||
**Sending a quote approval link via SMS:**
|
||||
On any quote's Details page, click **Send Quote via SMS**. The system texts the customer's mobile number a short message with the approval link. If no valid approval token exists yet, one is generated automatically. If an email was already sent for the same quote, the existing token is reused so both the email link and the SMS link remain valid simultaneously. The customer follows the link to the self-service approval portal and can approve or decline from their phone. Prospects (non-customers) receive the SMS at their ProspectPhone number without requiring an opt-in check; registered customers must have SMS Opt-In enabled.
|
||||
|
||||
**Compose-before-send (Admin/Manager):**
|
||||
When a Company Admin or Manager marks a job complete, the system pre-fills an SMS draft based on your notification template and opens a compose modal before sending. You can personalize the message on the spot. The message must contain "STOP" opt-out language — it is appended automatically if missing.
|
||||
|
||||
@@ -265,12 +265,13 @@
|
||||
<i class="bi bi-send text-primary me-2"></i>Sending a Quote
|
||||
</h2>
|
||||
<p>
|
||||
Once a quote is saved as a Draft and you are happy with the pricing and details, you can mark it
|
||||
as sent to the customer.
|
||||
Once a quote is saved as a Draft and you are happy with the pricing and details, you can send it
|
||||
to the customer via email or SMS, or both.
|
||||
</p>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Send via Email</h3>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Open the quote from the Quotes list and go to its Details page.</li>
|
||||
<li class="mb-2">Click <strong>Send Quote</strong>. The status changes from Draft to Sent.</li>
|
||||
<li class="mb-2">Click <strong>Send Quote via Email</strong>. The status changes from Draft to Sent and a PDF is emailed to the customer with an approval link.</li>
|
||||
<li class="mb-2">If email notifications are configured for your company, the customer will automatically receive an email with the quote details.</li>
|
||||
</ol>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
|
||||
@@ -283,9 +284,20 @@
|
||||
under their contact settings.
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Send via SMS</h3>
|
||||
<p>
|
||||
Click <strong>Send Quote via SMS</strong> on the Details page to text the customer a short message
|
||||
containing their quote total and a link to the self-service approval portal. The customer can open the
|
||||
link on their phone and approve or decline without logging in.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1">The customer must have <strong>SMS Opt-In</strong> enabled and a <strong>Mobile Phone</strong> number on their record.</li>
|
||||
<li class="mb-1">If you already sent the quote via email, the same approval link is reused — both the email link and SMS link remain valid simultaneously.</li>
|
||||
<li class="mb-1">For prospect quotes, the SMS goes to the <strong>Prospect Phone</strong> field on the quote.</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can also manually mark a quote as <strong>Approved</strong> or <strong>Rejected</strong> when
|
||||
you hear back from the customer verbally or by phone, without going through a formal email send.
|
||||
you hear back from the customer verbally or by phone, without going through a formal email or SMS send.
|
||||
Use the status buttons on the quote Details page to do this.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
|
||||
|
||||
@@ -393,6 +393,12 @@
|
||||
<h4 class="h6 fw-semibold mt-3 mb-2" style="font-size:.85rem;">What events send an SMS</h4>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Job Completed</strong> — notifies the customer their job is done and ready for pickup.</li>
|
||||
<li class="mb-1">
|
||||
<strong>Quote Approval Request</strong> — sends the customer a link to review and approve or decline
|
||||
their quote directly from their phone. Click <strong>Send Quote via SMS</strong> on any quote's Details
|
||||
page. If an email was already sent for the same quote, the existing approval link is reused so both
|
||||
delivery methods work simultaneously.
|
||||
</li>
|
||||
</ul>
|
||||
<h4 class="h6 fw-semibold mt-3 mb-2" style="font-size:.85rem;">Compose-before-send vs. auto-send</h4>
|
||||
<p>
|
||||
|
||||
@@ -1199,6 +1199,10 @@
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
<span>Stripe Events</span>
|
||||
</a>
|
||||
<a asp-controller="SmsAgreements" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-file-earmark-check"></i>
|
||||
<span>SMS Agreements</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Content & Communication</div>
|
||||
<a asp-controller="Announcements" asp-action="Index" class="nav-link">
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
@model List<PowderCoating.Web.Controllers.CompanySmsRow>
|
||||
@{
|
||||
ViewData["Title"] = "SMS Agreements";
|
||||
var currentVersion = ViewBag.CurrentTermsVersion as string ?? "1.0";
|
||||
var filter = ViewBag.Filter as string ?? "all";
|
||||
var search = ViewBag.Search as string ?? "";
|
||||
}
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0"><i class="bi bi-file-earmark-check me-2 text-primary"></i>SMS Agreements</h1>
|
||||
<p class="text-muted mb-0 small">Per-company SMS terms acceptance log — current terms version: <strong>v@currentVersion</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
||||
<i class="bi bi-building text-primary fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-4 fw-bold lh-1">@ViewBag.TotalCompanies</div>
|
||||
<div class="text-muted small">Active Companies</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
||||
<i class="bi bi-check-circle text-success fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-4 fw-bold lh-1">@ViewBag.AcceptedCount</div>
|
||||
<div class="text-muted small">Accepted Current Terms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-info bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
||||
<i class="bi bi-chat-dots text-info fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-4 fw-bold lh-1">@ViewBag.SmsEnabledCount</div>
|
||||
<div class="text-muted small">SMS Currently Enabled</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters + Search -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a asp-action="Index" asp-route-search="@search"
|
||||
class="btn btn-sm @(filter == "all" ? "btn-primary" : "btn-outline-secondary")">All</a>
|
||||
<a asp-action="Index" asp-route-filter="accepted" asp-route-search="@search"
|
||||
class="btn btn-sm @(filter == "accepted" ? "btn-success" : "btn-outline-success")">
|
||||
<i class="bi bi-check-circle me-1"></i>Accepted Current Terms
|
||||
</a>
|
||||
<a asp-action="Index" asp-route-filter="pending" asp-route-search="@search"
|
||||
class="btn btn-sm @(filter == "pending" ? "btn-warning" : "btn-outline-warning")">
|
||||
<i class="bi bi-clock me-1"></i>Not Accepted
|
||||
</a>
|
||||
<a asp-action="Index" asp-route-filter="enabled" asp-route-search="@search"
|
||||
class="btn btn-sm @(filter == "enabled" ? "btn-info" : "btn-outline-info")">
|
||||
<i class="bi bi-chat-dots me-1"></i>SMS Enabled
|
||||
</a>
|
||||
<a asp-action="Index" asp-route-filter="disabled" asp-route-search="@search"
|
||||
class="btn btn-sm @(filter == "disabled" ? "btn-danger" : "btn-outline-danger")">
|
||||
<i class="bi bi-slash-circle me-1"></i>Admin-Disabled
|
||||
</a>
|
||||
</div>
|
||||
<form method="get" class="d-flex gap-2" style="min-width:240px;">
|
||||
<input type="hidden" name="filter" value="@filter" />
|
||||
<input type="text" name="search" value="@search" class="form-control form-control-sm"
|
||||
placeholder="Search company…" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
@if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
<a asp-action="Index" asp-route-filter="@filter" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-x"></i>
|
||||
</a>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-file-earmark-x fs-1 d-block mb-2 opacity-25"></i>
|
||||
No companies match this filter.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>SMS Status</th>
|
||||
<th>Terms Accepted</th>
|
||||
<th>Accepted By</th>
|
||||
<th>Accepted At</th>
|
||||
<th>IP Address</th>
|
||||
<th class="text-center">History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model)
|
||||
{
|
||||
<tr class="@(row.IsDeleted ? "text-muted" : "")">
|
||||
<td>
|
||||
<div class="fw-medium">
|
||||
@row.CompanyName
|
||||
@if (row.IsDeleted)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Deleted</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.SmsDisabledByAdmin)
|
||||
{
|
||||
<span class="badge bg-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
|
||||
}
|
||||
else if (row.SmsEnabled)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-chat-dots me-1"></i>Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.CurrentAgreement != null)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>v@row.CurrentAgreement.TermsVersion</span>
|
||||
}
|
||||
else if (row.LatestAgreement != null)
|
||||
{
|
||||
<span class="badge bg-warning text-dark" title="Accepted v@row.LatestAgreement.TermsVersion — current is v@currentVersion">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Stale (v@row.LatestAgreement.TermsVersion)
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-muted border">Never</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.CurrentAgreement != null)
|
||||
{
|
||||
<span>@row.CurrentAgreement.AgreedByUserName</span>
|
||||
}
|
||||
else if (row.LatestAgreement != null)
|
||||
{
|
||||
<span class="text-muted">@row.LatestAgreement.AgreedByUserName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
var displayAgreement = row.CurrentAgreement ?? row.LatestAgreement;
|
||||
}
|
||||
@if (displayAgreement != null)
|
||||
{
|
||||
<span class="@(row.CurrentAgreement == null ? "text-muted" : "")">
|
||||
@displayAgreement.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") UTC
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (displayAgreement?.IpAddress != null)
|
||||
{
|
||||
<code class="small @(row.CurrentAgreement == null ? "text-muted" : "")">@displayAgreement.IpAddress</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (row.AllAgreements.Count > 0)
|
||||
{
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#historyModal"
|
||||
data-company="@row.CompanyName"
|
||||
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
|
||||
a.TermsVersion,
|
||||
a.AgreedByUserName,
|
||||
a.AgreedByUserId,
|
||||
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
|
||||
IpAddress = a.IpAddress ?? "—",
|
||||
UserAgent = a.UserAgent ?? "—"
|
||||
}))">
|
||||
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="card-footer text-muted small">
|
||||
Showing @Model.Count @(Model.Count == 1 ? "company" : "companies")
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Modal -->
|
||||
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="historyModalLabel">
|
||||
<i class="bi bi-clock-history me-2"></i>Agreement History — <span id="historyCompanyName"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Terms Version</th>
|
||||
<th>Accepted By</th>
|
||||
<th>Accepted At</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
document.getElementById('historyModal').addEventListener('show.bs.modal', function (e) {
|
||||
const btn = e.relatedTarget;
|
||||
document.getElementById('historyCompanyName').textContent = btn.dataset.company;
|
||||
|
||||
const rows = JSON.parse(btn.dataset.history);
|
||||
const tbody = document.getElementById('historyTableBody');
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td><span class="badge bg-primary">v${r.termsVersion}</span></td>
|
||||
<td>${r.agreedByUserName}</td>
|
||||
<td>${r.agreedAt}</td>
|
||||
<td><code class="small">${r.ipAddress}</code></td>
|
||||
<td><small class="text-muted text-truncate d-block" style="max-width:260px;" title="${r.userAgent}">${r.userAgent}</small></td>
|
||||
</tr>`).join('');
|
||||
});
|
||||
</script>
|
||||
}
|
||||
Reference in New Issue
Block a user