e4a256a6c4
Subscription expiry (SubscriptionExpiryBackgroundService): - Trials with no grace period now go directly Active -> Expired instead of briefly entering GracePeriod for a day, which was causing repeated 'Grace Period Started' admin notification emails - Remove redundant isTrial variable (query already filters to non-Stripe companies, so all processed companies are trials by definition) - Save per-company inside the loop so a single SaveChangesAsync failure no longer discards all other companies' status changes and notification log entries (which was the other cause of repeated emails) HTML entities in page titles (33 views): - Replace – / — with plain ' - ' in ViewData["Title"] C# strings; Razor HTML-encodes these when rendering @ViewData["Title"], causing browsers to display the literal text '–' instead of a dash Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
9.8 KiB
Plaintext
207 lines
9.8 KiB
Plaintext
@model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto
|
|
@{
|
|
ViewData["Title"] = $"Edit Template - {Model.DisplayName}";
|
|
ViewData["PageIcon"] = "bi-envelope-gear";
|
|
var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)>
|
|
?? new List<(string, string)>();
|
|
var isEmail = Model.IsEmail;
|
|
}
|
|
|
|
@section Styles {
|
|
<style>
|
|
.placeholder-pill {
|
|
cursor: pointer;
|
|
transition: background-color 0.15s;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.placeholder-pill:hover { background-color: #0d6efd !important; color: white !important; }
|
|
.copy-feedback { display: none; font-size: 0.75rem; color: #198754; }
|
|
</style>
|
|
}
|
|
|
|
<div class="container-fluid">
|
|
<div class="d-flex justify-content-start mb-4">
|
|
<a asp-controller="CompanySettings" asp-action="NotificationTemplates" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
<!-- LEFT: Edit form -->
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex align-items-center gap-2">
|
|
@if (isEmail)
|
|
{
|
|
<span class="badge bg-primary"><i class="bi bi-envelope"></i> Email</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-success"><i class="bi bi-phone"></i> SMS</span>
|
|
}
|
|
<span class="fw-semibold">@Model.NotificationType</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<form asp-action="EditTemplate" asp-route-id="@Model.Id" method="post" id="templateForm">
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" asp-for="Id" />
|
|
|
|
@if (isEmail)
|
|
{
|
|
<div class="mb-3">
|
|
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
|
|
<input asp-for="Subject" class="form-control" placeholder="Email subject line" />
|
|
<span asp-validation-for="Subject" class="text-danger small"></span>
|
|
</div>
|
|
}
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">
|
|
Body
|
|
@if (!isEmail)
|
|
{
|
|
<span class="text-muted fw-normal small ms-2">
|
|
<span id="charCount">@Model.Body.Length</span> characters
|
|
(<span id="segCount">@((Model.Body.Length / 160) + 1)</span> SMS segment@(((Model.Body.Length / 160) + 1) != 1 ? "s" : ""))
|
|
</span>
|
|
}
|
|
</label>
|
|
|
|
@if (isEmail)
|
|
{
|
|
<!-- Raw HTML textarea for email — supports {{placeholders}} and full HTML -->
|
|
<textarea asp-for="Body" class="form-control font-monospace" rows="16"
|
|
placeholder="Enter HTML email body..."></textarea>
|
|
<div class="form-text text-muted mt-1">
|
|
<i class="bi bi-code-slash me-1"></i>HTML is supported. Use <code>{{placeholder}}</code> tokens anywhere in the body.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<!-- Plain textarea for SMS -->
|
|
<textarea asp-for="Body" class="form-control" rows="5"
|
|
id="smsBody" maxlength="1000"
|
|
placeholder="Enter your SMS message text..."></textarea>
|
|
<span asp-validation-for="Body" class="text-danger small"></span>
|
|
<div class="form-text text-muted">
|
|
<i class="bi bi-info-circle"></i>
|
|
Standard SMS segments are 160 characters. Messages over 160 chars are split into multiple segments.
|
|
Always include <code>Reply STOP to opt out</code> for CTIA compliance.
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-between align-items-center pt-2">
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-floppy"></i> Save Template
|
|
</button>
|
|
<a asp-controller="CompanySettings" asp-action="NotificationTemplates"
|
|
class="btn btn-outline-secondary">
|
|
Cancel
|
|
</a>
|
|
</div>
|
|
<form asp-action="ResetTemplate" asp-route-id="@Model.Id" method="post"
|
|
onsubmit="return confirm('Reset this template to the built-in default? Your customisations will be lost.');">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
|
<i class="bi bi-arrow-counterclockwise"></i> Reset to Default
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT: Placeholder reference -->
|
|
<div class="col-lg-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header">
|
|
<h6 class="mb-0"><i class="bi bi-braces"></i> Available Placeholders</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-3">
|
|
Click any placeholder to copy it to your clipboard, then paste it into the template body.
|
|
</p>
|
|
<div class="d-flex flex-column gap-2">
|
|
@foreach (var (placeholder, description) in placeholders)
|
|
{
|
|
<div>
|
|
<span class="badge bg-light text-dark border placeholder-pill px-2 py-1"
|
|
onclick="copyPlaceholder('@placeholder', this)"
|
|
title="@description — click to copy">
|
|
@placeholder
|
|
</span>
|
|
<span class="copy-feedback ms-1">Copied!</span>
|
|
<div class="text-muted" style="font-size: 0.78rem;">@description</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mt-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title"><i class="bi bi-lightbulb"></i> Tips</h6>
|
|
<ul class="small text-muted mb-0 ps-3">
|
|
<li>Placeholders are case-insensitive.</li>
|
|
<li>Unrecognised placeholders are left as-is.</li>
|
|
@if (isEmail)
|
|
{
|
|
<li>Edit raw HTML directly. A plain-text version is generated automatically for email clients that require it.</li>
|
|
<li>An unsubscribe link is always appended to comply with CAN-SPAM.</li>
|
|
}
|
|
else
|
|
{
|
|
<li>Keep messages under 160 characters to avoid splitting.</li>
|
|
<li>Always include opt-out instructions (e.g. "Reply STOP") to comply with CTIA guidelines.</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@section Scripts {
|
|
@if (!isEmail)
|
|
{
|
|
<script>
|
|
const smsBody = document.getElementById('smsBody');
|
|
const charCount = document.getElementById('charCount');
|
|
const segCount = document.getElementById('segCount');
|
|
|
|
smsBody?.addEventListener('input', function () {
|
|
const len = this.value.length;
|
|
charCount.textContent = len;
|
|
const segs = Math.ceil(len / 160) || 1;
|
|
segCount.textContent = segs;
|
|
const suffixEl = segCount.nextSibling;
|
|
if (suffixEl) suffixEl.textContent = ` SMS segment${segs !== 1 ? 's' : ''}`;
|
|
});
|
|
</script>
|
|
}
|
|
|
|
<script>
|
|
function copyPlaceholder(text, el) {
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
const feedback = el.nextElementSibling;
|
|
if (feedback) {
|
|
feedback.style.display = 'inline';
|
|
setTimeout(() => { feedback.style.display = 'none'; }, 1800);
|
|
}
|
|
}).catch(function () {
|
|
// Fallback for browsers that don't support clipboard API
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
});
|
|
}
|
|
</script>
|
|
}
|