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
This commit is contained in:
d11n 2023-03-13 02:09:56 +01:00 committed by GitHub
parent eb3ba95114
commit f3d9e07c5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 84 additions and 16 deletions

View file

@ -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"));

View file

@ -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)

View file

@ -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(),

View file

@ -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;

View file

@ -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()
{

View file

@ -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; }

View file

@ -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; }

View file

@ -104,6 +104,7 @@
<div id="paid" v-if="isPaid">
<div class="top">
<span class="icn">
<div id="confetti" v-if="srvModel.celebratePayment" v-on:click="celebratePayment(5000)"></div>
<vc:icon symbol="payment-complete"/>
</span>
<h4 v-t="'invoice_paid'"></h4>
@ -130,7 +131,7 @@
<div id="expired" v-if="isUnpayable">
<div class="top">
<span class="icn">
<vc:icon symbol="invoice-expired"/>
<vc:icon symbol="invoice-expired" />
</span>
<h4 v-t="'invoice_expired'"></h4>
<div id="PaymentDetails" class="payment-details">
@ -148,7 +149,7 @@
</div>
<button class="d-flex align-items-center gap-1 btn btn-link payment-details-button" type="button" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<span class="fw-semibold" v-t="'view_details'"></span>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
<p class="text-center mt-3" v-html="replaceNewlines($t('invoice_expired_body', { storeName: srvModel.storeName, minutes: @Model.MaxTimeMinutes }))"></p>
</div>
@ -219,13 +220,18 @@
</dl>
</script>
<script>
const i18nUrl = @Safe.Json($"{Model.RootPath}misc/translations/checkout-v2/{{{{lng}}}}?v={Env.Version}");
const i18nUrl = @Safe.Json($"{Model.RootPath}misc/translations/checkout-v2/{{{{lng}}}}?v={Env.Version}");
const statusUrl = @Safe.Json(Url.Action("GetStatus", new { invoiceId = Model.InvoiceId }));
const statusWsUrl = @Safe.Json(Url.Action("GetStatusWebSocket", new { invoiceId = Model.InvoiceId }));
const availableLanguages = @Safe.Json(LangService.GetLanguages().Select(language => language.Code));
const initialSrvModel = @Safe.Json(Model);
const qrOptions = { margin: 0, type: 'svg', color: { dark: '#000', light: '#fff' } };
window.exports = {};
</script>
@if (Model.CelebratePayment)
{
<script src="~/vendor/dom-confetti/dom-confetti.min.js" asp-append-version="true"></script>
}
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18next.min.js" asp-append-version="true"></script>

View file

@ -86,6 +86,10 @@
</div>
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>
</div>
<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>
<div class="form-check">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input" />

View file

@ -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);
}

View file

@ -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, '<br>') : '';
},
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;
}
}
}
});

View file

@ -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-r<n?requestAnimationFrame(o):(t.forEach(function(t){if(t.element.parentNode===e)return e.removeChild(t.element)}),l())})})}var defaults={angle:90,spread:45,startVelocity:45,elementCount:50,width:"10px",height:"10px",perspective:"",colors:defaultColors,duration:3e3,stagger:0,dragFriction:.1,random:Math.random};function backwardPatch(e){return!e.stagger&&e.delay&&(e.stagger=e.delay),e}function confetti(e){var t=arguments.length>1&&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