POS Cart redesign (#5171)

* Move POS assets

* WIP

* Refactor into common Vue mixin

* Offcanvas updates

* Unifications across POS views

* POSData view fix

* Number and test fixes

* Update cart width

* Fix test

* More view unification

* Hide cart when emptied

* Validate cart

* Header improvement

* Increase remove icon size

* Animate add to cart action

* Offcanvas for mobile, sidebar for desktop

* ui+pos: updates icon size + badge + label

* Remove cart table headers

* Use same size for Cart and Shop headlines

* Update search placeholder

* Bump horizontal  input padding

* Increase sidebar width

* Bump badge font size

* Fix manipulating the quantity of line items

* Fix cart icon

* Update cart display

* updates empty button

* Rounded search input

* Remove cart button on desktop

* Fix dark accent color

* More accent fixes

* Fix plus/minus alignment

* Update BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml

* Apply suggestions from code review

---------

Co-authored-by: dstrukt <gfxdsign@gmail.com>
This commit is contained in:
d11n 2023-07-22 14:15:41 +02:00 committed by GitHub
parent 2e4be9310c
commit 845e2881fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1099 additions and 1953 deletions

View file

@ -988,14 +988,14 @@ namespace BTCPayServer.Tests
Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS");
Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view");
Assert.True(s.Driver.PageSource.Contains("Take my money"), "PoS not showing correct default view");
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
var drinks = s.Driver.FindElement(By.CssSelector("label[for='Category-Drinks']"));
Assert.Equal("Drinks", drinks.Text);
drinks.Click();
Assert.Single(s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")));
Assert.Single(s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")));
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
s.Driver.Url = posBaseUrl + "/static";
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");
@ -2189,47 +2189,52 @@ namespace BTCPayServer.Tests
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
s.Driver.FindElement(By.Id("ShowCustomAmount")).Click();
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.WaitForElement(By.Id("js-cart-list"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
Assert.Equal("0,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
Assert.False(s.Driver.FindElement(By.Id("CartClear")).Displayed);
s.Driver.WaitForElement(By.Id("PosItems"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select and clear
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.Id("CartClear")).Click();
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
Thread.Sleep(250);
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select items
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(2)")).Click();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")).Count);
Assert.Equal("2,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("3,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Custom amount
s.Driver.FindElement(By.Id("CartCustomAmount")).SendKeys("1.5");
s.Driver.FindElement(By.Id("CartTotal")).Click();
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
s.Driver.FindElement(By.Id("js-cart-confirm")).Click();
// Discount: 10%
s.Driver.ElementDoesNotExist(By.Id("CartDiscount"));
s.Driver.FindElement(By.Id("Discount")).SendKeys("10");
Assert.Contains("10% = 0,30 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
Assert.Equal("2,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Tip: 10%
s.Driver.ElementDoesNotExist(By.Id("CartTip"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("10% = 0,27 €", s.Driver.FindElement(By.Id("CartTip")).Text);
Assert.Equal("2,97 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Pay
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartSummaryTotal")).Text);
s.Driver.FindElement(By.Id("js-cart-pay")).Click();
s.Driver.FindElement(By.Id("CartSubmit")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("3,50 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("2,97 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]

View file

@ -93,7 +93,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
.ToList();
if (groups.Count == 0)
return;
groups.Insert(0, new KeyValuePair<string, string>("All items", "*"));
groups.Insert(0, new KeyValuePair<string, string>("All", "*"));
AllCategories = new SelectList(groups, "Value", "Key", "*");
}

View file

@ -53,7 +53,7 @@
<canvas id="fireworks" class="d-none"></canvas>
}
<div class="public-page-wrap flex-column container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
<div class="public-page-wrap container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
@if (!string.IsNullOrEmpty(Model.MainImageUrl))
{
<img v-if="srvModel.mainImageUrl" :src="srvModel.mainImageUrl" :alt="srvModel.title" id="crowdfund-main-image" asp-append-version="true"/>

View file

@ -1,349 +1,244 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Newtonsoft.Json.Linq;
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
Layout = "PointOfSale/Public/_Layout";
var customTipPercentages = Model.CustomTipPercentages;
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
Csp.UnsafeEval();
}
@section PageHeadContent {
<link rel="stylesheet" href="~/cart/css/style.css" asp-append-version="true">
<style>
.js-cart-item-minus .fa,
.js-cart-item-plus .fa {
background: #fff;
border-radius: 50%;
width: 17px;
height: 17px;
display: inline-flex;
justify-content: center;
align-items: center;
}
.card:not(.d-none:only-of-type) {
max-width: 320px;
margin: auto !important;
}
</style>
<link href="~/pos/cart.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/cart/js/cart.js" asp-append-version="true"></script>
<script src="~/cart/js/cart.jquery.js" asp-append-version="true"></script>
<script id="template-cart-item" type="text/template">
<tr data-id="{id}">
<td class="align-middle pe-0" width="1%">{image}</td>
<td class="align-middle pe-0 ps-2"><b>{title}</b></td>
<td class="align-middle px-0">
<a class="js-cart-item-remove btn btn-link" href="#"><i class="fa fa-trash text-muted"></i></a>
</td>
<td class="align-middle px-0">
<div class="input-group align-items-center">
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
<input class="js-cart-item-count form-control form-control-sm pull-left hide-number-spin text-end" type="number" step="1" name="count" placeholder="Qty" max="{inventory}" value="{count}" data-prev="{count}">
<a class="input-group-text js-cart-item-plus btn btn-link px-2" href="#">
<i class="fa fa-plus-circle fa-fw text-success"></i>
</a>
</div>
</td>
<td class="align-middle text-end">{price}</td>
</tr>
</script>
<script id="template-cart-item-image" type="text/template">
<img class="cart-item-image" src="{image}" alt="">
</script>
<script id="template-cart-custom-amount" type="text/template">
<tr>
<td colspan="5">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Pay what you want" id="CartCustomAmount">
<div class="input-group-text">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
</td>
</tr>
</script>
<script id="template-cart-extra" type="text/template">
@if (Model.ShowCustomAmount)
{
<tr>
<th colspan="5" class="border-0 pb-0">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" value="{customAmount}" placeholder="Pay what you want" id="CartCustomAmount">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</th>
</tr>
}
@if (Model.ShowDiscount)
{
<tr>
<th colspan="5" class="border-top-0">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-percent fa-fw"></i></span>
<input class="js-cart-discount form-control" type="number" min="0" step="@Model.Step" value="{discount}" name="discount" placeholder="Discount in %" id="CartDiscount">
<a class="js-cart-discount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</th>
</tr>
}
</script>
<script id="template-cart-tip" type="text/template">
@if (Model.EnableTips)
{
<tr>
<th colspan="5" class="border-top-0 pt-4 h5">@Model.CustomTipText</th>
</tr>
<tr>
<th colspan="5" class="border-0">
<div class="input-group mb-2">
<span class="input-group-text"><i class="fa fa-money fa-fw"></i></span>
<input
class="js-cart-tip form-control form-control-lg"
type="number"
min="0"
step="@Model.Step"
value="{tip}"
name="tip"
placeholder="Tip in @(Model.CurrencyInfo.CurrencySymbol != null ? Model.CurrencyInfo.CurrencySymbol : Model.CurrencyCode)"
/>
<a class="js-cart-tip-remove btn btn-lg btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
<div class="row mb-1">
@if (customTipPercentages != null && customTipPercentages.Length > 0)
{
@for (int i = 0; i < customTipPercentages.Length; i++)
{
var percentage = customTipPercentages[i];
<div class="col">
<a class="js-cart-tip-btn btn btn-lg btn-light w-100 border mb-2" href="#" data-tip="@percentage">@percentage%</a>
</div>
}
}
</div>
</th>
</tr>
}
</script>
<script id="template-cart-total" type="text/template">
<tr>
<th colspan="1" class="pb-4 h4">Total</th>
<th colspan="4" class="pb-4 h4 text-end">
<span class="js-cart-total" id="CartTotal">{total}</span>
</th>
</tr>
</script>
<script>const srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/cart.js" asp-append-version="true"></script>
}
<div id="cartModal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white border-0">
<h5 class="modal-title">Confirmation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body p-0">
<table id="js-cart-summary" class="table m-0">
<tbody class="my-3">
<tr>
<td colspan="2" class="border-top-0 h5">Summary</td>
</tr>
<tr>
<td class="border-0 pb-0 h6">Total products</td>
<td class="text-end border-0 pb-0 h6">
<span class="js-cart-summary-products text-nowrap"></span>
</td>
</tr>
@if (Model.ShowDiscount)
{
<tr>
<td class="border-0 pb-y h6">Discount</td>
<td class="text-end border-0 pb-y h6">
<span class="js-cart-summary-discount text-nowrap" id="CartSummaryDiscount"></span>
</td>
</tr>
}
@if (Model.EnableTips)
{
<tr>
<td class="border-top-0 pt-0 h6">Tip</td>
<td class="text-end border-top-0 pt-0 h6">
<span class="js-cart-summary-tip text-nowrap" id="CartSummaryTip"></span>
</td>
</tr>
}
<tr>
<td class="h3 table-light">Total</td>
<td class="h3 table-light text-end">
<span class="js-cart-summary-total text-nowrap" id="CartSummaryTotal"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer bg-light">
<form
id="js-cart-pay-form"
method="post"
asp-action="ViewPointOfSale"
asp-route-appId="@Model.AppId"
asp-antiforgery="false"
data-buy
>
<input id="js-cart-amount" type="hidden" name="amount">
<input id="js-cart-custom-amount" type="hidden" name="customAmount">
<input id="js-cart-tip" type="hidden" name="tip">
<input id="js-cart-discount" type="hidden" name="discount">
<input id="js-cart-posdata" type="hidden" name="posdata">
<button id="js-cart-pay" class="btn btn-primary btn-lg" type="submit">
<b>@Model.CustomButtonText</b>
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
<div id="PosCart">
<div id="content" class="public-page-wrap">
<div class="container-xl">
<header class="sticky-top bg-body d-flex flex-column py-3 py-lg-4 gap-3">
<div class="d-flex align-items-center justify-content-center gap-3 pe-5 position-relative">
<h1 class="mb-0">@(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title)</h1>
<button id="CartToggle" class="cart-toggle-btn" type="button" v-on:click="toggleCart" aria-controls="cart" :disabled="cartCount === 0">
<vc:icon symbol="pos-cart" />
<span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
</button>
</form>
</div>
</div>
</div>
</div>
<div class="wrapper">
<!-- Page Content -->
<div id="content">
<div class="p-2 p-sm-4">
<partial name="_StatusMessage" />
<div class="d-flex gap-3 mb-4">
<div class="flex-fill position-relative">
<input type="text" class="js-search form-control form-control-lg" placeholder="Find product">
<a class="js-search-reset btn btn-lg btn-link text-black" href="#">
<i class="fa fa-times-circle fa-lg"></i>
</a>
</div>
<a class="js-cart btn btn-lg btn-outline-primary text-nowrap" href="#">
<i class="fa fa-shopping-basket"></i>&nbsp;
<span class="badge bg-light rounded-pill">
<span id="js-cart-items">0</span>
</span>
</a>
</div>
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center mt-3">@Safe.Raw(Model.Description)</div>
}
@if (Model.AllCategories != null)
{
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3 mt-3" data-toggle="buttons" v-pre>
@foreach (var g in Model.AllCategories)
{
<input id="Category-@g.Value" type="radio" name="category" class="js-categories" value="@g.Value" @(g.Selected ? "checked" : "") autocomplete="off">
<label class="btcpay-pill" for="Category-@g.Value">@g.Text</label>
}
</div>
}
</div>
<div id="js-pos-list" class="text-center mx-auto px-2 px-sm-4 py-4 py-sm-2">
<div class="card-deck">
@for (var index = 0; index < Model.Items.Length; index++)
<input id="SearchTerm" class="form-control rounded-pill" placeholder="Search…" v-model="searchTerm">
<div v-if="allCategories" class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3">
<template v-for="cat in allCategories">
<input :id="`Category-${cat.value}`" type="radio" name="category" autocomplete="off" v-model="displayCategory" :value="cat.value">
<label :for="`Category-${cat.value}`" class="btcpay-pill">{{ cat.text }}</label>
</template>
</div>
</header>
<main>
<partial name="_StatusMessage" />
@if (!string.IsNullOrEmpty(Model.Description))
{
var item = Model.Items[index];
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
continue;
}
var image = item.Image;
var description = item.Description;
<div class="js-add-cart card px-0 card-wrapper" data-index="@index" data-categories="@(new JArray(item.Categories).ToString())">
@if (!string.IsNullOrWhiteSpace(image))
{
<img class="card-img-top" src="@image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
<div class="card-body p-3">
<h6 class="card-title mb-0">@Safe.Raw(item.Title)</h6>
@if (!string.IsNullOrWhiteSpace(description))
{
<p class="card-text">@Safe.Raw(description)</p>
}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
buttonText = buttonText.Replace("{0}",formatted)
?.Replace("{Price}",formatted);
}
}
@Safe.Raw(buttonText)
</span>
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
{
<span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}
else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
</div>
</div>
<div class="lead">@Safe.Raw(Model.Description)</div>
}
</div>
<div ref="posItems" class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-4" id="PosItems">
@for (var index = 0; index < Model.Items.Length; index++)
{
var item = Model.Items[index];
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
continue;
}
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
<div class="col posItem" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.Raw(item.Title) @Safe.Raw(item.Description)" data-categories="@(new JArray(item.Categories).ToString())">
<div class="card h-100 px-0" v-on:click="addToCart(@index)">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
{
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
else
{
<span class="fw-semibold">@Safe.Raw(formatted)</span>
}
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-warning" v-text="inventoryText(@index)">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
@if (inStock)
{
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<button type="button" class="btn btn-primary w-100" :disabled="!inStock(@index)">
@Safe.Raw(buttonText)
</button>
</div>
<div class="posItem-added"><vc:icon symbol="checkmark" /></div>
}
</div>
</div>
}
</div>
</main>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</div>
</div>
<!-- Sidebar -->
<nav id="sidebar">
<div class="d-flex align-items-center pt-4 p-2">
<h3 class="text-white m-0 me-auto">Cart</h3>
<a class="js-cart btn btn-sm bg-white text-black pull-right ms-5" href="#">
<i class="fa fa-times fa-lg"></i>
</a>
<a class="js-cart-destroy btn btn-danger pull-right" href="#" style="display: none;" id="CartClear">Empty cart <i class="fa fa-trash fa-fw fa-lg"></i></a>
<aside id="cart" ref="cart" tabindex="-1" aria-labelledby="cartLabel">
<div class="public-page-wrap" v-cloak>
<div class="container-xl">
<header class="sticky-top bg-tile offcanvas-header py-3 py-lg-4 d-flex align-items-baseline justify-content-center gap-3 px-5 pe-lg-0">
<h1 class="mb-0" id="cartLabel">Cart</h1>
<button id="CartClear" type="reset" v-on:click="clearCart" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
Empty
</button>
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
<vc:icon symbol="close" />
</button>
</header>
<div class="offcanvas-body py-0">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
<input type="hidden" name="amount" :value="totalNumeric">
<input type="hidden" name="tip" :value="tipNumeric">
<input type="hidden" name="discount" :value="discountPercentNumeric">
<input type="hidden" name="posdata" :value="posdata">
<table class="table table-borderless mt-0 mb-4">
<tbody id="CartItems">
<tr v-for="item in cart" :key="item.id">
<td class="align-middle">
<h6 class="fw-semibold mb-1">{{ item.title }}</h6>
<button type="button" v-on:click="removeFromCart(item.id)" class="btn btn-sm btn-link p-0 text-danger fw-semibold">Remove</button>
</td>
<td class="align-middle">
<div class="d-flex align-items-center gap-2 justify-content-end quantity">
<span class="badge text-bg-warning" v-if="item.inventory">
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
</span>
<div class="d-flex align-items-center gap-2">
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="minus" />
</button>
<input class="form-control hide-number-spin w-50px" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="plus" />
</button>
</div>
</div>
</td>
<td class="align-middle text-end">
{{ formatCurrency(item.price, true) }}
</td>
</tr>
</tbody>
</table>
<table class="table table-borderless my-4" v-if="showDiscount || enableTips">
<tr v-if="showDiscount">
<th class="align-middle">Discount</th>
<th class="align-middle" colspan="3">
<div class="input-group input-group-sm w-100px pull-right">
<input class="form-control hide-number-spin" type="number" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
<span class="input-group-text">%</span>
</div>
</th>
</tr>
<tr v-if="enableTips">
<th class="align-middle">Tip</th>
<th class="align-middle" colspan="3">
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-end gap-2" v-if="customTipPercentages">
<div class="btcpay-pill d-flex align-items-center px-3" id="Tip-Custom" :class="{ active: !tipPercent && tip }" v-on:click.prevent="tipPercent = null">
<input
v-model.number="tip"
class="form-control hide-number-spin shadow-none text-reset d-block bg-transparent border-0 p-0 me-1 fw-semibold"
style="height:1.5em;min-height:auto;width:4ch"
type="number"
min="0"
step="@Model.Step" />
<span>@(Model.CurrencyInfo.CurrencySymbol ?? Model.CurrencyCode)</span>
</div>
<button
v-for="percentage in customTipPercentages"
type="button"
class="btcpay-pill px-3"
:class="{ active: tipPercent == percentage }"
:id="`Tip-${percentage}`"
v-on:click.prevent="tipPercentage(percentage)">
{{ percentage }}%
</button>
</div>
</th>
</tr>
</table>
<table class="table table-borderless mt-4 mb-0">
<tr>
<td class="align-middle">Subtotal</td>
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
</tr>
<tr v-if="discountNumeric">
<td class="align-middle">Discount</td>
<td class="align-middle text-end" id="CartDiscount">
<span v-if="discountPercent">{{discountPercent}}% =</span>
{{ formatCurrency(discountNumeric, true) }}
</td>
</tr>
<tr v-if="tipNumeric">
<td class="align-middle">Tip</td>
<td class="align-middle text-end" id="CartTip">
<span v-if="tipPercent">{{tipPercent}}% =</span>
{{ formatCurrency(tipNumeric, true) }}
</td>
</tr>
<tr>
<td class="align-middle h5 border-0">Total</td>
<td class="align-middle h5 border-0 text-end" id="CartTotal">{{ formatCurrency(totalNumeric, true) }}</td>
</tr>
<tr>
<td colspan="2" class="pt-4">
<button id="CartSubmit" class="btn btn-primary btn-lg w-100" :disabled="payButtonLoading" type="submit">
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
<span class="sr-only">Loading...</span>
</div>
<template v-else>Pay</template>
</button>
</td>
</tr>
</table>
</form>
<p id="CartItems" v-else class="text-muted text-center my-0">There are no items in your cart yet.</p>
</div>
</div>
</div>
<table id="js-cart-list" class="table table-responsive table-light mt-0 mb-0">
<thead>
<tr>
<th colspan="3" width="55%">Product</th>
<th class="text-center" width="20%">
<div style="width: 84px">Quantity</div>
</th>
<th class="text-end" width="25%">
<div style="min-width: 50px">Price</div>
</th>
</tr>
</thead>
<tbody></tbody>
</table>
<table id="js-cart-extra" class="table table-light mt-0 mb-0">
<thead></thead>
</table>
<button id="js-cart-confirm" data-bs-toggle="modal" data-bs-target="#cartModal" class="btn btn-primary btn-lg mx-2 mb-3 p-3" disabled="disabled" type="submit">
Confirm
</button>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</nav>
</aside>
</div>

View file

@ -5,112 +5,15 @@
Csp.UnsafeEval();
}
@section PageHeadContent {
<style>
.public-page-wrap {
max-width: 560px;
overflow: hidden;
}
/* modes */
#ModeTabs {
min-height: 2.75rem;
}
/* keypad */
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.keypad .btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
border-radius: 0;
font-weight: var(--btcpay-font-weight-semibold);
font-size: 24px;
min-height: 3.5rem;
height: 8vh;
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="del"] svg {
--btn-icon-size: 2.25rem;
transform: rotate(180deg);
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
padding-left: 1rem;
padding-right: 1rem;
}
/* make borders collapse by shifting rows and columns by 1px */
/* second column */
.keypad .btn:nth-child(3n-1) {
margin-left: -1px;
}
/* third column */
.keypad .btn:nth-child(3n) {
margin-left: -1px;
}
/* from second row downwards */
.keypad .btn:nth-child(n+4) {
margin-top: -1px;
}
/* ensure highlighted button is topmost */
.keypad .btn:hover,
.keypad .btn:focus,
.keypad .btn:active {
z-index: 1;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.actions .btn {
flex: 1 1 50%;
}
#Calculation {
min-height: 1.5rem;
}
@@media (max-height: 700px) {
.store-header {
display: none !important;
}
}
@@media (max-width: 575px) {
.public-page-wrap {
padding-right: 0;
padding-left: 0;
}
.keypad {
margin-left: -1px;
margin-right: -1px;
}
.store-footer {
display: none !important;
}
}
/* fix sticky hover effect on mobile browsers */
@@media (hover: none) {
.keypad .btn-secondary:hover,
.actions .btn-secondary:hover {
border-color: var(--btcpay-secondary-border-active) !important;
}
}
</style>
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/light-pos/app.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/keypad.js" asp-append-version="true"></script>
}
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (Context.Request.Query.ContainsKey("simple"))

View file

@ -1,6 +1,6 @@
<div class="container p-0 l-pos-wrapper my-0 mx-auto">
<div class="py-5 px-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" step="@Model.Step" name="amount" placeholder="Amount">

View file

@ -19,15 +19,7 @@
}
}
@section PageHeadContent {
<style>
/* This hides unwanted metadata such as url, date, etc from appearing on a printed page. */
@@media print {
@@page {
margin-top: 0;
margin-bottom: 0;
}
}
</style>
<link href="~/pos/print.css" asp-append-version="true" rel="stylesheet" />
}
@if (supported is null)
@ -50,14 +42,14 @@ else
<a asp-route-viewType="static" class="alert-link">Regular version</a>
</div>
}
<div class="container public-page-wrap flex-column">
<div id="PosPrint" class="public-page-wrap container-xl">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
}
<main class="flex-grow-1 justify-content-center align-self-center mx-auto py-3">
<main>
@if (supported is not null)
{
if (Model.ShowCustomAmount)
@ -75,37 +67,45 @@ else
}
}
<div class="card-deck mx-auto">
<div class="posItems">
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
<div class="card" data-id="@x">
<div class="card-body my-auto">
<h4 class="card-title text-center">@Safe.Raw(item.Title)</h4>
@if (!string.IsNullOrEmpty(item.Description))
{
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
}
<div class="w-100 mb-3 fs-5 text-center">
@{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
}
@switch (item.PriceType)
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
<div class="d-flex flex-wrap">
<div class="card px-0" data-id="@x">
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
<span class="fw-semibold">
@switch (item.PriceType)
{
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();
}
</span>
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-light">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
@if (item.Inventory is null or > 0)
{
if (supported != null)
{
@ -116,7 +116,7 @@ else
ItemCode = item.Id
}, Context.Request.Scheme, Context.Request.Host.ToString()));
var lnUrl = LNURL.EncodeUri(lnurlEndpoint, "payRequest", supported.UseBech32Scheme);
<a href="@lnUrl" rel="noreferrer noopener" class="d-block mx-auto text-center">
<a href="@lnUrl" rel="noreferrer noopener" class="card-img-bottom">
<vc:qr-code data="@lnUrl.ToString().ToUpperInvariant()" />
</a>
}
@ -126,7 +126,7 @@ else
}
</div>
</main>
<footer class="store-footer">
<footer class="store-footer d-print-none">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>

