Store Branding: Add custom CSS option

This commit is contained in:
Dennis Reimann
2022-12-19 15:51:05 +01:00
parent f10c1c4730
commit 4df2f1f756
11 changed files with 158 additions and 64 deletions

View File

@@ -166,17 +166,28 @@ namespace BTCPayServer.Controllers
if (receipt.Enabled is not true) if (receipt.Enabled is not true)
return NotFound(); return NotFound();
var storeBlob = store.GetStoreBlob();
var vm = new InvoiceReceiptViewModel
{
InvoiceId = i.Id,
OrderId = i.Metadata?.OrderId,
OrderUrl = i.Metadata?.OrderUrl,
Status = i.Status.ToModernStatus(),
Currency = i.Currency,
Timestamp = i.InvoiceTime,
StoreName = store.StoreName,
BrandColor = storeBlob.BrandColor,
LogoFileId = storeBlob.LogoFileId,
CssFileId = storeBlob.CssFileId,
ReceiptOptions = receipt
};
if (i.Status.ToModernStatus() != InvoiceStatus.Settled) if (i.Status.ToModernStatus() != InvoiceStatus.Settled)
{ {
return View(new InvoiceReceiptViewModel return View(vm);
{
InvoiceId = i.Id,
OrderId = i.Metadata?.OrderId,
OrderUrl = i.Metadata?.OrderUrl,
StoreName = store.StoreName,
Status = i.Status.ToModernStatus()
});
} }
JToken? receiptData = null; JToken? receiptData = null;
i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData); i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData);
@@ -213,23 +224,13 @@ namespace BTCPayServer.Controllers
.Where(payment => payment != null) .Where(payment => payment != null)
.ToList(); .ToList();
return View(new InvoiceReceiptViewModel vm.Amount = payments.Sum(p => p!.Paid);
{ vm.Payments = receipt.ShowPayments is false ? null : payments;
StoreName = store.StoreName, vm.AdditionalData = receiptData is null
StoreLogoFileId = store.GetStoreBlob().LogoFileId, ? new Dictionary<string, object>()
Status = i.Status.ToModernStatus(), : PosDataParser.ParsePosData(receiptData.ToString());
Amount = payments.Sum(p => p!.Paid),
Currency = i.Currency, return View(vm);
Timestamp = i.InvoiceTime,
InvoiceId = i.Id,
OrderId = i.Metadata?.OrderId,
OrderUrl = i.Metadata?.OrderUrl,
Payments = receipt.ShowPayments is false ? null : payments,
ReceiptOptions = receipt,
AdditionalData = receiptData is null
? new Dictionary<string, object>()
: PosDataParser.ParsePosData(receiptData.ToString())
});
} }
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId) private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
{ {
@@ -762,6 +763,7 @@ namespace BTCPayServer.Controllers
CustomCSSLink = storeBlob.CustomCSS, CustomCSSLink = storeBlob.CustomCSS,
CustomLogoLink = storeBlob.CustomLogo, CustomLogoLink = storeBlob.CustomLogo,
LogoFileId = storeBlob.LogoFileId, LogoFileId = storeBlob.LogoFileId,
CssFileId = storeBlob.CssFileId,
BrandColor = storeBlob.BrandColor, BrandColor = storeBlob.BrandColor,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType, CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",

View File

@@ -608,6 +608,7 @@ namespace BTCPayServer.Controllers
StoreName = store.StoreName, StoreName = store.StoreName,
StoreWebsite = store.StoreWebsite, StoreWebsite = store.StoreWebsite,
LogoFileId = storeBlob.LogoFileId, LogoFileId = storeBlob.LogoFileId,
CssFileId = storeBlob.CssFileId,
BrandColor = storeBlob.BrandColor, BrandColor = storeBlob.BrandColor,
NetworkFeeMode = storeBlob.NetworkFeeMode, NetworkFeeMode = storeBlob.NetworkFeeMode,
AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice, AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice,
@@ -622,7 +623,10 @@ namespace BTCPayServer.Controllers
} }
[HttpPost("{storeId}/settings")] [HttpPost("{storeId}/settings")]
public async Task<IActionResult> GeneralSettings(GeneralSettingsViewModel model, [FromForm] bool RemoveLogoFile = false) public async Task<IActionResult> GeneralSettings(
GeneralSettingsViewModel model,
[FromForm] bool RemoveLogoFile = false,
[FromForm] bool RemoveCssFile = false)
{ {
bool needUpdate = false; bool needUpdate = false;
if (CurrentStore.StoreName != model.StoreName) if (CurrentStore.StoreName != model.StoreName)
@@ -688,6 +692,39 @@ namespace BTCPayServer.Controllers
needUpdate = true; needUpdate = true;
} }
if (model.CssFile != null)
{
if (model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
{
// delete existing CSS file
if (!string.IsNullOrEmpty(blob.CssFileId))
{
await _fileService.RemoveFile(blob.CssFileId, userId);
}
// add new CSS file
try
{
var storedFile = await _fileService.AddFile(model.CssFile, userId);
blob.CssFileId = storedFile.Id;
}
catch (Exception e)
{
TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}";
}
}
else
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
}
}
else if (RemoveCssFile && !string.IsNullOrEmpty(blob.CssFileId))
{
await _fileService.RemoveFile(blob.CssFileId, userId);
blob.CssFileId = null;
needUpdate = true;
}
if (CurrentStore.SetStoreBlob(blob)) if (CurrentStore.SetStoreBlob(blob))
{ {
needUpdate = true; needUpdate = true;

View File

@@ -215,8 +215,9 @@ namespace BTCPayServer.Data
public TimeSpan RefundBOLT11Expiration { get; set; } public TimeSpan RefundBOLT11Expiration { get; set; }
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; } public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public string LogoFileId { get; set; }
public string BrandColor { get; set; } public string BrandColor { get; set; }
public string LogoFileId { get; set; }
public string CssFileId { get; set; }
public IPaymentFilter GetExcludedPaymentMethods() public IPaymentFilter GetExcludedPaymentMethods()
{ {

View File

@@ -13,7 +13,9 @@ namespace BTCPayServer.Models.InvoicingModels
public string OrderId { get; set; } public string OrderId { get; set; }
public string Currency { get; set; } public string Currency { get; set; }
public string StoreName { get; set; } public string StoreName { get; set; }
public string StoreLogoFileId { get; set; } public string BrandColor { get; set; }
public string LogoFileId { get; set; }
public string CssFileId { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public DateTimeOffset Timestamp { get; set; } public DateTimeOffset Timestamp { get; set; }
public Dictionary<string, object> AdditionalData { get; set; } public Dictionary<string, object> AdditionalData { get; set; }

View File

@@ -24,6 +24,7 @@ namespace BTCPayServer.Models.InvoicingModels
} }
public string CustomCSSLink { get; set; } public string CustomCSSLink { get; set; }
public string CustomLogoLink { get; set; } public string CustomLogoLink { get; set; }
public string CssFileId { get; set; }
public string LogoFileId { get; set; } public string LogoFileId { get; set; }
public string BrandColor { get; set; } public string BrandColor { get; set; }
public string HtmlTitle { get; set; } public string HtmlTitle { get; set; }

View File

@@ -22,12 +22,16 @@ namespace BTCPayServer.Models.StoreViewModels
[MaxLength(500)] [MaxLength(500)]
public string StoreWebsite { get; set; } public string StoreWebsite { get; set; }
[Display(Name = "Brand Color")]
public string BrandColor { get; set; }
[Display(Name = "Logo")] [Display(Name = "Logo")]
public IFormFile LogoFile { get; set; } public IFormFile LogoFile { get; set; }
public string LogoFileId { get; set; } public string LogoFileId { get; set; }
[Display(Name = "Brand Color")] [Display(Name = "Custom CSS")]
public string BrandColor { get; set; } public IFormFile CssFile { get; set; }
public string CssFileId { get; set; }
public bool CanDelete { get; set; } public bool CanDelete { get; set; }

View File

@@ -0,0 +1,26 @@
@model (string BrandColor, string CssFileId)
@using BTCPayServer.Abstractions.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Contracts
@inject IFileService FileService
@{
var cssUrl = !string.IsNullOrEmpty(Model.CssFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.CssFileId)
: null;
}
@if (!string.IsNullOrEmpty(Model.BrandColor))
{
<style>
:root {
--btcpay-primary: @Model.BrandColor;
--btcpay-primary-bg-hover: @Model.BrandColor;
--btcpay-primary-bg-active: @Model.BrandColor;
--btcpay-primary-shadow: @Model.BrandColor;
--btcpay-body-link-accent: @Model.BrandColor;
}
</style>
}
@if (!string.IsNullOrEmpty(cssUrl))
{
<link href="@cssUrl" asp-append-version="true" rel="stylesheet" />
}

View File

@@ -15,7 +15,7 @@
var paymentMethodCount = Model.AvailableCryptos.Count; var paymentMethodCount = Model.AvailableCryptos.Count;
var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId) var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId) ? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId)
: Model.CustomLogoLink; : null;
} }
@functions { @functions {
private string PaymentMethodName(PaymentModel.AvailableCrypto pm) private string PaymentMethodName(PaymentModel.AvailableCrypto pm)
@@ -36,27 +36,16 @@
<partial name="LayoutHead"/> <partial name="LayoutHead"/>
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" /> <link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" />
@if (!string.IsNullOrEmpty(Model.BrandColor)) <partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId)" />
{
<style>
:root {
--btcpay-primary: @Model.BrandColor;
--btcpay-primary-bg-hover: @Model.BrandColor;
--btcpay-primary-bg-active: @Model.BrandColor;
--btcpay-primary-shadow: @Model.BrandColor;
--btcpay-body-link-accent: @Model.BrandColor;
}
</style>
}
</head> </head>
<body class="min-vh-100"> <body class="min-vh-100">
<div id="Checkout-v2" class="wrap gap-4" v-cloak v-waitForT> <div id="Checkout-v2" class="wrap gap-4" v-cloak v-waitForT>
<header class="store-header"> <header class="store-header">
@if (!string.IsNullOrEmpty(logoUrl)) @if (!string.IsNullOrEmpty(logoUrl))
{ {
<img src="@logoUrl" alt="@Model.StoreName" class="logo @(!string.IsNullOrEmpty(Model.LogoFileId) ? "logo--square" : "")"/> <img src="@logoUrl" alt="@Model.StoreName" class="store-logo"/>
} }
<h1>@Model.StoreName</h1> <h1 class="store-name">@Model.StoreName</h1>
</header> </header>
<main class="shadow-lg"> <main class="shadow-lg">
<nav v-if="isModal"> <nav v-if="isModal">

View File

@@ -7,14 +7,15 @@
@inject CurrencyNameTable CurrencyNameTable @inject CurrencyNameTable CurrencyNameTable
@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Contracts
@inject IFileService FileService @inject IFileService FileService
@{ @{
Layout = null; Layout = null;
ViewData["Title"] = Model.StoreName; ViewData["Title"] = $"Receipt from {Model.StoreName}";
var isProcessing = Model.Status == InvoiceStatus.Processing; var isProcessing = Model.Status == InvoiceStatus.Processing;
var isSettled = Model.Status == InvoiceStatus.Settled; var isSettled = Model.Status == InvoiceStatus.Settled;
var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId)
: null;
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" @(env.IsDeveloping ? " data-devenv" : "")> <html lang="en" @(env.IsDeveloping ? " data-devenv" : "")>
<head> <head>
@@ -31,6 +32,7 @@
#posData td > table:last-child { margin-bottom: 0 !important; } #posData td > table:last-child { margin-bottom: 0 !important; }
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; } #posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
</style> </style>
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId)" />
</head> </head>
<body> <body>
<div class="min-vh-100 d-flex flex-column"> <div class="min-vh-100 d-flex flex-column">
@@ -40,11 +42,11 @@
<div class="d-flex flex-column justify-content-center gap-4"> <div class="d-flex flex-column justify-content-center gap-4">
<header class="store-header"> <header class="store-header">
@if (!string.IsNullOrEmpty(Model.StoreLogoFileId)) @if (!string.IsNullOrEmpty(logoUrl))
{ {
<img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreLogoFileId))" alt="@Model.StoreName" class="logo @(!string.IsNullOrEmpty(Model.StoreLogoFileId) ? "logo--square" : "")" /> <img src="@logoUrl" alt="@Model.StoreName" class="store-logo" />
} }
<h1>@ViewData["Title"]</h1> <h1 class="store-name">@Model.StoreName</h1>
</header> </header>
<div id="InvoiceSummary" class="bg-tile p-3 p-sm-4 rounded d-flex flex-wrap align-items-center"> <div id="InvoiceSummary" class="bg-tile p-3 p-sm-4 rounded d-flex flex-wrap align-items-center">
@if (isProcessing) @if (isProcessing)

