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: ` +
+
+ +
+ + Lightning +
+
+
+
+ +
+ + Onchain +
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ + Tab +
+
+
+
+ ` +}) 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 +
+
-
+
@@ -95,10 +97,8 @@
-
-
-
- +
+ 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
-
-
- -
- - Lightning -
-
-
-
- -
- - Onchain -
-
-
-
- -
- - -
-
-
-
- -
- - -
-
-
-
- -
- - -
-
-
-
- -
- - Tab -
-
-
-
+
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 ({