View file

@ -1,93 +1,107 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
}
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
<style>
.card:only-of-type {
max-width: 320px;
margin: auto !important;
}
</style>
<div class="container public-page-wrap flex-column">
<partial name="_StatusMessage" />
<div id="PosStatic" class="public-page-wrap container-xl">
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
}
<main class="flex-grow-1 justify-content-center align-self-center text-center mx-auto py-3">
<div class="card-deck mx-auto">
<main>
<partial name="_StatusMessage" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead">@Safe.Raw(Model.Description)</div>
}
<div class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="PosItems">
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
<div class="card px-0" data-id="@x">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
@{CardBody(item.Title, item.Description);}
<div class="card-footer bg-transparent border-0 pb-3">
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
<div class="col">
<div class="card h-100 px-0" data-id="@x">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy autocomplete="off">
<input type="hidden" name="choiceKey" value="@item.Id" />
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.PriceType, item.Price.Value, item.Price.Value);}
</form>
}
else
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@Safe.Raw(buttonText)
</button>
</form>
}
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
{
<span>@item.Inventory left</span>
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
else
{
<span>Sold out</span>
<span class="fw-semibold">@Safe.Raw(formatted)</span>
}
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-warning">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
}
else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
@if (inStock)
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input type="hidden" name="choiceKey" value="@item.Id" />
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
{
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@item.Price" required>
</div>
}
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(buttonText)</button>
</form>
}
</div>
</div>
</div>
}
@if (Model.ShowCustomAmount)
{
<div class="card px-0">
@{CardBody("Custom Amount", "Create invoice to pay custom amount");}
<div class="card-footer bg-transparent border-0 pb-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.ItemPriceType.Minimum);}
</form>
@if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
<div class="col">
<div class="card h-100 px-0">
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title">Custom Amount</h5>
<p class="card-text">Create invoice to pay custom amount</p>
</div>
<div class="card-footer bg-transparent border-0 pb-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Amount" required>
</div>
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(Model.CustomButtonText ?? Model.ButtonText)</button>
</form>
</div>
</div>
</div>
}
@ -99,36 +113,3 @@
</a>
</footer>
</div>
@functions {
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
{
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
{
<div class="input-group">
<input class="form-control" type="text" readonly value="Free"/>
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}
else
{
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)">
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}
}
private void CardBody(string title, string description)
{
<div class="card-body my-auto pb-0">
<h5 class="card-title">@Safe.Raw(title)</h5>
@if (!string.IsNullOrWhiteSpace(description))
{
<p class="card-text">@Safe.Raw(description)</p>
}
</div>
}
}

