From f3d9e07c5ed45c616af7ede4ea0c973c6c2e0605 Mon Sep 17 00:00:00 2001 From: d11n Date: Mon, 13 Mar 2023 02:09:56 +0100 Subject: [PATCH] Checkout v2: Celebrate payment with confetti (#4727) * Checkout v2: Celebrate payment with confetti Have a colorful celebration for successful payments. * Make it default and add test --- BTCPayServer.Tests/Checkoutv2Tests.cs | 1 + BTCPayServer.Tests/ThirdPartyTests.cs | 5 ++ .../Controllers/UIInvoiceController.UI.cs | 1 + .../Controllers/UIStoresController.cs | 3 +- BTCPayServer/Data/StoreBlob.cs | 4 ++ .../Models/InvoicingModels/PaymentModel.cs | 1 + .../CheckoutAppearanceViewModel.cs | 3 ++ .../Views/UIInvoice/CheckoutV2.cshtml | 12 +++-- .../Views/UIStores/CheckoutAppearance.cshtml | 4 ++ BTCPayServer/wwwroot/checkout-v2/checkout.css | 11 ++++- BTCPayServer/wwwroot/checkout-v2/checkout.js | 47 ++++++++++++++----- .../vendor/dom-confetti/dom-confetti.min.js | 8 ++++ 12 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 BTCPayServer/wwwroot/vendor/dom-confetti/dom-confetti.min.js diff --git a/BTCPayServer.Tests/Checkoutv2Tests.cs b/BTCPayServer.Tests/Checkoutv2Tests.cs index fca082f6b..3381b28a4 100644 --- a/BTCPayServer.Tests/Checkoutv2Tests.cs +++ b/BTCPayServer.Tests/Checkoutv2Tests.cs @@ -186,6 +186,7 @@ namespace BTCPayServer.Tests Assert.True(paidSection.Displayed); Assert.Contains("Invoice Paid", paidSection.Text); }); + s.Driver.FindElement(By.Id("confetti")); s.Driver.FindElement(By.Id("ReceiptLink")); Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href")); diff --git a/BTCPayServer.Tests/ThirdPartyTests.cs b/BTCPayServer.Tests/ThirdPartyTests.cs index 02852ea0e..ae20411e4 100644 --- a/BTCPayServer.Tests/ThirdPartyTests.cs +++ b/BTCPayServer.Tests/ThirdPartyTests.cs @@ -353,6 +353,11 @@ retry: actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "tom-select", "tom-select.complete.min.js").Trim(); expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js")).Content.ReadAsStringAsync()).Trim(); Assert.Equal(expected, actual); + + actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "dom-confetti", "dom-confetti.min.js").Trim(); + version = Regex.Match(actual, "Original file: /npm/dom-confetti@([0-9]+.[0-9]+.[0-9]+)/lib/main.js").Groups[1].Value; + expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}/lib/main.min.js")).Content.ReadAsStringAsync()).Trim(); + Assert.Equal(expected, actual); } string GetFileContent(params string[] path) diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index dd147d8ad..7de0b3736 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -789,6 +789,7 @@ namespace BTCPayServer.Controllers BrandColor = storeBlob.BrandColor, CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType, HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", + CelebratePayment = storeBlob.CelebratePayment, OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback, CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)), BtcAddress = paymentMethodDetails.GetPaymentDestination(), diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index b54d622c2..21f83c442 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -386,6 +386,7 @@ namespace BTCPayServer.Controllers }).ToList(); vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2; + vm.CelebratePayment = storeBlob.CelebratePayment; vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback; vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; @@ -505,7 +506,7 @@ namespace BTCPayServer.Controllers } blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1; - + blob.CelebratePayment = model.CelebratePayment; blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback; blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi; blob.RequiresRefundEmail = model.RequiresRefundEmail; diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 71ae55bd3..7150cbea2 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -218,6 +218,10 @@ namespace BTCPayServer.Data public string BrandColor { get; set; } public string LogoFileId { get; set; } public string CssFileId { get; set; } + + [DefaultValue(true)] + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + public bool CelebratePayment { get; set; } = true; public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index b24383110..ae3476832 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -33,6 +33,7 @@ namespace BTCPayServer.Models.InvoicingModels public bool IsModal { get; set; } public bool IsUnsetTopUp { get; set; } public bool OnChainWithLnInvoiceFallback { get; set; } + public bool CelebratePayment { get; set; } public string CryptoCode { get; set; } public string InvoiceId { get; set; } public string BtcAddress { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs index 70aba10d4..674ccb652 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs @@ -35,6 +35,9 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Use the new checkout")] public bool UseNewCheckout { get; set; } + [Display(Name = "Celebrate payment with confetti")] + public bool CelebratePayment { get; set; } + [Display(Name = "Requires a refund email")] public bool RequiresRefundEmail { get; set; } diff --git a/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml index 907b00482..07ee61027 100644 --- a/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml +++ b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml @@ -104,6 +104,7 @@
diff --git a/BTCPayServer/wwwroot/checkout-v2/checkout.css b/BTCPayServer/wwwroot/checkout-v2/checkout.css index ff858cdb5..12e88a31e 100644 --- a/BTCPayServer/wwwroot/checkout-v2/checkout.css +++ b/BTCPayServer/wwwroot/checkout-v2/checkout.css @@ -4,6 +4,9 @@ --border-radius: var(--btcpay-border-radius-l); --wrap-max-width: 400px; } +body { + overflow-x: hidden; +} .public-page-wrap { flex-direction: column; max-width: var(--wrap-max-width); @@ -160,7 +163,13 @@ section dl > div dd { height: 3rem; margin: .5rem auto 1.5rem; } - +#result #confetti { + position: absolute; + left: 50%; + top: 2rem; + width: 1.5rem; + height: 1.5rem; +} #result #paid .top .icn .icon { color: var(--btcpay-primary); } diff --git a/BTCPayServer/wwwroot/checkout-v2/checkout.js b/BTCPayServer/wwwroot/checkout-v2/checkout.js index 341a6bce4..ec23a0ea7 100644 --- a/BTCPayServer/wwwroot/checkout-v2/checkout.js +++ b/BTCPayServer/wwwroot/checkout-v2/checkout.js @@ -183,6 +183,28 @@ function initApp() { return !this.paymentMethodIds.includes(this.pmId); } }, + watch: { + isPaid: function (newValue, oldValue) { + if (newValue === true && oldValue === false) { + const duration = 5000; + const self = this; + // celebration! + Vue.nextTick(function () { + self.celebratePayment(duration); + }); + // automatic redirect or close + if (self.srvModel.redirectAutomatically && self.storeLink) { + setTimeout(function () { + if (self.isModal && window.top.location === self.storeLink) { + self.close(); + } else { + window.top.location = self.storeLink; + } + }, duration); + } + } + } + }, mounted () { this.updateData(this.srvModel); this.updateTimer(); @@ -268,20 +290,23 @@ function initApp() { // updating ui this.srvModel = data; eventBus.$emit('data-fetched', this.srvModel); - - const self = this; - if (self.isPaid && data.redirectAutomatically && self.storeLink) { - setTimeout(function () { - if (self.isModal && window.top.location === self.storeLink){ - self.close(); - } else { - window.top.location = self.storeLink; - } - }, 2000); - } }, replaceNewlines (value) { return value ? value.replace(/\n/ig, '
') : ''; + }, + async celebratePayment (duration) { + const $confettiEl = document.getElementById('confetti') + if (window.confetti && $confettiEl && !$confettiEl.dataset.running) { + $confettiEl.dataset.running = true; + await window.confetti($confettiEl, { + duration, + spread: 90, + stagger: 5, + elementCount: 121, + colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"] + }); + delete $confettiEl.dataset.running; + } } } }); diff --git a/BTCPayServer/wwwroot/vendor/dom-confetti/dom-confetti.min.js b/BTCPayServer/wwwroot/vendor/dom-confetti/dom-confetti.min.js new file mode 100644 index 000000000..5bf3be974 --- /dev/null +++ b/BTCPayServer/wwwroot/vendor/dom-confetti/dom-confetti.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v3.14.1. + * Original file: /npm/dom-confetti@0.2.2/lib/main.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.confetti=confetti;var defaultColors=["#a864fd","#29cdff","#78ff44","#ff718d","#fdff6a"];function createElements(e,t,i,s,n){return Array.from({length:t}).map(function(t,a){var r=document.createElement("div"),l=i[a%i.length];return r.style["background-color"]=l,r.style.width=s,r.style.height=n,r.style.position="absolute",r.style.willChange="transform, opacity",r.style.visibility="hidden",e.appendChild(r),r})}function randomPhysics(e,t,i,s){var n=e*(Math.PI/180),a=t*(Math.PI/180);return{x:0,y:0,z:0,wobble:10*s(),wobbleSpeed:.1+.1*s(),velocity:.5*i+s()*i,angle2D:-n+(.5*a-s()*a),angle3D:-Math.PI/4+s()*(Math.PI/2),tiltAngle:s()*Math.PI,tiltAngleSpeed:.1+.3*s()}}function updateFetti(e,t,i,s){e.physics.x+=Math.cos(e.physics.angle2D)*e.physics.velocity,e.physics.y+=Math.sin(e.physics.angle2D)*e.physics.velocity,e.physics.z+=Math.sin(e.physics.angle3D)*e.physics.velocity,e.physics.wobble+=e.physics.wobbleSpeed,s?e.physics.velocity*=s:e.physics.velocity-=e.physics.velocity*i,e.physics.y+=3,e.physics.tiltAngle+=e.physics.tiltAngleSpeed;var n=e.physics,a=n.x,r=n.y,l=n.z,o=n.tiltAngle,c=n.wobble,h="translate3d("+(a+10*Math.cos(c))+"px, "+(r+10*Math.sin(c))+"px, "+l+"px) rotate3d(1, 1, 1, "+o+"rad)";e.element.style.visibility="visible",e.element.style.transform=h,e.element.style.opacity=1-t}function animate(e,t,i,s,n,a){var r=void 0;return new Promise(function(l){requestAnimationFrame(function o(c){r||(r=c);var h=c-r,y=r===c?0:(c-r)/n;t.slice(0,Math.ceil(h/a)).forEach(function(e){updateFetti(e,y,i,s)}),c-r1&&void 0!==arguments[1]?arguments[1]:{},i=Object.assign({},defaults,backwardPatch(t)),s=i.elementCount,n=i.colors,a=i.width,r=i.height,l=i.perspective,o=i.angle,c=i.spread,h=i.startVelocity,y=i.decay,d=i.dragFriction,p=i.duration,u=i.stagger,f=i.random;return e.style.perspective=l,animate(e,createElements(e,s,n,a,r).map(function(e){return{element:e,physics:randomPhysics(o,c,h,f)}}),d,y,p,u)} +//# sourceMappingURL=/sm/b04c41c9e0ca7d063aa88138fff144a5dd6481a3809187211290de788b6184f8.map