diff --git a/static/components/payment-method-selector.js b/static/components/payment-method-selector.js
new file mode 100644
index 00000000..1fd611ee
--- /dev/null
+++ b/static/components/payment-method-selector.js
@@ -0,0 +1,116 @@
+window.app.component('tpos-payment-method-selector', {
+ name: 'tpos-payment-method-selector',
+ props: {
+ bitcoinSymbol: {type: String, required: true},
+ currency: {type: String, required: true},
+ currencySymbol: {type: String, required: true},
+ fiatProvider: {type: Boolean, default: false},
+ allowCashSettlement: {type: Boolean, default: false},
+ onchainEnabled: {type: Boolean, default: false},
+ tabsEnabled: {type: Boolean, default: false},
+ isSettlingTab: {type: Boolean, default: false},
+ drawer: {type: Boolean, default: false},
+ disabled: {type: Boolean, default: false}
+ },
+ emits: ['select'],
+ template: `
+
+ `
+})
diff --git a/static/js/tpos.js b/static/js/tpos.js
index de6783ff..e2778dd9 100644
--- a/static/js/tpos.js
+++ b/static/js/tpos.js
@@ -4,6 +4,11 @@ const {
roundTposCurrencyAmount
} = window.tposUtils
+const TILE_SIZE_DEFAULT = 150
+const TILE_SIZE_MIN = 80
+const TILE_SIZE_MAX = 240
+const TILE_SIZE_STEP = 10
+
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
@@ -80,7 +85,8 @@ window.app = Vue.createApp({
pendingTabSettlement: null,
cashValidating: false,
tipDialog: {
- show: false
+ show: false,
+ paymentMethod: null
},
urlDialog: {
show: false
@@ -91,6 +97,7 @@ window.app = Vue.createApp({
rounding: false,
isFullScreen: false,
isGridView: this.$q.screen.gt.sm,
+ tileSize: TILE_SIZE_DEFAULT,
moreBtn: false,
total: 0.0,
cartTax: 0.0,
@@ -311,8 +318,14 @@ window.app = Vue.createApp({
drawerWidth() {
return this.$q.screen.lt.sm ? 360 : 450
},
- drawerItemsHeight() {
- return `overflow-y: auto; height: ${this.$q.screen.gt.sm ? 'calc(100vh - 400px)' : 'calc(100vh - 465px)'}`
+ tileSizeStorageKey() {
+ return `lnbits.tpos.${this.tposId}.tileSize`
+ },
+ tileImageStyle() {
+ return {
+ height: `${Math.max(this.tileSize - 36, 32)}px`,
+ flex: '0 0 auto'
+ }
},
formattedCartTax() {
return this.formatAmount(this.cartTax, this.currency)
@@ -921,17 +934,19 @@ window.app = Vue.createApp({
}, 3000)
},
processTipSelection(selectedTipOption) {
+ const selectedPaymentMethod = this.tipDialog.paymentMethod
+ this.tipDialog.paymentMethod = null
this.tipDialog.show = false
if (!selectedTipOption) {
this.tipAmount = 0.0
- return this.showInvoice()
+ return this.showInvoice(selectedPaymentMethod)
}
this.tipAmount = roundTposCurrencyAmount(
(selectedTipOption / 100) * this.activePaymentAmount,
this.currency
)
- this.showInvoice()
+ this.showInvoice(selectedPaymentMethod)
},
resetPaymentAttempt() {
this.paymentAmount = null
@@ -939,7 +954,7 @@ window.app = Vue.createApp({
this.rounding = false
this.tipRounding = null
},
- submitForm() {
+ submitForm(selectedPaymentMethod = null) {
const paymentAmount =
this.total > 0.0
? roundTposCurrencyAmount(this.total + this.amount, this.currency)
@@ -949,6 +964,7 @@ window.app = Vue.createApp({
this.sat = Math.ceil(paymentAmount * this.exchangeRate)
if (!this.exchangeRate || this.exchangeRate == 0 || this.sat == 0) {
+ this.tipDialog.paymentMethod = null
Quasar.Notify.create({
type: 'negative',
message:
@@ -957,19 +973,24 @@ window.app = Vue.createApp({
return
}
+ this.tipDialog.paymentMethod = selectedPaymentMethod
if (this.tip_options && this.tip_options.length) {
this.rounding = false
this.tipRounding = null
this.showTipModal()
} else {
- this.showInvoice()
+ const method = this.tipDialog.paymentMethod
+ this.tipDialog.paymentMethod = null
+ this.showInvoice(method)
}
},
showTipModal() {
if (!this.atmMode) {
this.tipDialog.show = true
} else {
- this.showInvoice()
+ const method = this.tipDialog.paymentMethod
+ this.tipDialog.paymentMethod = null
+ this.showInvoice(method)
}
},
showPaymentMethod() {
@@ -981,30 +1002,47 @@ window.app = Vue.createApp({
selectPaymentMethod(method) {
this.currency_choice = false
if (this._currencyResolver) {
- switch (method) {
- case 'fiat_tap':
- this.fiatMethod = 'terminal'
- method = 'fiat'
- break
- case 'fiat':
- this.fiatMethod = 'checkout'
- break
- case 'cash':
- this.fiatMethod = 'cash'
- method = 'fiat'
- break
- case 'btc':
- case 'btc_onchain':
- this.fiatMethod = 'checkout'
- break
- case 'tab':
- this.fiatMethod = 'checkout'
- break
- }
- this._currencyResolver(method)
+ this._currencyResolver(this.normalizePaymentMethod(method))
this._currencyResolver = null
}
},
+ normalizePaymentMethod(method) {
+ switch (method) {
+ case 'fiat_tap':
+ this.fiatMethod = 'terminal'
+ return 'fiat'
+ case 'fiat':
+ this.fiatMethod = 'checkout'
+ return 'fiat'
+ case 'cash':
+ this.fiatMethod = 'cash'
+ return 'fiat'
+ case 'btc':
+ case 'btc_onchain':
+ case 'tab':
+ this.fiatMethod = 'checkout'
+ return method
+ default:
+ return method
+ }
+ },
+ normalizeTileSize(value) {
+ if (
+ value === null ||
+ value === undefined ||
+ (typeof value === 'string' && !value.trim())
+ ) {
+ return TILE_SIZE_DEFAULT
+ }
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed)) return TILE_SIZE_DEFAULT
+ const bounded = Math.min(TILE_SIZE_MAX, Math.max(TILE_SIZE_MIN, parsed))
+ return Math.round(bounded / TILE_SIZE_STEP) * TILE_SIZE_STEP
+ },
+ persistTileSize(value) {
+ this.tileSize = this.normalizeTileSize(value)
+ this.$q.localStorage.set(this.tileSizeStorageKey, this.tileSize)
+ },
normalizeApiAmount(currency, value) {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
@@ -1285,7 +1323,7 @@ window.app = Vue.createApp({
}
return params
},
- async showInvoice() {
+ async showInvoice(selectedPaymentMethod = null) {
if (this.atmMode) {
this.atmGetWithdraw()
return
@@ -1298,7 +1336,9 @@ window.app = Vue.createApp({
this.isSettlingTab ||
(this.tabsEnabled && !this.isSettlingTab)
) {
- const method = await this.showPaymentMethod()
+ const method = selectedPaymentMethod
+ ? this.normalizePaymentMethod(selectedPaymentMethod)
+ : await this.showPaymentMethod()
if (method === 'tab') {
await this.openTabChargeDialog()
return
@@ -1868,6 +1908,9 @@ window.app = Vue.createApp({
this.amountFormatted = this.formatAmount(this.amount, this.currency)
this.totalFormatted = this.formatAmount(this.total, this.currency)
this.tposId = tpos.id
+ this.tileSize = this.normalizeTileSize(
+ this.$q.localStorage.getItem(this.tileSizeStorageKey)
+ )
this.atmPremium = tpos.withdraw_premium / 100
this.withdrawMaximum = withdraw_maximum
this.pinDisabled = tpos.withdraw_pin_disabled
diff --git a/templates/tpos/_cart.html b/templates/tpos/_cart.html
index 89ce2234..5f2f7f7e 100644
--- a/templates/tpos/_cart.html
+++ b/templates/tpos/_cart.html
@@ -7,19 +7,21 @@
:width="drawerWidth"
:breakpoint="1024"
>
-
-
- Hide Cart
-
-
-
-
- sat
-
+
+
+
+ Hide Cart
+
+
+
+
+ sat
+
+
-
-
-
-
+
+
Subtotal
@@ -122,25 +122,42 @@
-
-
-
-
-
@@ -154,6 +171,18 @@
:icon="isGridView ? 'view_list' : 'grid_view'"
@click="isGridView = !isGridView"
>
+
:label="itemCartQty(item.id)"
floating
>
-
+
diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html
index 087c1cfb..e86a0c3a 100644
--- a/templates/tpos/dialogs.html
+++ b/templates/tpos/dialogs.html
@@ -65,7 +65,11 @@
@print-receipt="printReceipt"
>
-
+
Would you like to leave a tip?
@@ -324,113 +328,17 @@
Payment Method
-
+
diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html
index 0bb560a2..fe4254e2 100644
--- a/templates/tpos/tpos.html
+++ b/templates/tpos/tpos.html
@@ -245,6 +245,7 @@
+
diff --git a/tests/check_static_load.js b/tests/check_static_load.js
index 7e4dc512..eaeac866 100644
--- a/tests/check_static_load.js
+++ b/tests/check_static_load.js
@@ -37,6 +37,7 @@ const pages = [
'static/js/tpos.js',
'static/components/item-list.js',
'static/components/keypad.js',
+ 'static/components/payment-method-selector.js',
'static/components/payment-dialog.js',
'static/components/held-carts-dialog.js',
'static/components/print-dialog.js',
diff --git a/tests/e2e/tpos.spec.ts b/tests/e2e/tpos.spec.ts
index 9ed796bc..8f0ad576 100644
--- a/tests/e2e/tpos.spec.ts
+++ b/tests/e2e/tpos.spec.ts
@@ -66,29 +66,107 @@ test('public item checkout completes through Lightning with FakeWallet', async (
disabled: false
}
]),
- tip_options: '[]',
+ tip_options: '[10]',
enable_remote: false,
- tabs_enabled: false
+ tabs_enabled: true
})
await page.goto(`/tpos/${terminal.id}`)
const pos = page.locator('body')
+ const tile = pos.locator('div.flex.justify-center.gt-xs > div').first()
+ await expect(tile).toBeVisible()
+ await expect
+ .poll(() =>
+ tile.evaluate(element => {
+ const style = getComputedStyle(element)
+ return {height: style.height, width: style.width}
+ })
+ )
+ .toEqual({height: '150px', width: '150px'})
+ await page.evaluate(
+ key => window.localStorage.setItem(key, JSON.stringify('invalid')),
+ `lnbits.tpos.${terminal.id}.tileSize`
+ )
+ await page.reload()
+ const fallbackTile = pos
+ .locator('div.flex.justify-center.gt-xs > div')
+ .first()
+ await expect
+ .poll(() =>
+ fallbackTile.evaluate(element => getComputedStyle(element).width)
+ )
+ .toBe('150px')
+ const tileSizeSlider = pos.locator('[aria-label="Tile size"]')
+ await expect(tileSizeSlider).toBeVisible()
+ await tileSizeSlider.focus()
+ await tileSizeSlider.press('ArrowRight', {delay: 50})
+ await tileSizeSlider.press('ArrowRight', {delay: 50})
+ await tileSizeSlider.press('ArrowRight', {delay: 50})
+ await tileSizeSlider.press('ArrowRight', {delay: 50})
+ await tileSizeSlider.press('ArrowRight', {delay: 50})
+ await expect
+ .poll(() =>
+ tile.evaluate(element => {
+ const style = getComputedStyle(element)
+ return {height: style.height, width: style.width}
+ })
+ )
+ .toEqual({height: '200px', width: '200px'})
+ await expect
+ .poll(() =>
+ page.evaluate(key => {
+ const value = window.localStorage.getItem(key)
+ return value === null ? null : JSON.parse(value)
+ }, `lnbits.tpos.${terminal.id}.tileSize`)
+ )
+ .toBe(200)
+ await page.reload()
+ const restoredTile = pos
+ .locator('div.flex.justify-center.gt-xs > div')
+ .first()
+ await expect
+ .poll(() =>
+ restoredTile.evaluate(element => getComputedStyle(element).width)
+ )
+ .toBe('200px')
+ await page.setViewportSize({width: 1280, height: 600})
+ await page.reload()
const item = pos
.locator('.item-grid-title:visible')
.filter({hasText: itemName})
await expect(item).toBeVisible()
await item.click()
await expect(pos.getByText('Total', {exact: true}).last()).toBeVisible()
+ const clearCart = pos.getByRole('button', {name: /clear cart/i})
+ await expect(clearCart).toBeVisible()
+ const clearCartBox = await clearCart.boundingBox()
+ expect(clearCartBox).not.toBeNull()
+ expect(clearCartBox!.y).toBeGreaterThanOrEqual(420)
+ expect(clearCartBox!.y + clearCartBox!.height).toBeLessThanOrEqual(592)
+ await expect(pos.getByRole('button', {name: /^pay$/i})).toHaveCount(0)
+ await pos.getByRole('button', {name: /lightning network/i}).click()
+ const tipDialog = pos
+ .locator('.q-dialog')
+ .filter({hasText: 'Would you like to leave a tip?'})
+ .last()
+ await expect(tipDialog).toBeVisible()
+ await expect(
+ pos.locator('.q-dialog').filter({hasText: 'Payment Method'})
+ ).toHaveCount(0)
const invoiceResponse = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
response.url().includes(`/tpos/api/v1/tposs/${terminal.id}/invoices`)
)
- await pos.getByRole('button', {name: /^pay$/i}).click()
- const invoice = (await (await invoiceResponse).json()) as {
+ await tipDialog.getByRole('button', {name: /no, thanks/i}).click()
+ const invoiceResponseResult = await invoiceResponse
+ const invoice = (await invoiceResponseResult.json()) as {
bolt11?: string
}
expect(invoice.bolt11).toMatch(/^lnbc/i)
+ expect(invoiceResponseResult.request().postDataJSON()).toMatchObject({
+ payment_method: 'btc'
+ })
await payInvoice(page, customerWallet, invoice.bolt11 as string)
await expect(pos.getByText('Invoice Paid!', {exact: true})).toBeVisible({
timeout: 60_000
@@ -96,6 +174,49 @@ test('public item checkout completes through Lightning with FakeWallet', async (
await expect(
pos.locator('table tbody tr').filter({hasText: itemName})
).toHaveCount(0)
+
+ await page.setViewportSize({width: 800, height: 900})
+ await page.reload()
+ await pos.getByText(itemName, {exact: true}).last().click()
+ await pos.getByRole('button', {name: /checkout/i}).click()
+ await expect(pos.getByRole('button', {name: /^pay$/i})).toBeVisible()
+ await expect(
+ pos.getByRole('button', {name: /lightning network/i})
+ ).toHaveCount(0)
+ await pos.getByRole('button', {name: /^pay$/i}).click()
+ const paymentMethodDialog = pos
+ .locator('.q-dialog')
+ .filter({hasText: 'Payment Method'})
+ .last()
+ const compactTipDialog = pos
+ .locator('.q-dialog')
+ .filter({hasText: 'Would you like to leave a tip?'})
+ .last()
+ await expect(compactTipDialog).toBeVisible()
+ await expect(paymentMethodDialog).toHaveCount(0)
+ await compactTipDialog.getByRole('button', {name: /no, thanks/i}).click()
+ await expect(paymentMethodDialog).toBeVisible()
+ const compactInvoiceResponse = page.waitForResponse(
+ response =>
+ response.request().method() === 'POST' &&
+ response.url().includes(`/tpos/api/v1/tposs/${terminal.id}/invoices`)
+ )
+ await paymentMethodDialog
+ .getByRole('button', {name: /lightning network/i})
+ .click()
+ const compactInvoiceResponseResult = await compactInvoiceResponse
+ const compactInvoice = (await compactInvoiceResponseResult.json()) as {
+ bolt11?: string
+ }
+ expect(compactInvoice.bolt11).toMatch(/^lnbc/i)
+ expect(compactInvoiceResponseResult.request().postDataJSON()).toMatchObject({
+ payment_method: 'btc'
+ })
+
+ await page.setViewportSize({width: 500, height: 900})
+ await page.reload()
+ await expect(pos.locator('[aria-label="Tile size"]')).toHaveCount(0)
+ await expect(pos.getByText(itemName, {exact: true}).last()).toBeVisible()
})
test('held carts survive restore and can then be deleted', async ({