View file

@ -1,22 +1,22 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<form id="PosKeypad" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<input id="posdata" type="hidden" name="posdata" v-model="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{srvModel.currencyCode}}</div>
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation" v-if="srvModel.showDiscount || srvModel.enableTips">{{ calculation }}</div>
<div class="text-muted text-center mt-2" id="Calculation" v-if="showDiscount || enableTips">{{ calculation }}</div>
</div>
<div id="ModeTabs" class="tab-content mb-n2" v-if="srvModel.showDiscount || srvModel.enableTips">
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="srvModel.showDiscount">
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="showDiscount">
<div class="h4 fw-semibold text-muted text-center" id="Discount">
<span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
</div>
</div>
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="srvModel.enableTips">
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="enableTips">
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
<template v-if="srvModel.customTipPercentages">
<template v-if="customTipPercentages">
<button
id="Tip-Custom"
type="button"
@ -27,7 +27,7 @@
<template v-else>Custom</template>
</button>
<button
v-for="percentage in srvModel.customTipPercentages"
v-for="percentage in customTipPercentages"
type="button"
class="btcpay-pill"
:class="{ active: tipPercent == percentage }"

View file

@ -3,8 +3,9 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@using System.IO
@using BTCPayServer.Services
@inject IWebHostEnvironment WebHostEnvironment
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
@inject BTCPayServerEnvironment Env
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
@ -38,19 +39,7 @@
<link rel="apple-touch-icon" href="~/img/icons/icon-512x512.png">
<link rel="apple-touch-startup-image" href="~/img/splash.png">
<link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
<style>
.lead :last-child {
margin-bottom: 0;
}
.card-deck {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 1.5rem;
}
.card {
page-break-inside: avoid;
}
</style>
<link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
@await RenderSectionAsync("PageHeadContent", false)
</head>
<body class="min-vh-100">

View file

@ -365,7 +365,7 @@
show(discounts);
show(description);
show(buttonPriceText);
show(customPayments);
hide(customPayments);
break;
case 'Light':
show(tips);

View file

@ -23,8 +23,7 @@
{
<th class="w-150px">@key</th>
}
<td style="white-space:pre-wrap">
@if (IsValidURL(str))
<td style="white-space:pre-wrap">@* Explicitely remove whitespace at front here *@@if (IsValidURL(str))
{
<a href="@str" target="_blank" rel="noreferrer noopener">@str</a>
}

View file

@ -100,7 +100,7 @@
<span class="text-danger" v-pre>@error.ErrorMessage</span>
}
}
<div class="bg-light card">
<div class="bg-tile card">
<div class="card-body " v-bind:class="{ 'card-deck': config.length > 0}">
<div v-if="!config || config.length === 0" class="col-12 text-center">
No items.<br />
@ -108,7 +108,7 @@
Add your first item
</button>
</div>
<div v-else v-for="(item, index) of config" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
<div v-else v-for="(item, index) of config" class="card my-2 template-item me-0 ms-0" v-bind:key="item.id">
<div class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
<div class="card-body">
<h6 class="card-title" v-html="item.title"></h6>

View file

@ -13,7 +13,7 @@
<meta name="robots" content="noindex,nofollow">
</head>
<body class="min-vh-100">
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
@if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
{

View file

@ -42,7 +42,7 @@
</style>
</head>
<body class="min-vh-100">
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<main class="flex-grow-1">
<div class="container" style="max-width:720px;">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })"/>

View file

@ -26,7 +26,7 @@
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">minutes</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
@ -34,7 +34,7 @@
<div class="form-group">
<label asp-for="CancelPayoutAfterFailures" class="form-label">Max Payout Failure Attempts</label>
<div class="input-group">
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">attempts</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>

View file

@ -26,7 +26,7 @@
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">minutes</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
@ -34,7 +34,7 @@
<div class="form-group">
<label asp-for="FeeTargetBlock" class="form-label" data-required>Fee block target</label>
<div class="input-group">
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:10ch;">
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">blocks</span>
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>
@ -42,7 +42,7 @@
<div class="form-group">
<label asp-for="Threshold" class="form-label" data-required>Threshold</label>
<div class="input-group">
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:10ch;">
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">@cryptoCode</span>
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>

View file

@ -111,7 +111,7 @@
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>

View file

@ -105,7 +105,7 @@
<div class="form-group">
<label asp-for="DisplayExpirationTimer" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:10ch;" />
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:12ch;" />
<span class="input-group-text">minutes</span>
</div>
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>

View file

@ -145,7 +145,7 @@
<vc:icon symbol="info"/>
</a>
<div class="input-group">
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">minutes</span>
</div>
<span asp-validation-for="InvoiceExpiration" class="text-danger"></span>
@ -156,7 +156,7 @@
<vc:icon symbol="info"/>
</a>
<div class="input-group">
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:10ch;"/>
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">percent</span>
</div>
<span asp-validation-for="PaymentTolerance" class="text-danger"></span>
@ -164,7 +164,7 @@
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>

View file

@ -38,7 +38,7 @@
</div>
<div class="form-group">
<label for="accountNumber" class="form-label">Account</label>
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:10ch;" />
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:12ch;" />
</div>
</div>
<div>

View file

