mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 05:54:26 +01:00
Checkout v2: Play sound when invoice is paid (#5113)
* Checkout v2: Play sound when invoice is paid Closes #5085. * Refactoring: Use low-level audio API to play the sound Allows to play the sound regardless of browser permissions. * Add audio file detection * Use model state for file upload errors * Add default sound and customizing option * Fix mp3 detection * Add sounds * Update defaults * Add nfcread and error sounds * Improve label wording * Replace sound --------- Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
@@ -659,7 +659,7 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDetectImage()
|
||||
public void CanDetectFileType()
|
||||
{
|
||||
Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.bmp"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, ".bmp"));
|
||||
@@ -672,6 +672,15 @@ namespace BTCPayServer.Tests
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.jpg"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF }, "e.jpg"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { }, "empty.jpg"));
|
||||
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x9A, 0x08, 0x00, 0x57, 0x41 }, "music.wav"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF1, 0x50, 0x80, 0x1C, 0x3F, 0xFC, 0xDA, 0x00, 0x4C }, "music.aac"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x66, 0x4C, 0x61, 0x43, 0x00, 0x00, 0x00, 0x22, 0x04, 0x80 }, "music.flac"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 }, "music.ogg"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 }, "music.weba"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF3, 0xE4, 0x64, 0x00, 0x20, 0xAD, 0xBD, 0x04, 0x00 }, "music.mp3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -20,8 +20,6 @@ using BTCPayServer.Models.AppViewModels;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Models.PaymentRequestViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
@@ -956,6 +954,16 @@ namespace BTCPayServer.Controllers
|
||||
model.PaymentMethodId = paymentMethodId.ToString();
|
||||
model.PaymentType = paymentMethodId.PaymentType.ToString();
|
||||
model.OrderAmountFiat = OrderAmountFromInvoice(model.CryptoCode, invoice, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
|
||||
if (storeBlob.PlaySoundOnPayment)
|
||||
{
|
||||
model.PaymentSoundUrl = string.IsNullOrEmpty(storeBlob.SoundFileId)
|
||||
? string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
|
||||
: await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), storeBlob.SoundFileId);
|
||||
model.ErrorSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/error.mp3");
|
||||
model.NfcReadSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/nfcread.mp3");
|
||||
}
|
||||
|
||||
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
|
||||
model.TimeLeft = expiration.PrettyPrint();
|
||||
return model;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
@@ -55,6 +56,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly AppService _appService;
|
||||
private readonly IFileService _fileService;
|
||||
|
||||
public WebhookSender WebhookNotificationManager { get; }
|
||||
|
||||
@@ -79,6 +81,7 @@ namespace BTCPayServer.Controllers
|
||||
InvoiceActivator invoiceActivator,
|
||||
LinkGenerator linkGenerator,
|
||||
AppService appService,
|
||||
IFileService fileService,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
_displayFormatter = displayFormatter;
|
||||
@@ -100,6 +103,7 @@ namespace BTCPayServer.Controllers
|
||||
_invoiceActivator = invoiceActivator;
|
||||
_linkGenerator = linkGenerator;
|
||||
_authorizationService = authorizationService;
|
||||
_fileService = fileService;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
|
||||
@@ -1050,29 +1050,28 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.LogoFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
|
||||
}
|
||||
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.LogoFile.Bufferize();
|
||||
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.LogoFile = formFile;
|
||||
// delete existing image
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(settings.LogoFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(settings.LogoFileId, userId);
|
||||
}
|
||||
|
||||
// add new image
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.LogoFile, userId);
|
||||
|
||||
@@ -392,6 +392,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
vm.UseClassicCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V1;
|
||||
vm.CelebratePayment = storeBlob.CelebratePayment;
|
||||
vm.PlaySoundOnPayment = storeBlob.PlaySoundOnPayment;
|
||||
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
|
||||
vm.ShowPayInWalletButton = storeBlob.ShowPayInWalletButton;
|
||||
vm.ShowStoreHeader = storeBlob.ShowStoreHeader;
|
||||
@@ -401,6 +402,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
|
||||
vm.CustomCSS = storeBlob.CustomCSS;
|
||||
vm.CustomLogo = storeBlob.CustomLogo;
|
||||
vm.SoundFileId = storeBlob.SoundFileId;
|
||||
vm.HtmlTitle = storeBlob.HtmlTitle;
|
||||
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
|
||||
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
|
||||
@@ -450,7 +452,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
[HttpPost("{storeId}/checkout")]
|
||||
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model)
|
||||
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model, [FromForm] bool RemoveSoundFile = false)
|
||||
{
|
||||
bool needUpdate = false;
|
||||
var blob = CurrentStore.GetStoreBlob();
|
||||
@@ -475,6 +477,57 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId is null)
|
||||
return NotFound();
|
||||
|
||||
if (model.SoundFile != null)
|
||||
{
|
||||
if (model.SoundFile.Length > 1_000_000)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file should be less than 1MB");
|
||||
}
|
||||
else if (!model.SoundFile.ContentType.StartsWith("audio/", StringComparison.InvariantCulture))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.SoundFile.Bufferize();
|
||||
if (!FileTypeDetector.IsAudio(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.SoundFile = formFile;
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.SoundFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.SoundFileId, userId);
|
||||
}
|
||||
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.SoundFile, userId);
|
||||
blob.SoundFileId = storedFile.Id;
|
||||
needUpdate = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), $"Could not save sound: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RemoveSoundFile && !string.IsNullOrEmpty(blob.SoundFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.SoundFileId, userId);
|
||||
blob.SoundFileId = null;
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@@ -516,6 +569,7 @@ namespace BTCPayServer.Controllers
|
||||
blob.ShowStoreHeader = model.ShowStoreHeader;
|
||||
blob.CheckoutType = model.UseClassicCheckout ? Client.Models.CheckoutType.V1 : Client.Models.CheckoutType.V2;
|
||||
blob.CelebratePayment = model.CelebratePayment;
|
||||
blob.PlaySoundOnPayment = model.PlaySoundOnPayment;
|
||||
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
|
||||
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
|
||||
blob.RequiresRefundEmail = model.RequiresRefundEmail;
|
||||
@@ -674,28 +728,27 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.LogoFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
|
||||
}
|
||||
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.LogoFile.Bufferize();
|
||||
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.LogoFile = formFile;
|
||||
// delete existing image
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.LogoFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.LogoFileId, userId);
|
||||
}
|
||||
|
||||
// add new image
|
||||
try
|
||||
{
|
||||
@@ -704,7 +757,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), $"Could not save logo: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,25 +773,24 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.CssFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file should be less than 1MB");
|
||||
}
|
||||
else if (!model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
|
||||
}
|
||||
else if (!model.CssFile.FileName.EndsWith(".css", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
|
||||
}
|
||||
else
|
||||
{
|
||||
// delete existing CSS file
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.CssFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.CssFileId, userId);
|
||||
}
|
||||
|
||||
// add new CSS file
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.CssFile, userId);
|
||||
@@ -746,7 +798,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}";
|
||||
ModelState.AddModelError(nameof(model.CssFile), $"Could not save CSS file: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,12 @@ namespace BTCPayServer.Data
|
||||
[DefaultValue(true)]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
public bool CelebratePayment { get; set; } = true;
|
||||
|
||||
[DefaultValue(true)]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
public bool PlaySoundOnPayment { get; set; } = false;
|
||||
|
||||
public string SoundFileId { get; set; }
|
||||
|
||||
public IPaymentFilter GetExcludedPaymentMethods()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace BTCPayServer
|
||||
{
|
||||
// Thanks to https://www.garykessler.net/software/FileSigs_20220731.zip
|
||||
|
||||
|
||||
const string pictureSigs =
|
||||
"JPEG2000 image files,00 00 00 0C 6A 50 20 20,JP2,Picture,0,(null)\n" +
|
||||
"Bitmap image,42 4D,BMP|DIB,Picture,0,(null)\n" +
|
||||
@@ -19,19 +20,30 @@ namespace BTCPayServer
|
||||
"JPEG-EXIF-SPIFF images,FF D8 FF,JFIF|JPE|JPEG|JPG,Picture,0,FF D9\n" +
|
||||
"SVG images, 3C 73 76 67,SVG,Picture,0,(null)\n" +
|
||||
"Google WebP image file, 52 49 46 46 XX XX XX XX 57 45 42 50,WEBP,Picture,0,(null)\n" +
|
||||
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n";
|
||||
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n" +
|
||||
"MP3 audio file,49 44 33,MP3,Multimedia,0,(null)\n" +
|
||||
"MP3 audio file,FF,MP3,Multimedia,0,(null)\n" +
|
||||
"RIFF Windows Audio,57 41 56 45 66 6D 74 20,WAV,Multimedia,8,(null)\n" +
|
||||
"Free Lossless Audio Codec file,66 4C 61 43 00 00 00 22,FLAC,Multimedia,0,(null)\n" +
|
||||
"MPEG-4 AAC audio,FF F1,AAC,Audio,0,(null)\n" +
|
||||
"Ogg Vorbis Codec compressed file,4F 67 67 53,OGA|OGG|OGV|OGX,Multimedia,0,(null)\n" +
|
||||
"Apple Lossless Audio Codec file,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
|
||||
"WebM/WebA,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
|
||||
"WebM/WEBA video file,1A 45 DF A3,WEBM|WEBA,Multimedia,0,(null)\n" +
|
||||
"Resource Interchange File Format,52 49 46 46,AVI|CDA|QCP|RMI|WAV|WEBP,Multimedia,0,(null)\n";
|
||||
|
||||
readonly static (int[] Header, int[]? Trailer, string[] Extensions)[] headerTrailers;
|
||||
readonly static (int[] Header, int[]? Trailer, string Type, string[] Extensions)[] headerTrailers;
|
||||
static FileTypeDetector()
|
||||
{
|
||||
var lines = pictureSigs.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
headerTrailers = new (int[] Header, int[]? Trailer, string[] Extensions)[lines.Length];
|
||||
headerTrailers = new (int[] Header, int[]? Trailer, string Type, string[] Extensions)[lines.Length];
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var cells = lines[i].Split(',');
|
||||
headerTrailers[i] = (
|
||||
DecodeData(cells[1]),
|
||||
cells[^1] == "(null)" ? null : DecodeData(cells[^1]),
|
||||
cells[3],
|
||||
cells[2].Split('|').Select(p => $".{p}").ToArray()
|
||||
);
|
||||
}
|
||||
@@ -51,11 +63,21 @@ namespace BTCPayServer
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public static bool IsPicture(byte[] bytes, string? filename)
|
||||
{
|
||||
return IsFileType(bytes, filename, new[] { "Picture" });
|
||||
}
|
||||
public static bool IsAudio(byte[] bytes, string? filename)
|
||||
{
|
||||
return IsFileType(bytes, filename, new[] { "Multimedia", "Audio" });
|
||||
}
|
||||
|
||||
static bool IsFileType(byte[] bytes, string? filename, string[] types)
|
||||
{
|
||||
for (int i = 0; i < headerTrailers.Length; i++)
|
||||
{
|
||||
if (!types.Contains(headerTrailers[i].Type))
|
||||
goto next;
|
||||
if (headerTrailers[i].Header is int[] header)
|
||||
{
|
||||
if (header.Length > bytes.Length)
|
||||
@@ -80,7 +102,7 @@ namespace BTCPayServer
|
||||
if (filename is not null)
|
||||
{
|
||||
if (!headerTrailers[i].Extensions.Any(ext => filename.Length > ext.Length && filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
goto next;
|
||||
}
|
||||
return true;
|
||||
next:
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public string CustomLogoLink { get; set; }
|
||||
public string CssFileId { get; set; }
|
||||
public string LogoFileId { get; set; }
|
||||
public string PaymentSoundUrl { get; set; }
|
||||
public string NfcReadSoundUrl { get; set; }
|
||||
public string ErrorSoundUrl { get; set; }
|
||||
public string BrandColor { get; set; }
|
||||
public string HtmlTitle { get; set; }
|
||||
public string DefaultLang { get; set; }
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@@ -44,6 +45,9 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Celebrate payment with confetti")]
|
||||
public bool CelebratePayment { get; set; }
|
||||
|
||||
[Display(Name = "Enable sounds on checkout page")]
|
||||
public bool PlaySoundOnPayment { get; set; }
|
||||
|
||||
[Display(Name = "Requires a refund email")]
|
||||
public bool RequiresRefundEmail { get; set; }
|
||||
|
||||
@@ -61,9 +65,14 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
|
||||
[Display(Name = "Link to a custom CSS stylesheet")]
|
||||
public string CustomCSS { get; set; }
|
||||
|
||||
[Display(Name = "Link to a custom logo")]
|
||||
public string CustomLogo { get; set; }
|
||||
|
||||
[Display(Name = "Custom sound file for successful payment")]
|
||||
public IFormFile SoundFile { get; set; }
|
||||
public string SoundFileId { get; set; }
|
||||
|
||||
[Display(Name = "Custom HTML title to display on Checkout page")]
|
||||
public string HtmlTitle { get; set; }
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
|
||||
await ndef.scan({ signal: this.readerAbortController.signal })
|
||||
|
||||
ndef.onreadingerror = this.reportNfcError
|
||||
ndef.onreadingerror = () => this.reportNfcError('Could not read NFC tag')
|
||||
|
||||
ndef.onreading = async ({ message }) => {
|
||||
const record = message.records[0]
|
||||
@@ -180,7 +180,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
await this.sendData(data)
|
||||
break;
|
||||
case 'nfc:error':
|
||||
this.reportNfcError()
|
||||
this.reportNfcError('Could not read NFC tag')
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -189,7 +189,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
// we came here, so the user must have allowed NFC access
|
||||
this.permissionGranted = true;
|
||||
} catch (error) {
|
||||
this.errorMessage = `NFC scan failed: ${error}`;
|
||||
this.reportNfcError(`NFC scan failed: ${error}`);
|
||||
}
|
||||
},
|
||||
async sendData (lnurl) {
|
||||
@@ -197,6 +197,8 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
this.successMessage = null;
|
||||
this.errorMessage = null;
|
||||
|
||||
if (this.isV2) this.$root.playSound('nfcRead');
|
||||
|
||||
// Post LNURL-Withdraw data to server
|
||||
const body = JSON.stringify({ lnurl, invoiceId: this.model.invoiceId, amount: this.amount })
|
||||
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
|
||||
@@ -208,15 +210,16 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
if (response.ok) {
|
||||
this.successMessage = result;
|
||||
} else {
|
||||
this.errorMessage = result;
|
||||
this.reportNfcError(error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage = error;
|
||||
this.reportNfcError(error);
|
||||
}
|
||||
this.submitting = false;
|
||||
},
|
||||
reportNfcError() {
|
||||
this.errorMessage = 'Could not read NFC tag';
|
||||
reportNfcError(message) {
|
||||
this.errorMessage = message;
|
||||
if (this.isV2) this.$root.playSound('error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@using BTCPayServer.Services
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Microsoft.EntityFrameworkCore.Diagnostics
|
||||
@inject LanguageService LangService
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@inject IEnumerable<IUIExtension> UiExtensions
|
||||
@@ -27,6 +28,18 @@
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" />
|
||||
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
|
||||
@if (!string.IsNullOrEmpty(Model.PaymentSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.PaymentSoundUrl" as="audio" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.NfcReadSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.NfcReadSoundUrl" as="audio" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.ErrorSoundUrl" as="audio" />
|
||||
}
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div id="Checkout-v2" class="public-page-wrap" v-cloak>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
@using BTCPayServer.Payments
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Services.Stores
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@inject IFileService FileService
|
||||
@model CheckoutAppearanceViewModel
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id);
|
||||
|
||||
var store = ViewContext.HttpContext.GetStoreData();
|
||||
var canUpload = await FileService.IsAvailable();
|
||||
}
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
@@ -22,6 +24,7 @@
|
||||
$("#UseClassicCheckout").prop('checked', false);
|
||||
$("#CheckoutV2Settings").addClass('show');
|
||||
$("#ClassicCheckoutSettings").removeClass('show');
|
||||
$("#PlaySoundOnPayment").prop('checked', true);
|
||||
$("#ShowPayInWalletButton").prop('checked', false);
|
||||
$("#ShowStoreHeader").prop('checked', false);
|
||||
});
|
||||
@@ -29,6 +32,7 @@
|
||||
$("#UseClassicCheckout").prop('checked', false);
|
||||
$("#CheckoutV2Settings").addClass('show');
|
||||
$("#ClassicCheckoutSettings").removeClass('show');
|
||||
$("#PlaySoundOnPayment").prop('checked', false);
|
||||
$("#ShowPayInWalletButton").prop('checked', true);
|
||||
$("#ShowStoreHeader").prop('checked', true);
|
||||
});
|
||||
@@ -37,7 +41,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xxl-constrain col-xl-8">
|
||||
<form method="post">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
@@ -113,6 +117,41 @@
|
||||
<div class="form-check">
|
||||
<input asp-for="CelebratePayment" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="CelebratePayment" class="form-check-label"></label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="PlaySoundOnPayment" type="checkbox" class="form-check-input" data-bs-toggle="collapse" data-bs-target="#PlaySoundOnPaymentOptions" aria-expanded="@Model.PlaySoundOnPayment" aria-controls="PlaySoundOnPaymentOptions" />
|
||||
<label asp-for="PlaySoundOnPayment" class="form-check-label"></label>
|
||||
<div class="collapse @(Model.PlaySoundOnPayment ? "show" : "")" id="PlaySoundOnPaymentOptions">
|
||||
<div class="form-group mb-0 py-3">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<label asp-for="SoundFile" class="form-label"></label>
|
||||
@if (!string.IsNullOrEmpty(Model.SoundFileId))
|
||||
{
|
||||
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveSoundFile" value="true">
|
||||
<span class="fa fa-times"></span> Remove
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (canUpload)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<input asp-for="SoundFile" type="file" class="form-control flex-grow">
|
||||
@{
|
||||
var soundUrl = string.IsNullOrEmpty(Model.SoundFileId)
|
||||
? string.Concat(Context.Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
|
||||
: await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.SoundFileId);
|
||||
}
|
||||
<audio controls src="@soundUrl" style="height:2.1rem;max-width:10.5rem;"></audio>
|
||||
</div>
|
||||
<span asp-validation-for="SoundFile" class="text-danger"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input asp-for="SoundFile" type="file" class="form-control" disabled>
|
||||
<div class="form-text">In order to upload a custom sound, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="ShowStoreHeader" type="checkbox" class="form-check-input" />
|
||||
|
||||
@@ -140,6 +140,9 @@ section dl > div dd {
|
||||
height: 3rem;
|
||||
margin: .5rem auto 1.5rem;
|
||||
}
|
||||
#result #sound {
|
||||
display: none;
|
||||
}
|
||||
#result #confetti {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -116,7 +116,10 @@ function initApp() {
|
||||
paymentMethodId: null,
|
||||
endData: null,
|
||||
isModal: srvModel.isModal,
|
||||
pollTimeoutID: null
|
||||
pollTimeoutID: null,
|
||||
paymentSound: null,
|
||||
nfcReadSound: null,
|
||||
errorSound: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -236,6 +239,11 @@ function initApp() {
|
||||
if (this.isProcessing) {
|
||||
this.listenForConfirmations();
|
||||
}
|
||||
if (this.srvModel.paymentSoundUrl) {
|
||||
this.prepareSound(this.srvModel.paymentSoundUrl).then(sound => this.paymentSound = sound);
|
||||
this.prepareSound(this.srvModel.nfcReadSoundUrl).then(sound => this.nfcReadSound = sound);
|
||||
this.prepareSound(this.srvModel.errorSoundUrl).then(sound => this.errorSound = sound);
|
||||
}
|
||||
updateLanguageSelect();
|
||||
window.parent.postMessage('loaded', '*');
|
||||
},
|
||||
@@ -338,7 +346,23 @@ function initApp() {
|
||||
replaceNewlines (value) {
|
||||
return value ? value.replace(/\n/ig, '<br>') : '';
|
||||
},
|
||||
playSound (soundName) {
|
||||
// sound
|
||||
const sound = this[soundName + 'Sound'];
|
||||
if (sound && !sound.playing) {
|
||||
const { audioContext, audioBuffer } = sound;
|
||||
const source = audioContext.createBufferSource();
|
||||
source.onended = () => { sound.playing = false; };
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start();
|
||||
sound.playing = true;
|
||||
}
|
||||
},
|
||||
async celebratePayment (duration) {
|
||||
// sound
|
||||
this.playSound('payment')
|
||||
// confetti
|
||||
const $confettiEl = document.getElementById('confetti')
|
||||
if (window.confetti && $confettiEl && !$confettiEl.dataset.running) {
|
||||
$confettiEl.dataset.running = true;
|
||||
@@ -351,6 +375,14 @@ function initApp() {
|
||||
});
|
||||
delete $confettiEl.dataset.running;
|
||||
}
|
||||
},
|
||||
async prepareSound (url) {
|
||||
const audioContext = new AudioContext();
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return console.error(`Could not load payment sound, HTTP error ${response.status}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return { audioContext, audioBuffer, playing: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
BIN
BTCPayServer/wwwroot/checkout-v2/error.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/error.mp3
Normal file
Binary file not shown.
BIN
BTCPayServer/wwwroot/checkout-v2/nfcread.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/nfcread.mp3
Normal file
Binary file not shown.
BIN
BTCPayServer/wwwroot/checkout-v2/payment.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/payment.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user