POS Keypad: Add plus and change clear functionality (#5396)

Closes #5299.
This commit is contained in:
d11n 2023-11-02 20:03:34 +01:00 committed by GitHub
parent c16dfb2dcb
commit 696a414e95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 86 additions and 77 deletions

View file

@ -2217,7 +2217,7 @@ namespace BTCPayServer.Tests
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-amount")).Selected);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-amounts")).Selected);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
@ -2226,13 +2226,17 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector(".keypad [data-key='2']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='3']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='4']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='.']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
Assert.Equal("1.234,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
s.Driver.FindElement(By.CssSelector(".keypad [data-key='+']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='5']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='6']")).Click();
Assert.Equal("1.234,56", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.Equal("1.234,00 € + 0,56 €", s.Driver.FindElement(By.Id("Calculation")).Text);
// Discount: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-discount']")).Click();
@ -2240,14 +2244,14 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
Assert.Contains("1.111,10", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("10% discount", s.Driver.FindElement(By.Id("Discount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.Contains("1.234,00 € + 0,56 € - 123,46 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Tip: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-tip']")).Click();
s.Driver.WaitForElement(By.Id("Tip-Custom"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("1.222,21", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%) + 111,11 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.Contains("1.234,00 € + 0,56 € - 123,46 € (10%) + 111,11 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Pay
s.Driver.FindElement(By.Id("pay-button")).Click();

View file

@ -1,12 +1,12 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
<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">
<input type="hidden" name="posdata" v-model="posdata" id="posdata">
<input type="hidden" name="amount" v-model="totalNumeric">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<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="showDiscount || enableTips">{{ calculation }}</div>
<div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
</div>
<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">
@ -44,24 +44,17 @@
</div>
<div id="ModeTablist" class="nav btcpay-pills align-items-center justify-content-center mb-n2 pb-1" role="tablist" v-if="modes.length > 1">
<template v-for="m in modes" :key="m.value">
<input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amount' && amountNumeric == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
<input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amounts' && amountNumeric == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
<label :for="`ModeTablist-${m.type}`">{{ m.title }}</label>
</template>
</div>
<div class="keypad">
<button v-for="k in keys" :key="k" v-on:click.prevent="keyPressed(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">
<template v-if="k === 'del'"><vc:icon symbol="caret-right"/></template>
<template v-else>{{ k }}</template>
</button>
<button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">{{ k }}</button>
</div>
<div class="actions px-4 gap-4">
<button class="btn btn-lg btn-secondary" type="reset" v-on:click.prevent="clear">Clear</button>
<button class="btn btn-lg btn-primary" type="submit" :disabled="payButtonLoading" id="pay-button">
<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>Charge</template>
</button>
</div>
<input class="form-control" type="hidden" name="amount" v-model="totalNumeric">
<button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading" id="pay-button">
<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>Charge</template>
</button>
</form>

View file

@ -44,14 +44,6 @@ const posCommon = {
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,

View file

@ -27,9 +27,8 @@
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="del"] svg {
--btn-icon-size: 2.25rem;
transform: rotate(180deg);
.keypad .btn[data-key="+"] {
font-size: 2.25em;
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
@ -57,15 +56,6 @@
z-index: 1;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.actions .btn {
flex: 1 1 50%;
}
#Calculation {
min-height: 1.5rem;
}

View file

@ -5,28 +5,37 @@ document.addEventListener("DOMContentLoaded",function () {
mixins: [posCommon],
data () {
return {
mode: 'amount',
mode: 'amounts',
fontSize: displayFontSize,
defaultFontSize: displayFontSize,
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', 'del']
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '+'],
amounts: [null]
}
},
computed: {
modes () {
const modes = [{ title: 'Amount', type: 'amount' }]
const modes = [{ title: 'Amount', type: 'amounts' }]
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 'amounts':
return 'amounts';
case 'discount':
return 'discountPercent';
case 'tip':
return 'tip';
}
},
calculation () {
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2) return null
let calc = this.amounts.map(amt => this.formatCurrency(amt, true)).join(' + ')
if (this.discountNumeric > 0 || this.discountPercentNumeric > 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
}
},
watch: {
@ -46,47 +55,68 @@ document.addEventListener("DOMContentLoaded",function () {
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
}
});
},
amounts (values) {
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
}
},
methods: {
getWidth (el) {
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';
clear() {
this.amounts = [null];
this.tip = this.discount = this.tipPercent = this.discountPercent = null;
this.mode = 'amounts';
},
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;
applyKeyToValue(key, value, divisibility) {
if (!value || value === '0') value = '';
value = (value + key)
.replace('.', '')
.padStart(divisibility, '0')
.replace(new RegExp(`(\\d*)(\\d{${divisibility}})`), '$1.$2');
return parseFloat(value).toFixed(divisibility);
},
keyPressed (key) {
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget]);
if (this.keypadTarget === 'amounts') {
const lastIndex = this.amounts.length - 1;
const lastAmount = this.amounts[lastIndex];
if (key === 'C') {
if (!lastAmount && lastIndex === 0) {
// clear completely
this.clear();
} else if (!lastAmount) {
// remove latest value
this.amounts.pop();
} else {
// clear latest value
Vue.set(this.amounts, lastIndex, null);
}
} else if (key === '+' && parseFloat(lastAmount || '0')) {
this.amounts.push(null);
} else { // Is a digit
const { divisibility } = this.currencyInfo;
const value = this.applyKeyToValue(key, lastAmount, divisibility);
Vue.set(this.amounts, lastIndex, value);
}
} else {
if (key === 'C') {
this[this.keypadTarget] = null;
} else {
const divisibility = this.keypadTarget === 'tip' ? this.currencyInfo.divisibility : 0;
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget], divisibility);
}
}
},
doubleClick (key) {
if (key === 'C') {
// clear completely
this.clear();
}
}
},
created() {