@ -1,160 +0,0 @@
.logo {
height: 40px;
}
.logo-brand-text {
fill: currentColor;
}
.modal-content {
border-radius: 0.5rem;
}
.modal-header {
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
}
.card-img-top {
width: 100%;
max-height: 210px;
object-fit: scale-down;
}
.cart-item-image {
width: 50px;
height: 50px;
object-fit: scale-down;
}
.js-cart-added {
background-color: rgba(0, 0, 0, 0.7);
border-radius: 0.25rem;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
}
.js-cart-added .fa {
height: 50px;
position: relative;
top: 50%;
margin-top: -25px;
}
.js-add-cart:hover {
cursor: pointer;
}
.js-cart-tip-btn:focus {
background-color: #dee2e6;
}
.js-search-reset {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
z-index: 1049;
display: none;
}
/* ---------------------------------------------------
SIDEBAR STYLE
----------------------------------------------------- */
.wrapper {
display: flex;
width: 100%;
}
#sidebar {
position: fixed;
width: 400px;
display: flex;
flex-direction: column;
top: 0;
right: 0;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 999;
color: var(--btcpay-white);
background: var(--btcpay-bg-dark);
transition: all 0.3s;
padding-bottom: var(--btcpay-space-l);
-webkit-overflow-scrolling: touch;
}
#sidebar .js-cart {
display: none;
}
#sidebar #js-cart-list,
#sidebar #js-cart-extra {
border-radius: 0;
}
#sidebar.active {
margin-right: -400px;
}
/* ---------------------------------------------------
CONTENT STYLE
----------------------------------------------------- */
#content {
width: calc(100% - 400px);
min-height: 100vh;
transition: all 0.3s;
position: absolute;
top: 0;
left: 0;
}
#content.active {
width: 100%;
}
.bg-gray {
background-color: #aaa;
}
.text-black {
color: #000;
}
/* ---------------------------------------------------
MEDIAQUERIES
----------------------------------------------------- */
@media (max-width: 768px) {
#sidebar {
margin-right: -400px;
}
#sidebar .js-cart {
display: inline;
}
#sidebar.active {
margin-right: 0;
}
#content {
width: 100%;
}
#content.active {
width: calc(100% - 400px);
}
#sidebarCollapse span {
display: none;
}
}
@media (max-width: 575px) {
#sidebar {
width: 100%;
margin-right: -575px;
}
#content.active {
width: 100%;
}
}

View file

@ -1,167 +0,0 @@
$.fn.addAnimate = function(completeCallback) {
if ($(this).find('.js-cart-added').length === 0) {
$(this).append('<div class="js-cart-added"><i class="fa fa-check fa-3x text-white align-middle"></i></div>');
// Animate the element
$(this).find('.js-cart-added').fadeIn(200, function(){
var self = this;
// Show it for 200ms
setTimeout(function(){
// Hide and remove
$(self).fadeOut(100, function(){
$(this).remove();
completeCallback && completeCallback();
})
}, 200);
});
}
}
function removeAccents(input){
var accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇČçčÐĎďÌÍÎÏìíîïĽľÙÚÛÜùúûüÑŇñňŠšŤťŸÿýŽž ́',
accentsOut = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCCccDDdIIIIiiiiLlUUUUuuuuNNnnSsTtYyyZz ',
output = '',
index = -1;
for( var i = 0; i < input.length; i++ ) {
index = accents.indexOf(input[i]);
if( index != -1 ) {
output += typeof accentsOut[index] != 'undefined' ? accentsOut[index] : '';
}
else {
output += typeof input[i] != 'undefined' ? input[i] : '';
}
}
return output;
}
jQuery.expr[':'].icontains = function (a, i, m) {
var string = removeAccents(jQuery(a).text().toLowerCase());
return string.indexOf(removeAccents(m[3].toLowerCase())) >= 0;
};
$(document).ready(function(){
var cart = new Cart();
$('.js-add-cart').click(function(event){
event.preventDefault();
var $btn = $(event.target),
self = this;
index = $btn.closest('.card').data('index'),
item = srvModel.items[index],
items = cart.items;
// Is event catching disabled?
if (!$(this).hasClass('disabled')) {
// Disable catching events for this element
$(this).addClass('disabled');
// Add-to-cart animation only once
$(this).addAnimate(function(){
// Enable the event
$(self).removeClass('disabled');
});
cart.addItem({
id: item.id,
title: item.title,
price: item.price,
image: typeof item.image != 'undefined' ? item.image : null,
inventory: item.inventory
});
cart.listItems();
}
});
// Destroy the cart when the "pay button is clicked"
$('#js-cart-pay').click(function(){
cart.destroy(true);
});
// Disable pay button and add loading animation when pay form is submitted
$('#js-cart-pay-form').on('submit', function() {
var button = $('#js-cart-pay');
if (button) {
// Disable the pay button
button.attr('disabled', true);
// Add loading animation to the pay button
button.prepend([
'<div class="spinner-grow spinner-grow-sm align-baseline" role="status">',
' <span class="visually-hidden">Loading...</span>',
'</div>'
].join(''));
}
});
$('.js-cart').on('click', function () {
$('#sidebar, #content').toggleClass('active');
$('.collapse.in').toggleClass('in');
$('a[aria-expanded=true]').attr('aria-expanded', 'false');
});
$('.js-search').keyup(function(event){
var str = $(this).val();
$('#js-pos-list').find(".card-wrapper").show();
if (str.length > 1) {
var $list = $('#js-pos-list').find(".card-title:not(:icontains('" + $.escapeSelector(str) + "'))");
$list.parents('.card-wrapper').hide();
$('.js-search-reset').show();
} else if (str.length === 0) {
$('.js-search-reset').hide();
}
});
$('.js-search-reset').click(function(event){
event.preventDefault();
$('.js-search').val('');
$('.js-search').trigger('keyup');
$(this).hide();
});
$('#js-cart-summary').find('tbody').prepend(cart.template($('#template-cart-tip'), {
'tip': cart.fromCents(cart.getTip()) || ''
}));
$('#cartModal').one('show.bs.modal', function () {
cart.updateDiscount();
cart.updateTip();
cart.updateSummaryProducts();
cart.updateSummaryTotal();
var $tipInput = $('.js-cart-tip');
$tipInput[0].addEventListener('input', function(e) {
var value = parseFloat(e.target.value)
if (Number.isNaN(value) || value < 0) {
e.target.value = '';
return;
}
});
// Change total when tip is changed
$tipInput.inputAmount(cart, 'tip');
// Remove tip
$('.js-cart-tip-remove').removeAmount(cart, 'tip');
$('.js-cart-tip-btn').click(function(event){
event.preventDefault();
var $tip = $('.js-cart-tip'),
discount = cart.percentage(cart.getTotalProducts(), cart.getDiscount());
var purchaseAmount = cart.getTotalProducts() - discount;
var tipPercentage = parseInt($(this).data('tip'));
var tipValue = cart.percentage(purchaseAmount, tipPercentage).toFixed(srvModel.currencyInfo.divisibility);
$tip.val(tipValue);
$tip.trigger('input');
});
});
});

View file