View File

@@ -34,6 +34,14 @@
</div> </div>
<h3 class="mt-5 mb-3">Branding</h3> <h3 class="mt-5 mb-3">Branding</h3>
<div class="form-group">
<label asp-for="BrandColor" class="form-label"></label>
<div class="input-group">
<input id="BrandColorInput" class="form-control form-control-color flex-grow-0" type="color" style="width:3rem" aria-describedby="BrandColorValue" value="@Model.BrandColor" />
<input asp-for="BrandColor" class="form-control form-control-color flex-grow-0 font-monospace" pattern="@ColorPalette.Pattern" style="width:5.5rem" />
</div>
<span asp-validation-for="BrandColor" class="text-danger"></span>
</div>
<div class="form-group"> <div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-2"> <div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="LogoFile" class="form-label"></label> <label asp-for="LogoFile" class="form-label"></label>
@@ -66,12 +74,34 @@
} }
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="BrandColor" class="form-label"></label> <div class="d-flex align-items-center justify-content-between gap-2">
<div class="input-group"> <label asp-for="CssFile" class="form-label"></label>
<input id="BrandColorInput" class="form-control form-control-color flex-grow-0" type="color" style="width:3rem" aria-describedby="BrandColorValue" value="@Model.BrandColor" /> @if (!string.IsNullOrEmpty(Model.CssFileId))
<input asp-for="BrandColor" class="form-control form-control-color flex-grow-0 font-monospace" pattern="@ColorPalette.Pattern" style="width:5.5rem" /> {
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveCssFile" value="true">
<span class="fa fa-times"></span> Remove
</button>
}
</div> </div>
<span asp-validation-for="BrandColor" class="text-danger"></span> @if (canUpload)
{
<div class="d-flex align-items-center gap-3">
<input asp-for="CssFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.CssFileId))
{
<a href="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.CssFileId))" target="_blank" rel="noreferrer noopener" class="text-nowrap">Custom CSS</a>
}
</div>
<span asp-validation-for="LogoFile" class="text-danger"></span>
<div class="form-text">
Use this CSS to customize the public/customer-facing pages of this store. (Invoice, Payment Request, Pull Payment, etc.)
</div>
}
else
{
<input asp-for="CssFile" type="file" class="form-control" disabled>
<div class="form-text">In order to upload a CSS file, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</div>
}
</div> </div>
<h3 class="mt-5 mb-3">Payment</h3> <h3 class="mt-5 mb-3">Payment</h3>

View File

@@ -207,24 +207,24 @@ h2 small .fa-question-circle-o {
/* Store header */ /* Store header */
.store-header { .store-header {
--logo-size: 3rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--btcpay-space-s); gap: var(--btcpay-space-s);
} }
.store-header .logo { .store-logo {
height: var(--logo-size); --logo-size: 3rem;
} --logo-bg: transparent;
--logo-radius: 50%;
.store-header .logo--square {
width: var(--logo-size); width: var(--logo-size);
border-radius: 50%; height: var(--logo-size);
background: var(--logo-bg);
border-radius: var(--logo-radius);
} }
.store-header h1 { .store-name {
font-size: 1.3rem; font-size: 1.3rem;
} }