@ -1,765 +0,0 @@
function Cart() {
this.items = 0;
this.totalAmount = 0;
this.content = [];
this.loadLocalStorage();
this.buildUI();
this.$list = $('#js-cart-list');
this.$items = $('#js-cart-items');
this.$total = $('.js-cart-total');
this.$summaryProducts = $('.js-cart-summary-products');
this.$summaryDiscount = $('.js-cart-summary-discount');
this.$summaryTotal = $('.js-cart-summary-total');
this.$summaryTip = $('.js-cart-summary-tip');
this.$destroy = $('.js-cart-destroy');
this.$confirm = $('#js-cart-confirm');
this.$categories = $('.js-categories');
this.listItems();
this.bindEmptyCart();
this.updateItemsCount();
this.updateAmount();
this.updatePosData();
}
Cart.prototype.setCustomAmount = function(amount) {
if (!srvModel.showCustomAmount) {
return 0;
}
this.customAmount = this.toNumber(amount);
if (this.customAmount > 0) {
localStorage.setItem(this.getStorageKey('cartCustomAmount'), this.customAmount);
} else {
localStorage.removeItem(this.getStorageKey('cartCustomAmount'));
}
return this.customAmount;
}
Cart.prototype.getCustomAmount = function() {
if (!srvModel.showCustomAmount) {
return 0;
}
return this.toCents(this.customAmount);
}
Cart.prototype.setTip = function(amount) {
if (!srvModel.enableTips) {
return 0;
}
this.tip = this.toNumber(amount);
if (this.tip > 0) {
localStorage.setItem(this.getStorageKey('cartTip'), this.tip);
} else {
localStorage.removeItem(this.getStorageKey('cartTip'));
}
return this.tip;
}
Cart.prototype.getTip = function() {
if (!srvModel.enableTips) {
return 0;
}
return this.toCents(this.tip);
}
Cart.prototype.setDiscount = function(amount) {
if (!srvModel.showDiscount) {
return 0;
}
this.discount = this.toNumber(amount);
if (this.discount > 0) {
localStorage.setItem(this.getStorageKey('cartDiscount'), this.discount);
} else {
localStorage.removeItem(this.getStorageKey('cartDiscount'));
}
return this.discount;
}
Cart.prototype.getDiscount = function() {
if (!srvModel.showDiscount) {
return 0;
}
return this.toCents(this.discount);
}
Cart.prototype.getDiscountAmount = function(amount) {
if (!srvModel.showDiscount) {
return 0;
}
return this.percentage(amount, this.getDiscount());
}
// Get total amount of products
Cart.prototype.getTotalProducts = function() {
var amount = 0 ;
// Always calculate the total amount based on the cart content
for (var key in this.content) {
if (
this.content.hasOwnProperty(key) &&
typeof this.content[key] != 'undefined' &&
!this.content[key].disabled
) {
const price = this.toCents(this.content[key].price ||0);
amount += (this.content[key].count * price);
}
}
// Add custom amount
amount += this.getCustomAmount();
return amount;
}
// Get absolute total amount
Cart.prototype.getTotal = function(includeTip) {
this.totalAmount = this.getTotalProducts();
if (this.getDiscount() > 0) {
this.totalAmount -= this.getDiscountAmount(this.totalAmount);
}
if (includeTip) {
this.totalAmount += this.getTip();
}
return this.fromCents(this.totalAmount);
}
/*
* Data manipulation
*/
// Add item to the cart or update its count
Cart.prototype.addItem = function(item) {
var id = item.id,
result = this.content.filter(function(obj){
return obj.id === id;
});
// Add new item because it doesn't exist yet
if (!result.length) {
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image, inventory: item.inventory});
this.emptyCartToggle();
}
// Increment item count
this.incrementItem(id);
}
Cart.prototype.incrementItem = function(id) {
var oldItemsCount = this.items;
this.items = 0; // Calculate total # of items from scratch just to make sure
var result = true;
for (var i = 0; i < this.content.length; i++) {
var obj = this.content[i];
if (obj.id === id){
if(obj.inventory != null && obj.inventory <= obj.count){
result = false;
continue;
}
obj.count++;
delete(obj.disabled);
}
// Increment the total # of items
this.items += obj.count;
}
if(!result){
this.items = oldItemsCount;
}
this.updateAll();
return result;
}
// Disable cart item so it doesn't count towards total amount
Cart.prototype.disableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
obj.disabled = true;
self.items -= obj.count;
}
});
this.updateAll();
}
// Enable cart item so it counts towards total amount
Cart.prototype.enableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
delete(obj.disabled);
self.items += obj.count;
}
});
this.updateAll();
}
Cart.prototype.decrementItem = function(id) {
var self = this;
this.items = 0; // Calculate total # of items from scratch just to make sure
this.content.filter(function(obj, index, arr){
// Decrement the item count
if (obj.id === id)
{
obj.count--;
delete(obj.disabled);
// It's the last item with the same ID, remove it
if (obj.count <= 0) {
self.removeItem(id, index, arr);
}
}
self.items += obj.count;
});
this.updateAll();
}
Cart.prototype.removeItemAll = function(id) {
var self = this;
this.items = 0;
// Remove by item
if (typeof id != 'undefined') {
this.content.filter(function(obj, index, arr){
if (obj.id === id) {
self.removeItem(id, index, arr);
for (var i = 0; i < obj.count; i++) {
self.items--;
}
}
self.items += obj.count;
});
} else { // Remove all
this.$list.find('tbody').empty();
this.content = [];
}
this.emptyCartToggle();
this.updateAll();
}
Cart.prototype.removeItem = function(id, index, arr) {
// Remove from the array
arr.splice(index, 1);
// Remove from the DOM
this.$list.find('tr').eq(index+1).remove();
}
/*
* Update DOM
*/
// Update all data elements
Cart.prototype.updateAll = function() {
this.saveLocalStorage();
this.updateItemsCount();
this.updateDiscount();
this.updateSummaryProducts();
this.updateSummaryTotal();
this.updateTotal();
this.updateAmount();
this.updatePosData();
}
// Update number of cart items
Cart.prototype.updateItemsCount = function() {
this.$items.text(this.items);
}
// Update total products (including the custom amount and discount) in the cart
Cart.prototype.updateTotal = function() {
this.$total.text(this.formatCurrency(this.getTotal()));
}
// Update total amount in the summary
Cart.prototype.updateSummaryTotal = function() {
this.$summaryTotal.text(this.formatCurrency(this.getTotal(true)));
}
// Update total products amount in the summary
Cart.prototype.updateSummaryProducts = function() {
this.$summaryProducts.text(this.formatCurrency(this.fromCents(this.getTotalProducts())));
}
// Update discount amount in the summary
Cart.prototype.updateDiscount = function(amount) {
var discount = 0;
if (typeof amount != 'undefined') {
discount = amount;
} else {
discount = this.percentage(this.getTotalProducts(), this.getDiscount());
discount = this.fromCents(discount);
}
this.$summaryDiscount.text((discount > 0 ? '-' : '') + this.formatCurrency(discount));
}
// Update tip amount in the summary
Cart.prototype.updateTip = function(amount) {
var tip = typeof amount != 'undefined' ? amount : this.fromCents(this.getTip());
this.$summaryTip.text(this.formatCurrency(tip));
}
// Update hidden total amount value to be sent to the checkout page
Cart.prototype.updateAmount = function() {
$('#js-cart-amount').val(this.getTotal(true));
$('#js-cart-tip').val(this.tip);
$('#js-cart-discount').val(this.discount);
$('#js-cart-custom-amount').val(this.customAmount);
}
Cart.prototype.updatePosData = function() {
var result = {
cart: this.content,
customAmount: this.fromCents(this.getCustomAmount()),
discountPercentage: this.discount? parseFloat(this.discount): 0,
subTotal: this.fromCents(this.getTotalProducts()),
discountAmount: this.fromCents(this.getDiscountAmount(this.totalAmount)),
tip: this.tip? this.tip: 0,
total: this.getTotal(true)
};
$('#js-cart-posdata').val(JSON.stringify(result));
}
Cart.prototype.resetDiscount = function() {
this.setDiscount(0);
this.updateDiscount(0);
$('.js-cart-discount').val('');
}
Cart.prototype.resetTip = function() {
this.setTip(0);
this.updateTip(0);
$('.js-cart-tip').val('');
}
Cart.prototype.resetCustomAmount = function() {
this.setCustomAmount(0);
$('.js-cart-custom-amount').val('');
}
// Escape html characters
Cart.prototype.escape = function(input) {
return ('' + input) /* Forces the conversion to string. */
.replace(/&/g, '&amp;') /* This MUST be the 1st replacement. */
.replace(/'/g, '&apos;') /* The 4 other predefined entities, required. */
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
;
}
// Load the template
Cart.prototype.template = function($template, obj) {
var template = $template.text();
for (var key in obj) {
var re = new RegExp('{' + key + '}', 'mg');
template = template.replace(re, obj[key]);
}
return template;
}
// Build the cart skeleton
Cart.prototype.buildUI = function() {
var $table = $('#js-cart-extra').find('thead'),
list = [];
tableTemplate = this.template($('#template-cart-extra'), {
'discount': this.escape(this.fromCents(this.getDiscount()) || ''),
'customAmount': this.escape(this.fromCents(this.getCustomAmount()) || '')
});
list.push($(tableTemplate));
tableTemplate = this.template($('#template-cart-total'), {
'total': this.escape(this.formatCurrency(this.getTotal()))
});
list.push($(tableTemplate));
// Add the list to DOM
$table.append(list);
// Change total when discount is changed
$('.js-cart-discount').inputAmount(this, 'discount');
// Remove discount
$('.js-cart-discount-remove').removeAmount(this, 'discount');
// Change total when discount is changed
$('.js-cart-custom-amount').inputAmount(this, 'customAmount');
// Remove discount
$('.js-cart-custom-amount-remove').removeAmount(this, 'customAmount');
}
// List cart items and bind their events
Cart.prototype.listItems = function() {
var $table = this.$list.find('tbody'),
self = this,
list = [],
tableTemplate = '';
this.$categories.on('change', function (event) {
if ($(this).is(':checked')) {
var selectedCategory = $(this).val();
$(".js-add-cart").each(function () {
var categories = JSON.parse(this.getAttribute("data-categories"));
if (selectedCategory === "*" || categories.includes(selectedCategory))
this.classList.remove("d-none");
else
this.classList.add("d-none");
});
}
});
if (this.content.length > 0) {
// Prepare the list of items in the cart
for (var key in this.content) {
var item = this.content[key],
image = item.image && this.escape(item.image);
if (image && image.startsWith("~")) {
image = image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps')));
}
tableTemplate = this.template($('#template-cart-item'), {
'id': this.escape(item.id),
'image': image ? this.template($('#template-cart-item-image'), {
'image' : image
}) : '',
'title': this.escape(item.title),
'count': this.escape(item.count),
'inventory': this.escape(item.inventory < 0? 99999: item.inventory),
'price': this.escape(item.price || 0)
});
list.push($(tableTemplate));
}
// Add the list to DOM
$table.html(list);
list = [];
// Update the cart when number of items is changed
$('.js-cart-item-count').off().on('input', function(event){
var _this = this,
id = $(this).closest('tr').data('id'),
qty = parseInt($(this).val()),
isQty = !isNaN(qty),
prevQty = parseInt($(this).data('prev')),
qtyDiff = Math.abs(qty - prevQty),
qtyIncreased = qty > prevQty;
if (isQty) {
$(this).data('prev', qty);
} else {
// User hasn't inputed any quantity
qty = null;
}
self.resetTip();
// Quantity was increased
if (qtyIncreased) {
var item = self.content.filter(function(obj){
return obj.id === id;
});
// Quantity may have been increased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.addItem({
id: id,
title: item.title,
price: item.price,
image: item.image
});
}
} else if (!qtyIncreased) { // Quantity decreased
// No quantity set (e.g. empty string)
if (!isQty) {
// Disable the item so it doesn't count towards total amount
self.disableItem(id);
} else {
// Quantity vas decreased
if (qtyDiff > 0) {
// Quantity may have been decreased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.decrementItem(id);
}
} else {
// Quantity hasn't changed, enable the item so it counts towards the total amount
self.enableItem(id);
}
}
}
});
// Remove item from the cart
$('.js-cart-item-remove').off().on('click', function(event){
event.preventDefault();
self.resetTip();
self.removeItemAll($(this).closest('tr').data('id'));
});
// Increment item
$('.js-cart-item-plus').off().on('click', function(event){
event.preventDefault();
if(self.incrementItem($(this).closest('tr').data('id'))){
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val);
$val.data('prev', val);
self.resetTip();
}
});
// Decrement item
$('.js-cart-item-minus').off().on('click', function(event){
event.preventDefault();
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
id = $(this).closest('tr').data('id'),
val = parseInt($val.val() || $val.data('prev')) - 1;
self.resetTip();
if (val === 0) {
self.removeItemAll(id);
} else {
$val.val(val);
$val.data('prev', val);
self.decrementItem(id);
}
});
}
}
Cart.prototype.bindEmptyCart = function() {
var self = this;
this.emptyCartToggle();
this.$destroy.click(function(event){
event.preventDefault();
self.destroy();
self.emptyCartToggle();
});
}
Cart.prototype.emptyCartToggle = function() {
if (this.content.length > 0 || this.getCustomAmount()) {
this.$destroy.show();
this.$confirm.removeAttr('disabled');
} else {
this.$destroy.hide();
this.$confirm.attr('disabled', 'disabled');
}
}
/*
* Currencies and numbers
*/
Cart.prototype.formatCurrency = function(amount) {
var amt = '',
thousandsSep = '',
decimalSep = ''
prefix = '',
postfix = '';
if (srvModel.currencyInfo.prefixed) {
prefix = srvModel.currencyInfo.currencySymbol;
if (srvModel.currencyInfo.symbolSpace) {
prefix = prefix + ' ';
}
}
else {
postfix = srvModel.currencyInfo.currencySymbol;
if (srvModel.currencyInfo.symbolSpace) {
postfix = ' ' + postfix;
}
}
thousandsSep = srvModel.currencyInfo.thousandSeparator;
decimalSep = srvModel.currencyInfo.decimalSeparator;
amt = amount.toFixed(srvModel.currencyInfo.divisibility);
// Add currency sign and thousands separator
var splittedAmount = amt.split('.');
amt = (splittedAmount[0] + '.').replace(/(\d)(?=(\d{3})+\.)/g, '$1' + thousandsSep);
amt = amt.substr(0, amt.length - 1);
if(splittedAmount.length == 2) {
amt = amt + decimalSep + splittedAmount[1];
}
if (srvModel.currencyInfo.divisibility !== 0) {
amt[amt.length - srvModel.currencyInfo.divisibility - 1] = decimalSep;
}
amt = prefix + amt + postfix;
return amt;
}
Cart.prototype.toNumber = function(num) {
return (num * 1) || 0;
}
Cart.prototype.toCents = function(num) {
return num * Math.pow(10, srvModel.currencyInfo.divisibility);
}
Cart.prototype.fromCents = function(num) {
return num / Math.pow(10, srvModel.currencyInfo.divisibility);
}
Cart.prototype.percentage = function(amount, percentage) {
return this.fromCents((amount / 100) * percentage);
}
/*
* Storage
*/
Cart.prototype.getStorageKey = function (name) {
return (name + srvModel.appId + srvModel.currencyCode);
}
Cart.prototype.saveLocalStorage = function() {
localStorage.setItem(this.getStorageKey('cart'), JSON.stringify(this.content));
}
Cart.prototype.loadLocalStorage = function() {
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
var self = this;
// Get number of cart items
for (var i = this.content.length-1; i >= 0; i--) {
if (!this.content[i]) {
this.content.splice(i,1);
continue;
}
//check if the pos items still has the cached cart items
var matchedItem = srvModel.items.find(function(item){
return item.id === self.content[i].id;
});
if(!matchedItem){
//remove if no longer available
this.content.splice(i,1);
continue;
}else{
if(matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
this.content.splice(i,1);
}else if(matchedItem.inventory != null && matchedItem.inventory < this.content[i].count){
//not enough stock for original cart amount, reduce to available stock
this.content[i].count = matchedItem.inventory;
}
//update its stock
this.content[i].inventory = matchedItem.inventory;
}
this.items += this.content[i].count;
// Delete the disabled flag if any
delete(this.content[i].disabled);
}
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));
this.customAmount = localStorage.getItem(this.getStorageKey('cartCustomAmount'));
this.tip = localStorage.getItem(this.getStorageKey('cartTip'));
}
Cart.prototype.destroy = function(keepAmount) {
this.resetDiscount();
this.resetTip();
this.resetCustomAmount();
// When form is sent
if (keepAmount) {
this.content = [];
this.items = 0;
} else {
this.removeItemAll();
}
localStorage.removeItem(this.getStorageKey('cart'));
}
/*
* jQuery helpers
*/
$.fn.inputAmount = function(obj, type) {
$(this).off().on('input', function(event){
var val = obj.toNumber($(this).val());
switch (type) {
case 'customAmount':
obj.setCustomAmount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'discount':
obj.setDiscount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'tip':
obj.setTip(val);
obj.updateTip();
break;
}
obj.updateSummaryTotal();
obj.updateAmount();
obj.updatePosData();
obj.emptyCartToggle();
});
}
$.fn.removeAmount = function(obj, type) {
$(this).off().on('click', function(event){
event.preventDefault();
switch (type) {
case 'customAmount':
obj.resetCustomAmount();
obj.updateSummaryProducts();
break;
case 'discount':
obj.resetDiscount();
obj.updateSummaryProducts();
break;
}
obj.resetTip();
obj.updateTotal();
obj.updateSummaryTotal();
obj.emptyCartToggle();
});
}

View file

@ -8,7 +8,6 @@ body {
overflow-x: hidden;
}
.public-page-wrap {
flex-direction: column;
max-width: var(--wrap-max-width);
}
main {

View file

@ -29,6 +29,7 @@
<symbol id="lightningterminal" viewBox="0 0 28 55" fill="none"><g fill="currentColor"><path d="m27.25 30.5-15.9 23.2a.84.84 0 1 1-1.38-.96l15.9-23.19a.84.84 0 1 1 1.38.96zm-2.09-4.13L9.63 49.08a.84.84 0 0 1-1.39-.95l15.54-22.71a.84.84 0 0 1 1.38.95zm-4.72-24.8L2.43 27.9h16.9l-1.14 1.68H.36a.84.84 0 0 1-.22-1.15L19 .62A.84.84 0 0 1 20.16.4c.4.26.52.78.28 1.19z"/><path d="M22.12 6.62 10.24 23.99H22l-1.15 1.68H7.05l1.14-1.68 12.53-18.3a.84.84 0 0 1 1.39.93z"/></g></symbol>
<symbol id="manage-plugins" viewBox="0 0 24 24" fill="none"><path d="M6 7H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 17H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="mattermost" viewBox="0 0 206 206" fill="none"><path fill="currentColor" d="m163.012 19.596 1.082 21.794c17.667 19.519 24.641 47.161 15.846 73.14-13.129 38.782-56.419 59.169-96.693 45.535-40.272-13.633-62.278-56.124-49.15-94.905 8.825-26.066 31.275-43.822 57.276-48.524L105.422.038C61.592-1.15 20.242 26.056 5.448 69.76c-18.178 53.697 10.616 111.963 64.314 130.142 53.698 18.178 111.964-10.617 130.143-64.315 14.77-43.633-1.474-90.283-36.893-115.99"/><path fill="currentColor" d="m137.097 53.436-.596-17.531-.404-15.189s.084-7.322-.17-9.043a2.776 2.776 0 0 0-.305-.914l-.05-.109-.06-.094a2.378 2.378 0 0 0-1.293-1.07 2.382 2.382 0 0 0-1.714.078l-.033.014-.18.092a2.821 2.821 0 0 0-.75.518c-1.25 1.212-5.63 7.08-5.63 7.08l-9.547 11.82-11.123 13.563-19.098 23.75s-8.763 10.938-6.827 24.4c1.937 13.464 11.946 20.022 19.71 22.65 7.765 2.63 19.7 3.5 29.417-6.019 9.716-9.518 9.397-23.53 9.397-23.53l-.744-30.466z"/></symbol>
<symbol id="minus" viewBox="0 0 16 16" fill="none"><path d="M14 7H2V9H14V7Z" fill="currentColor"/></symbol>
<symbol id="new-store" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="new-wallet" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="new" viewBox="0 0 24 24" fill="none"><path d="M17 11H13V7C13 6.45 12.55 6 12 6C11.45 6 11 6.45 11 7V11H7C6.45 11 6 11.45 6 12C6 12.55 6.45 13 7 13H11V17C11 17.55 11.45 18 12 18C12.55 18 13 17.55 13 17V13H17C17.55 13 18 12.55 18 12C18 11.45 17.55 11 17 11Z" fill="currentColor"/></symbol>
@ -46,6 +47,7 @@
<symbol id="payment-sent" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="M24 16v16m5.71-10.29L24 16l-6.29 6.29" stroke="currentColor" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payouts" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="plugin" viewBox="0 0 24 24" fill="none"><path d="M12.0002 10.2354L4.73633 7.38747M12.0002 10.2354L19.2642 7.38747M12.0002 10.2354V19.5M5.21166 7.01614L11.2783 4.6375C11.7412 4.45417 12.2566 4.45417 12.7196 4.6375L18.7862 7.01614C19.0023 7.1083 19.1858 7.26312 19.3131 7.46062C19.4404 7.65812 19.5055 7.88923 19.5002 8.12413V15.876C19.5058 16.1106 19.441 16.3415 19.3142 16.539C19.1874 16.7365 19.0045 16.8915 18.7888 16.984L12.7222 19.3633C12.259 19.5453 11.7441 19.5453 11.2809 19.3633L5.21433 16.984C4.9982 16.8919 4.81466 16.737 4.68739 16.5395C4.56012 16.342 4.49496 16.1109 4.50033 15.876V8.12413C4.49475 7.88953 4.55951 7.65864 4.68628 7.46117C4.81305 7.26371 4.99603 7.10871 5.21166 7.01614Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="plus" viewBox="0 0 16 16" fill="none"><path d="M14 7.18182H8.81818V2H7.18182V7.18182H2V8.81818H7.18182V14H8.81818V8.81818H14V7.18182Z" fill="currentColor"/></symbol>
<symbol id="pointofsale" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.4303H7.12002C6.79748 19.4322 6.47817 19.3659 6.18309 19.2356C5.88802 19.1054 5.62385 18.9141 5.40795 18.6745C5.19206 18.4349 5.02933 18.1522 4.93046 17.8452C4.83159 17.5382 4.79882 17.2137 4.83431 16.8931L5.60002 10.1617C5.6311 9.88087 5.76509 9.62152 5.97615 9.43368C6.1872 9.24584 6.46035 9.14284 6.74288 9.14455H17.2572C17.5397 9.14284 17.8129 9.24584 18.0239 9.43368C18.235 9.62152 18.369 9.88087 18.4 10.1617L19.1429 16.8931C19.1782 17.2118 19.146 17.5343 19.0485 17.8398C18.951 18.1452 18.7903 18.4267 18.5769 18.666C18.3634 18.9053 18.1021 19.097 17.8097 19.2286C17.5174 19.3603 17.2006 19.429 16.88 19.4303Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.42859 9.14369C7.42859 7.93128 7.91022 6.76852 8.76753 5.91121C9.62484 5.0539 10.7876 4.57227 12 4.57227C13.2124 4.57227 14.3752 5.0539 15.2325 5.91121C16.0898 6.76852 16.5714 7.93128 16.5714 9.14369" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.14282 12.5723H14.8571" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-cart" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.426H7.12a2.286 2.286 0 0 1-2.286-2.537l.766-6.731A1.143 1.143 0 0 1 6.743 9.14h10.514a1.143 1.143 0 0 1 1.143 1.017l.743 6.731a2.286 2.286 0 0 1-2.263 2.537Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.43 9.142a4.571 4.571 0 1 1 9.143 0M9.14 12.57h5.715" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-light" viewBox="0 0 24 24" fill="none"><path d="M8 4h8c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4H8c-2.2 0-4-1.8-4-4V8c0-2.2 1.8-4 4-4Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8 13h8M8 16.25h8" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><rect x="7" y="7" width="10" height="3.5" rx="1" fill="currentColor"/></symbol>

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -1,201 +0,0 @@
document.addEventListener("DOMContentLoaded",function () {
const displayFontSize = 64;
new Vue({
el: '#app',
data () {
return {
srvModel: window.srvModel,
mode: 'amount',
amount: null,
tip: null,
tipPercent: null,
discount: null,
discountPercent: null,
fontSize: displayFontSize,
defaultFontSize: displayFontSize,
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', 'del'],
payButtonLoading: false
}
},
computed: {
modes () {
const modes = [{ title: 'Amount', type: 'amount' }]
if (this.srvModel.showDiscount) modes.push({ title: 'Discount', type: 'discount' })
if (this.srvModel.enableTips) modes.push({ title: 'Tip', type: 'tip'})
return modes
},
keypadTarget () {
switch (this.mode) {
case 'amount':
return 'amount';
case 'discount':
return 'discountPercent';
case 'tip':
return 'tip';
}
},
calculation () {
if (!this.tipNumeric && !this.discountNumeric) return null
let calc = this.formatCurrency(this.amountNumeric, true)
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
return calc
},
amountNumeric () {
const value = parseFloat(this.amount)
return isNaN(value) ? 0.0 : value
},
discountPercentNumeric () {
const value = parseFloat(this.discountPercent)
return isNaN(value) ? 0.0 : value;
},
discountNumeric () {
return this.amountNumeric && this.discountPercentNumeric
? this.amountNumeric * (this.discountPercentNumeric / 100)
: 0.0;
},
amountMinusDiscountNumeric () {
return this.amountNumeric - this.discountNumeric;
},
tipNumeric () {
if (this.tipPercent) {
return this.amountMinusDiscountNumeric * (this.tipPercent / 100);
} else {
const value = parseFloat(this.tip)
return isNaN(value) ? 0.0 : value;
}
},
total () {
return (this.amountNumeric - this.discountNumeric + this.tipNumeric);
},
totalNumeric () {
return parseFloat(this.total);
},
posdata () {
const data = {
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
discountPercent (val) {
const value = parseFloat(val)
if (isNaN(value)) this.discountPercent = null
else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString();
},
tip (val) {
this.tipPercent = null;
},
total () {
// This must be timed out because the updated width is not available yet
this.$nextTick(function () {
const displayWidth = this.getWidth(this.$refs.display),
amountWidth = this.getWidth(this.$refs.amount),
gamma = displayWidth / amountWidth || 0,
isAmountWider = displayWidth < amountWidth;
if (isAmountWider) {
// Font size will get smaller
this.fontSize = Math.floor(this.fontSize * gamma);
} else if (!isAmountWider && this.fontSize < this.defaultFontSize) {
// Font size will get larger up to the max size
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
}
});
}
},
methods: {
getWidth (el) {
const styles = window.getComputedStyle(el),
width = parseFloat(el.clientWidth),
padL = parseFloat(styles.paddingLeft),
padR = parseFloat(styles.paddingRight);
return width - padL - padR;
},
clear () {
this.amount = this.tip = this.discount = this.tipPercent = this.discountPercent = null;
this.mode = 'amount';
},
handleFormSubmit () {
this.payButtonLoading = true;
},
unsetPayButtonLoading () {
this.payButtonLoading = false;
},
formatCrypto (value, withSymbol) {
const symbol = withSymbol ? ` ${this.srvModel.currencySymbol || this.srvModel.currencyCode}` : '';
const divisibility = this.srvModel.currencyInfo.divisibility;
return parseFloat(value).toFixed(divisibility) + symbol;
},
formatCurrency (value, withSymbol) {
const currency = this.srvModel.currencyCode;
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
const divisibility = this.srvModel.currencyInfo.divisibility;
const locale = this.getLocale(currency);
const style = withSymbol ? 'currency' : 'decimal';
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
try {
return new Intl.NumberFormat(locale, opts).format(value);
} catch (err) {
return this.formatCrypto(value, withSymbol);
}
},
applyKeyToValue (key, value) {
if (!value) value = '';
if (key === 'del') {
value = value.substring(0, value.length - 1);
value = value === '' ? '0' : value;
} else if (key === '.') {
// Only add decimal point if it doesn't exist yet
if (value.indexOf('.') === -1) {
value += key;
}
} else { // Is a digit
if (!value || value === '0') {
value = '';
}
value += key;
const { divisibility } = this.srvModel.currencyInfo;
const decimalIndex = value.indexOf('.')
if (decimalIndex !== -1 && (value.length - decimalIndex - 1 > divisibility)) {
value = value.replace('.', '');
value = value.substr(0, value.length - divisibility) + '.' +
value.substr(value.length - divisibility);
}
}
return value;
},
keyPressed (key) {
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget]);
},
tipPercentage (percentage) {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
},
getLocale(currency) {
switch (currency) {
case 'USD': return 'en-US';
case 'EUR': return 'de-DE';
case 'JPY': return 'ja-JP';
default: return navigator.language;
}
}
},
created () {
/** We need to unset state in case user clicks the browser back button */
window.addEventListener('pagehide', this.unsetPayButtonLoading);
},
destroyed () {
window.removeEventListener('pagehide', this.unsetPayButtonLoading);
}
});
});

View file

@ -396,20 +396,13 @@
background: var(--btcpay-nav-bg-active);
}
/* Sticky Header: The <div class="sticky-header-setup"></div> needs to be included once
before the first sticky-header on the page. The sticky-header has a padding-top so
that it does not scroll underneath the fixed header on mobile. The sticky-header-setup
negates that padding with a negative margin, so that everything fits in the end. */
.sticky-header-setup {
margin-top: calc(var(--content-padding-top) * -1);
}
.sticky-header {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 1020;
z-index: 1021;
background: var(--btcpay-body-bg);
margin-top: calc(var(--content-padding-top) * -1);
/* pull it out of the content padding and adjust its inner padding to make up for that space */
margin-left: calc(var(--content-padding-horizontal) * -1);
margin-right: calc(var(--content-padding-horizontal) * -1);
@ -502,15 +495,11 @@
transform: none;
}
.offcanvas-backdrop {
#mainMenu .offcanvas-backdrop {
top: var(--mobile-header-height);
transition-duration: var(--btcpay-transition-duration-fast);
}
.offcanvas-backdrop.show {
opacity: 0.8;
}
#StoreSelector {
margin: 0 auto;
max-width: 60vw;
@ -655,7 +644,7 @@
}
#mainMenuToggle,
.offcanvas-backdrop {
#mainMenu .offcanvas-backdrop {
display: none !important;
}
@ -677,6 +666,11 @@
max-width: none;
}
#mainContent > section .w-100-fixed {
/* constrains the content to respect the maximum width and enable responsive tables */
width: calc(100vw - var(--sidebar-width) - var(--content-padding-horizontal) * 2);
}
#SectionNav .nav {
margin-top: calc(var(--btcpay-space-m) * -1);
}

View file

@ -255,6 +255,14 @@ h2 svg.icon.icon-info {
.toasted-container {
display: none !important;
}
.truncate-center a,
.truncate-center button,
.truncate-center-truncated {
display: none;
}
.card {
page-break-inside: avoid;
}
#markStatusDropdownMenuButton {
border: 0;
background: transparent;
@ -650,6 +658,7 @@ input:checked + label.btcpay-list-select-item {
/* Public pages */
.public-page-wrap {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin: 0 auto;
padding: var(--btcpay-space-l) var(--btcpay-space-m);
@ -1011,14 +1020,6 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
}
}
@media print {
.truncate-center a,
.truncate-center button,
.truncate-center-truncated {
display: none;
}
}
/* Copy */
[data-clipboard],
[data-clipboard] input[readonly] {

View file

@ -153,7 +153,7 @@ const initLabelManagers = () => {
document.addEventListener("DOMContentLoaded", () => {
// sticky header
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
const stickyHeader = document.querySelector('#mainContent > section > .sticky-header');
if (stickyHeader) {
document.documentElement.style.scrollPaddingTop = `calc(${stickyHeader.offsetHeight}px + var(--btcpay-space-m))`;
}

View file

@ -37,11 +37,10 @@
--btcpay-warning-text-hover: var(--btcpay-neutral-100);
--btcpay-warning-text-active: var(--btcpay-neutral-100);
--btcpay-warning-dim-text: var(--btcpay-neutral-200);
--btcpay-light-accent: var(--btcpay-black);
--btcpay-light-dim-bg: var(--btcpay-neutral-50);
--btcpay-light-shadow: rgba(66, 70, 73, 0.33);
--btcpay-light-rgb: 33, 38, 45;
--btcpay-dark-accent: var(--btcpay-neutral-400);
--btcpay-dark-accent: var(--btcpay-neutral-600);
--btcpay-dark-dim-bg: var(--btcpay-white);
--btcpay-dark-shadow: rgba(211, 212, 213, 0.33);
--btcpay-dark-rgb: 201, 209, 217;

View file

@ -414,7 +414,7 @@
--btcpay-light-rgb: 233, 236, 239;
--btcpay-dark: var(--btcpay-neutral-800);
--btcpay-dark-accent: var(--btcpay-black);
--btcpay-dark-accent: var(--btcpay-neutral-900);
--btcpay-dark-text: var(--btcpay-neutral-200);
--btcpay-dark-text-hover: var(--btcpay-neutral-200);
--btcpay-dark-text-active: var(--btcpay-neutral-200);

View file

@ -0,0 +1,145 @@
#PosCart {
--sidebar-width: 480px;
}
#PosCart .public-page-wrap {
padding: 0 0 var(--btcpay-space-l);
}
#PosCart .offcanvas-backdrop {
top: var(--mobile-header-height);
transition-duration: var(--btcpay-transition-duration-fast);
}
.cart-toggle-btn {
--button-width: 40px;
--button-height: 40px;
--button-padding: 7px;
--icon-size: 1rem;
position: absolute;
top: calc(50% - var(--button-height) / 2);
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--button-width);
height: var(--button-height);
padding: var(--button-padding);
background: transparent;
border: none;
cursor: pointer;
outline: none;
}
header .cart-toggle-btn {
--icon-size: 32px;
}
.cart-toggle-btn .icon-pos-cart {
width: var(--icon-size);
height: var(--icon-size);
color: var(--btcpay-header-link);
}
.cart-toggle-btn:disabled svg {
color: var(--btcpay-body-text-muted);
}
.cart-toggle-btn:not(:disabled):hover svg {
color: var(--btcpay-header-link-accent);
}
#SearchTerm {
max-width: 47em;
margin: 0 auto;
}
#cart {
position: fixed;
top: 0;
bottom: 0;
right: 0;
z-index: 1045;
height: 100vh;
overflow-y: auto;
color: var(--btcpay-body-text);
background-color: var(--btcpay-bg-tile);
}
#cart .quantity .btn {
width: 2rem;
height: 2rem;
}
#cart .quantity .btn .icon{
--btn-icon-size: .75rem;
}
#CartBadge {
position: absolute;
top: 0;
right: 0;
min-width: 1.75em;
}
.card-img-top {
max-height: 210px;
object-fit: scale-down;
}
.posItem {
position: relative;
}
.posItem.posItem--inStock {
cursor: pointer;
}
.posItem-added {
display: flex;
align-items: center;
justify-content: center;
background: var(--btcpay-success);
color: var(--btcpay-success-text);
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--btcpay-transition-duration-default) ease-in-out;
}
.posItem-added .icon {
width: 2rem;
height: 2rem;
}
.posItem--added {
pointer-events: none;
}
.posItem--added .posItem-added {
opacity: .8;
}
@media (max-width: 991px) {
#cart {
left: 0;
transform: translateX(100%);
transition: transform var(--btcpay-transition-duration-fast) ease-in-out;
}
#cart.show {
transform: none;
}
#CartClose {
color: var(--btcpay-body-text);
}
}
@media (min-width: 992px) {
#content {
margin-right: var(--sidebar-width);
}
#cart {
width: var(--sidebar-width);
border-left: 1px solid var(--btcpay-body-border-light);
}
.cart-toggle-btn {
display: none;
}
}

View file

@ -0,0 +1,179 @@
document.addEventListener("DOMContentLoaded",function () {
function storageKey(name) {
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
}
function saveState(name, data) {
localStorage.setItem(storageKey(name), JSON.stringify(data));
}
function loadState(name) {
const data = localStorage.getItem(storageKey(name))
if (!data) return []
const cart = JSON.parse(data);
for (let i = cart.length-1; i >= 0; i--) {
if (!cart[i]) {
cart.splice(i, 1);
continue;
}
//check if the pos items still has the cached cart items
const matchedItem = srvModel.items.find(item => item.id === cart[i].id);
if (!matchedItem){
cart.splice(i, 1);
} else {
if (matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
cart.splice(i, 1);
} else if (matchedItem.inventory != null && matchedItem.inventory < cart[i].count){
//not enough stock for original cart amount, reduce to available stock
cart[i].count = matchedItem.inventory;
//update its stock
cart[i].inventory = matchedItem.inventory;
}
}
}
return cart;
}
const POS_ITEM_ADDED_CLASS = 'posItem--added';
new Vue({
el: '#PosCart',
mixins: [posCommon],
data () {
return {
displayCategory: '*',
searchTerm: null,
cart: loadState('cart'),
$cart: null
}
},
computed: {
cartCount() {
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
},
amountNumeric () {
return parseFloat(this.cart.reduce((res, item) => res + item.price * item.count, 0).toFixed(this.currencyInfo.divisibility))
},
posdata () {
const data = {
cart: this.cart,
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
searchTerm(term) {
const t = term.toLowerCase();
this.forEachItem(item => {
const terms = item.dataset.search.toLowerCase()
const included = terms.indexOf(t) !== -1
item.classList[included ? 'remove' : 'add']("d-none")
})
},
displayCategory(category) {
this.forEachItem(item => {
const categories = JSON.parse(item.dataset.categories)
const included = category === "*" || categories.includes(category)
item.classList[included ? 'remove' : 'add']("d-none")
})
},
cart: {
handler(newCart) {
newCart.forEach(item => {
if (!item.count) item.count = 1
if (item.inventory && item.inventory < item.count) item.count = item.inventory
})
saveState('cart', newCart)
if (!newCart || newCart.length === 0) {
this.$cart.hide()
}
},
deep: true
}
},
methods: {
toggleCart() {
this.$cart.toggle()
},
forEachItem(callback) {
this.$refs.posItems.querySelectorAll('.posItem').forEach(callback)
},
inStock(index) {
const item = this.items[index]
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
return item.inventory == null || item.inventory > (itemInCart ? itemInCart.count : 0)
},
inventoryText(index) {
const item = this.items[index]
if (item.inventory == null) return null
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
const left = item.inventory - (itemInCart ? itemInCart.count : 0)
return left > 0 ? `${item.inventory} left` : 'Sold out'
},
addToCart(index) {
if (!this.inStock(index)) return false;
const item = this.items[index];
let itemInCart = this.cart.find(lineItem => lineItem.id === item.id);
// Add new item because it doesn't exist yet
if (!itemInCart) {
itemInCart = {
id: item.id,
title: item.title,
price: item.price,
inventory: item.inventory,
count: 0
}
this.cart.push(itemInCart);
}
itemInCart.count += 1;
// Animate
const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index];
if(!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
return true;
},
removeFromCart(id) {
const index = this.cart.findIndex(lineItem => lineItem.id === id);
this.cart.splice(index, 1);
},
updateQuantity(id, count) {
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
const applyable = (count < 0 && itemInCart.count + count > 0) ||
(count > 0 && (itemInCart.inventory == null || itemInCart.count + count <= itemInCart.inventory));
if (applyable) {
itemInCart.count += count;
}
},
clearCart() {
this.cart = [];
}
},
mounted() {
this.$cart = new bootstrap.Offcanvas(this.$refs.cart, {backdrop: false})
window.addEventListener('pagehide', () => {
if (this.payButtonLoading) {
this.unsetPayButtonLoading();
localStorage.removeItem(storageKey('cart'));
}
})
this.forEachItem(item => {
item.addEventListener('transitionend', () => {
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
item.classList.remove(POS_ITEM_ADDED_CLASS);
}
});
})
},
});
});

View file

@ -0,0 +1,8 @@
.lead {
max-width: 36em;
text-align: center;
margin: 0 auto 2.5rem;
}
.lead :last-child {
margin-bottom: 0;
}

View file

@ -0,0 +1,113 @@
const posCommon = {
data () {
return {
...srvModel,
amount: null,
tip: null,
tipPercent: null,
discount: null,
discountPercent: null,
payButtonLoading: false
}
},
computed: {
amountNumeric () {
const value = parseFloat(this.amount)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
},
discountPercentNumeric () {
const value = parseFloat(this.discountPercent)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
},
discountNumeric () {
return this.amountNumeric && this.discountPercentNumeric
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
: 0.0;
},
amountMinusDiscountNumeric () {
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
},
tipNumeric () {
if (this.tipPercent) {
return parseFloat((this.amountMinusDiscountNumeric * (this.tipPercent / 100)).toFixed(this.currencyInfo.divisibility))
} else {
const value = parseFloat(this.tip)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
}
},
total () {
return this.amountNumeric - this.discountNumeric + this.tipNumeric
},
totalNumeric () {
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
},
calculation () {
if (!this.tipNumeric && !this.discountNumeric) return null
let calc = this.formatCurrency(this.amountNumeric, true)
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
return calc
},
posdata () {
const data = {
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
discountPercent (val) {
const value = parseFloat(val)
if (isNaN(value)) this.discountPercent = null
else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString()
},
tip (val) {
this.tipPercent = null
}
},
methods: {
handleFormSubmit() {
this.payButtonLoading = true;
},
getLocale(currency) {
switch (currency) {
case 'USD': return 'en-US'
case 'EUR': return 'de-DE'
case 'JPY': return 'ja-JP'
default: return navigator.language
}
},
tipPercentage (percentage) {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
},
unsetPayButtonLoading () {
this.payButtonLoading = false
},
formatCrypto (value, withSymbol) {
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
const { divisibility } = this.currencyInfo
return parseFloat(value).toFixed(divisibility) + symbol
},
formatCurrency (value, withSymbol) {
const currency = this.currencyCode
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol)
const { divisibility } = this.currencyInfo
const locale = this.getLocale(currency);
const style = withSymbol ? 'currency' : 'decimal'
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility }
try {
return new Intl.NumberFormat(locale, opts).format(value)
} catch (err) {
return this.formatCrypto(value, withSymbol)
}
},
}
}

View file

@ -0,0 +1,97 @@
.public-page-wrap {
max-width: 560px;
overflow: hidden;
}
/* modes */
#ModeTabs {
min-height: 2.75rem;
}
/* keypad */
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.keypad .btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
border-radius: 0;
font-weight: var(--btcpay-font-weight-semibold);
font-size: 24px;
min-height: 3.5rem;
height: 8vh;
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="del"] svg {
--btn-icon-size: 2.25rem;
transform: rotate(180deg);
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
padding-left: 1rem;
padding-right: 1rem;
}
/* make borders collapse by shifting rows and columns by 1px */
/* second column */
.keypad .btn:nth-child(3n-1) {
margin-left: -1px;
}
/* third column */
.keypad .btn:nth-child(3n) {
margin-left: -1px;
}
/* from second row downwards */
.keypad .btn:nth-child(n+4) {
margin-top: -1px;
}
/* ensure highlighted button is topmost */
.keypad .btn:hover,
.keypad .btn:focus,
.keypad .btn:active {
z-index: 1;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.actions .btn {
flex: 1 1 50%;
}
#Calculation {
min-height: 1.5rem;
}
@media (max-height: 700px) {
.store-header {
display: none !important;
}
}
@media (max-width: 575px) {
.public-page-wrap {
padding-right: 0;
padding-left: 0;
}
.keypad {
margin-left: -1px;
margin-right: -1px;
}
.store-footer {
display: none !important;
}
}
/* fix sticky hover effect on mobile browsers */
@media (hover: none) {
.keypad .btn-secondary:hover,
.actions .btn-secondary:hover {
border-color: var(--btcpay-secondary-border-active) !important;
}
}

View file

@ -0,0 +1,101 @@
document.addEventListener("DOMContentLoaded",function () {
const displayFontSize = 64;
new Vue({
el: '#PosKeypad',
mixins: [posCommon],
data () {
return {
mode: 'amount',
fontSize: displayFontSize,
defaultFontSize: displayFontSize,
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', 'del']
}
},
computed: {
modes () {
const modes = [{ title: 'Amount', type: 'amount' }]
if (this.showDiscount) modes.push({ title: 'Discount', type: 'discount' })
if (this.enableTips) modes.push({ title: 'Tip', type: 'tip'})
return modes
},
keypadTarget () {
switch (this.mode) {
case 'amount':
return 'amount';
case 'discount':
return 'discountPercent';
case 'tip':
return 'tip';
}
}
},
watch: {
total () {
// This must be timed out because the updated width is not available yet
this.$nextTick(function () {
const displayWidth = this.getWidth(this.$refs.display),
amountWidth = this.getWidth(this.$refs.amount),
gamma = displayWidth / amountWidth || 0,
isAmountWider = displayWidth < amountWidth;
if (isAmountWider) {
// Font size will get smaller
this.fontSize = Math.floor(this.fontSize * gamma);
} else if (!isAmountWider && this.fontSize < this.defaultFontSize) {
// Font size will get larger up to the max size
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
}
});
}
},
methods: {
getWidth (el) {
const styles = window.getComputedStyle(el),
width = parseFloat(el.clientWidth),
padL = parseFloat(styles.paddingLeft),
padR = parseFloat(styles.paddingRight);
return width - padL - padR;
},
clear () {
this.amount = this.tip = this.discount = this.tipPercent = this.discountPercent = null;
this.mode = 'amount';
},
applyKeyToValue (key, value) {
if (!value) value = '';
if (key === 'del') {
value = value.substring(0, value.length - 1);
value = value === '' ? '0' : value;
} else if (key === '.') {
// Only add decimal point if it doesn't exist yet
if (value.indexOf('.') === -1) {
value += key;
}
} else { // Is a digit
if (!value || value === '0') {
value = '';
}
value += key;
const { divisibility } = this.currencyInfo;
const decimalIndex = value.indexOf('.')
if (decimalIndex !== -1 && (value.length - decimalIndex - 1 > divisibility)) {
value = value.replace('.', '');
value = value.substr(0, value.length - divisibility) + '.' +
value.substr(value.length - divisibility);
}
}
return value;
},
keyPressed (key) {
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget]);
}
},
created() {
/** We need to unset state in case user clicks the browser back button */
window.addEventListener('pagehide', this.unsetPayButtonLoading)
},
destroyed() {
window.removeEventListener('pagehide', this.unsetPayButtonLoading)
},
});
});

View file

@ -0,0 +1,29 @@
/* This hides unwanted metadata such as url, date, etc from appearing on a printed page. */
@media print {
@page {
margin-top: .25em;
margin-bottom: .25em;
}
.card {
page-break-inside: avoid;
break-inside: avoid;
}
}
.posItems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(256px, 1fr));
grid-gap: 1.5rem;
}
.card {
page-break-inside: avoid;
}
.qr-code {
width: 100%;
min-width: auto !important;
min-height: auto !important;
border-bottom-right-radius: var(--btcpay-card-inner-border-radius);
border-bottom-left-radius: var(--btcpay-card-inner-border-radius);
}