From e9be615c9aaa559fe01be0932fa3116770508ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Sep 2026 12:33:06 -0600 Subject: [PATCH 1/3] WIP Add payment methods to place details --- app/components/place-details.gjs | 3 + app/components/place-payment-methods.gjs | 91 ++++++++ app/icons/bitcoin.svg | 3 + app/styles/app.css | 51 ++++ app/utils/icons.js | 7 + app/utils/payment.js | 111 +++++++++ .../components/place-details-test.gjs | 37 +++ .../components/place-payment-methods-test.gjs | 111 +++++++++ tests/unit/utils/payment-test.js | 218 ++++++++++++++++++ 9 files changed, 632 insertions(+) create mode 100644 app/components/place-payment-methods.gjs create mode 100644 app/icons/bitcoin.svg create mode 100644 app/utils/payment.js create mode 100644 tests/integration/components/place-payment-methods-test.gjs create mode 100644 tests/unit/utils/payment-test.js diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 98cdb72..ef6e3da 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -15,6 +15,7 @@ import NostrConnect from './nostr-connect'; import Modal from './modal'; import PhotoCarousel from './photo-carousel'; import PhotoGallery from './photo-gallery'; +import PlacePaymentMethods from './place-payment-methods'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -467,6 +468,8 @@ export default class PlaceDetails extends Component { {{/if}} + +
{{#if this.cuisine}} diff --git a/app/components/place-payment-methods.gjs b/app/components/place-payment-methods.gjs new file mode 100644 index 0000000..b873aea --- /dev/null +++ b/app/components/place-payment-methods.gjs @@ -0,0 +1,91 @@ +import Component from '@glimmer/component'; +import Icon from './icon'; +import { parsePaymentMethods } from '../utils/payment'; +import { eq } from 'ember-truth-helpers'; + +export default class PlacePaymentMethods extends Component { + get payment() { + return parsePaymentMethods(this.args.tags); + } + + get showCash() { + return this.payment.cash !== null; + } + + get showCards() { + return this.payment.cards !== null; + } + + get showBitcoin() { + return this.payment.bitcoin.status !== null; + } + + get cashTitle() { + return this.payment.cash === 'accepted' ? 'Cash accepted' : 'No cash'; + } + + get cardsTitle() { + return this.payment.cards === 'accepted' ? 'Cards accepted' : 'No cards'; + } + + get bitcoinTitle() { + if (this.payment.bitcoin.status === 'denied') { + return 'No Bitcoin'; + } + if (this.payment.bitcoin.lightning) { + return 'Bitcoin (Lightning)'; + } + return 'Bitcoin (On-chain)'; + } + + +} diff --git a/app/icons/bitcoin.svg b/app/icons/bitcoin.svg new file mode 100644 index 0000000..4fa6f13 --- /dev/null +++ b/app/icons/bitcoin.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/styles/app.css b/app/styles/app.css index e69d4ed..28b2077 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -1175,6 +1175,57 @@ abbr[title] { margin-top: 1.5rem; } +.place-payment-methods { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1.2rem; + padding-top: 1.2rem; + border-top: 1px solid var(--divider-color); + color: var(--secondary-text-color); +} + +.payment-method { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--secondary-text-color); +} + +.payment-method.is-denied { + opacity: 0.5; +} + +.payment-method.is-denied::after { + content: ''; + position: absolute; + top: 50%; + left: -2px; + right: -2px; + height: 2px; + background-color: var(--danger-color); + transform: rotate(-45deg); + pointer-events: none; + border-radius: 1px; +} + +.payment-method-badge { + position: absolute; + bottom: -3px; + right: -5px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--primary-background-color); + border-radius: 50%; + width: 14px; + height: 14px; + box-shadow: 0 1px 2px rgb(0 0 0 / 15%); +} + .btn { /* TODO: remove this scoped rule in favor of a global box-sizing reset (e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top diff --git a/app/utils/icons.js b/app/utils/icons.js index 29b3368..7d3f25e 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -113,6 +113,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw'; import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw'; import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw'; import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw'; +import paymentCard from '@waysidemapping/pinhead/dist/icons/payment_card.svg?raw'; import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw'; import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw'; import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw'; @@ -148,6 +149,7 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome /* * Custom/local icons */ +import bitcoin from '../icons/bitcoin.svg?raw'; import loadingRing from '../icons/270-ring.svg?raw'; import nostrich from '../icons/nostrich-2.svg?raw'; import remotestorage from '../icons/remotestorage.svg?raw'; @@ -167,6 +169,7 @@ const ICONS = { 'badge-shield-with-fire': badgeShieldWithFire, 'beach-umbrella-in-ground': beachUmbrellaInGround, 'beer-mug-with-foam': beerMugWithFoam, + bitcoin, bookmark, 'boxing-glove-up': boxingGloveUp, 'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw, @@ -246,6 +249,7 @@ const ICONS = { nostrich, 'open-book': openBook, palace, + 'payment-card': paymentCard, 'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall, 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track': personBoardingTramWithDestinationDisplayAndPantographOnTramTrack, @@ -303,7 +307,10 @@ const ICONS = { }; const FILLED_ICONS = [ + 'banknote', + 'bitcoin', 'fork-and-knife', + 'payment-card', 'wikipedia', 'whatsapp', 'cup-and-saucer', diff --git a/app/utils/payment.js b/app/utils/payment.js new file mode 100644 index 0000000..7656a39 --- /dev/null +++ b/app/utils/payment.js @@ -0,0 +1,111 @@ +const CARD_KEYS = [ + 'payment:cards', + 'payment:credit_cards', + 'payment:debit_cards', + 'payment:contactless', + 'payment:visa', + 'payment:mastercard', + 'payment:american_express', + 'payment:amex', + 'payment:maestro', + 'payment:girocard', + 'payment:discover_card', + 'payment:diners_club', + 'payment:jcb', + 'payment:unionpay', + 'payment:bancontact', + 'payment:postfinance_card', + 'payment:mir', + 'payment:dankort', + 'payment:interac', + 'payment:visa_debit', + 'payment:mastercard_debit', + 'payment:apple_pay', + 'payment:google_pay', +]; + +function isYes(val) { + return val === 'yes' || val === 'only'; +} + +function isNo(val) { + return val === 'no'; +} + +export function parsePaymentMethods(tags = {}) { + const safeTags = tags || {}; + + // 1. Cash + let cash = null; + const cashVal = safeTags['payment:cash']; + const coinsVal = safeTags['payment:coins']; + const notesVal = safeTags['payment:notes']; + + if ( + isYes(cashVal) || + (!isNo(cashVal) && (isYes(coinsVal) || isYes(notesVal))) + ) { + cash = 'accepted'; + } else if ( + isNo(cashVal) || + (!isYes(cashVal) && isNo(coinsVal) && isNo(notesVal)) + ) { + cash = 'denied'; + } + + // 2. Cards + let cards = null; + const anyCardAccepted = CARD_KEYS.some((key) => isYes(safeTags[key])); + + if (anyCardAccepted) { + cards = 'accepted'; + } else { + const cardsNo = isNo(safeTags['payment:cards']); + const creditAndDebitNo = + isNo(safeTags['payment:credit_cards']) && + isNo(safeTags['payment:debit_cards']); + const cashOnly = safeTags['payment:cash'] === 'only'; + + if (cardsNo || creditAndDebitNo || cashOnly) { + cards = 'denied'; + } + } + + // 3. Bitcoin + const xbtVal = safeTags['currency:XBT']; + const lightningVal = safeTags['payment:lightning']; + const onchainVal = safeTags['payment:onchain']; + const btcVal = safeTags['payment:bitcoin']; + const btcCurrencyVal = safeTags['currency:BTC']; + + const isBtcAccepted = + isYes(xbtVal) || + isYes(lightningVal) || + isYes(onchainVal) || + isYes(btcVal) || + isYes(btcCurrencyVal); + + const isBtcDenied = !isBtcAccepted && (isNo(xbtVal) || isNo(btcVal)); + + const lightning = isYes(lightningVal); + const onchain = + isYes(onchainVal) || + ((isYes(xbtVal) || isYes(btcVal) || isYes(btcCurrencyVal)) && + !isNo(onchainVal)); + + const bitcoin = { + status: isBtcAccepted ? 'accepted' : isBtcDenied ? 'denied' : null, + lightning, + onchain: isBtcAccepted ? onchain : false, + }; + + const hasPaymentInfo = + cash !== null || cards !== null || bitcoin.status !== null; + + return { + hasPaymentInfo, + cash, + cards, + bitcoin, + }; +} diff --git a/tests/integration/components/place-details-test.gjs b/tests/integration/components/place-details-test.gjs index 59f1c12..e047ca9 100644 --- a/tests/integration/components/place-details-test.gjs +++ b/tests/integration/components/place-details-test.gjs @@ -411,4 +411,41 @@ module('Integration | Component | place-details', function (hooks) { assert.dom('button.btn-link').doesNotExist(); }); + + test('it renders payment methods when payment tags are present on place', async function (assert) { + const place = { + title: 'Coffee Place', + lat: 52.52, + lon: 13.4, + osmTags: { + 'payment:cash': 'yes', + 'payment:cards': 'no', + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + }, + }; + + await render(); + + assert.dom('.place-payment-methods').exists(); + assert.dom('[data-test-payment-method="cash"]').exists(); + assert.dom('[data-test-payment-method="cards"]').hasClass('is-denied'); + assert.dom('[data-test-payment-method="bitcoin"]').exists(); + assert.dom('[data-test-payment-badge="lightning"]').exists(); + }); + + test('it does not render payment methods when no payment tags are present on place', async function (assert) { + const place = { + title: 'Simple Place', + lat: 52.52, + lon: 13.4, + osmTags: { + amenity: 'bench', + }, + }; + + await render(); + + assert.dom('.place-payment-methods').doesNotExist(); + }); }); diff --git a/tests/integration/components/place-payment-methods-test.gjs b/tests/integration/components/place-payment-methods-test.gjs new file mode 100644 index 0000000..d224cb0 --- /dev/null +++ b/tests/integration/components/place-payment-methods-test.gjs @@ -0,0 +1,111 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render } from '@ember/test-helpers'; +import PlacePaymentMethods from 'marco/components/place-payment-methods'; + +module('Integration | Component | place-payment-methods', function (hooks) { + setupRenderingTest(hooks); + + test('it renders nothing when no payment tags are present', async function (assert) { + await render(); + assert.dom('.place-payment-methods').doesNotExist(); + + const otherTags = { amenity: 'restaurant', name: 'Bistro' }; + await render( + + ); + assert.dom('.place-payment-methods').doesNotExist(); + }); + + test('it renders cash and card icons when accepted', async function (assert) { + const tags = { + 'payment:cash': 'yes', + 'payment:cards': 'yes', + }; + + await render(); + + assert.dom('.place-payment-methods').exists(); + assert.dom('[data-test-payment-method="cash"]').exists(); + assert + .dom('[data-test-payment-method="cash"]') + .doesNotHaveClass('is-denied'); + assert.dom('[data-test-payment-method="cards"]').exists(); + assert + .dom('[data-test-payment-method="cards"]') + .doesNotHaveClass('is-denied'); + assert.dom('[data-test-payment-method="bitcoin"]').doesNotExist(); + }); + + test('it shows strike-through when cards are explicitly denied', async function (assert) { + const tags = { + 'payment:cash': 'yes', + 'payment:cards': 'no', + }; + + await render(); + + assert.dom('[data-test-payment-method="cards"]').exists(); + assert.dom('[data-test-payment-method="cards"]').hasClass('is-denied'); + assert + .dom('[data-test-payment-method="cash"]') + .doesNotHaveClass('is-denied'); + }); + + test('it shows strike-through when cash is explicitly denied', async function (assert) { + const tags = { + 'payment:cash': 'no', + 'payment:cards': 'yes', + }; + + await render(); + + assert.dom('[data-test-payment-method="cash"]').exists(); + assert.dom('[data-test-payment-method="cash"]').hasClass('is-denied'); + assert + .dom('[data-test-payment-method="cards"]') + .doesNotHaveClass('is-denied'); + }); + + test('it renders on-chain bitcoin without lightning zap badge', async function (assert) { + const tags = { + 'currency:XBT': 'yes', + 'payment:onchain': 'yes', + 'payment:lightning': 'no', + }; + + await render(); + + assert.dom('[data-test-payment-method="bitcoin"]').exists(); + assert + .dom('[data-test-payment-method="bitcoin"]') + .doesNotHaveClass('is-denied'); + assert.dom('[data-test-payment-badge="lightning"]').doesNotExist(); + }); + + test('it renders bitcoin with lightning zap badge when lightning is accepted', async function (assert) { + const tags = { + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + }; + + await render(); + + assert.dom('[data-test-payment-method="bitcoin"]').exists(); + assert + .dom('[data-test-payment-method="bitcoin"]') + .doesNotHaveClass('is-denied'); + assert.dom('[data-test-payment-badge="lightning"]').exists(); + }); + + test('it renders bitcoin with strike-through when explicitly denied', async function (assert) { + const tags = { + 'currency:XBT': 'no', + }; + + await render(); + + assert.dom('[data-test-payment-method="bitcoin"]').exists(); + assert.dom('[data-test-payment-method="bitcoin"]').hasClass('is-denied'); + }); +}); diff --git a/tests/unit/utils/payment-test.js b/tests/unit/utils/payment-test.js new file mode 100644 index 0000000..cb67eba --- /dev/null +++ b/tests/unit/utils/payment-test.js @@ -0,0 +1,218 @@ +import { parsePaymentMethods } from 'marco/utils/payment'; +import { module, test } from 'qunit'; + +module('Unit | Utility | payment', function () { + test('it returns hasPaymentInfo: false for empty or non-payment tags', function (assert) { + assert.deepEqual(parsePaymentMethods(undefined), { + hasPaymentInfo: false, + cash: null, + cards: null, + bitcoin: { status: null, lightning: false, onchain: false }, + }); + + assert.deepEqual(parsePaymentMethods({}), { + hasPaymentInfo: false, + cash: null, + cards: null, + bitcoin: { status: null, lightning: false, onchain: false }, + }); + + assert.deepEqual( + parsePaymentMethods({ amenity: 'cafe', name: 'Coffee Shop' }), + { + hasPaymentInfo: false, + cash: null, + cards: null, + bitcoin: { status: null, lightning: false, onchain: false }, + } + ); + }); + + module('cash', function () { + test('it identifies cash as accepted', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:cash': 'yes' }).cash, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:cash': 'only' }).cash, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:coins': 'yes' }).cash, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:notes': 'yes' }).cash, + 'accepted' + ); + }); + + test('it identifies cash as denied', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:cash': 'no' }).cash, + 'denied' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:coins': 'no', 'payment:notes': 'no' }) + .cash, + 'denied' + ); + }); + + test('it keeps cash as null when cash tags are absent', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:cards': 'yes' }).cash, + null + ); + }); + }); + + module('cards', function () { + test('it identifies cards as accepted from generic or card tags', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:cards': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:credit_cards': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:debit_cards': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:contactless': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:visa': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:mastercard': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:american_express': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:girocard': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:apple_pay': 'yes' }).cards, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:google_pay': 'yes' }).cards, + 'accepted' + ); + }); + + test('it identifies cards as denied when explicitly disabled', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:cards': 'no' }).cards, + 'denied' + ); + assert.strictEqual( + parsePaymentMethods({ + 'payment:credit_cards': 'no', + 'payment:debit_cards': 'no', + }).cards, + 'denied' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:cash': 'only' }).cards, + 'denied' + ); + }); + + test('it accepts cards if debit is accepted even if credit_cards is no', function (assert) { + assert.strictEqual( + parsePaymentMethods({ + 'payment:credit_cards': 'no', + 'payment:debit_cards': 'yes', + }).cards, + 'accepted' + ); + }); + }); + + module('bitcoin', function () { + test('it identifies on-chain bitcoin acceptance', function (assert) { + const result = parsePaymentMethods({ 'currency:XBT': 'yes' }); + assert.strictEqual(result.bitcoin.status, 'accepted'); + assert.true(result.bitcoin.onchain); + assert.false(result.bitcoin.lightning); + }); + + test('it identifies bitcoin from payment:onchain tag', function (assert) { + const result = parsePaymentMethods({ 'payment:onchain': 'yes' }); + assert.strictEqual(result.bitcoin.status, 'accepted'); + assert.true(result.bitcoin.onchain); + assert.false(result.bitcoin.lightning); + }); + + test('it identifies bitcoin with lightning', function (assert) { + const result = parsePaymentMethods({ + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + 'payment:onchain': 'no', + }); + assert.strictEqual(result.bitcoin.status, 'accepted'); + assert.true(result.bitcoin.lightning); + assert.false(result.bitcoin.onchain); + }); + + test('it identifies bitcoin with both lightning and on-chain', function (assert) { + const result = parsePaymentMethods({ + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + 'payment:onchain': 'yes', + }); + assert.strictEqual(result.bitcoin.status, 'accepted'); + assert.true(result.bitcoin.lightning); + assert.true(result.bitcoin.onchain); + }); + + test('it supports fallback bitcoin tags', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'payment:bitcoin': 'yes' }).bitcoin.status, + 'accepted' + ); + assert.strictEqual( + parsePaymentMethods({ 'currency:BTC': 'yes' }).bitcoin.status, + 'accepted' + ); + }); + + test('it identifies bitcoin as denied when explicitly no', function (assert) { + assert.strictEqual( + parsePaymentMethods({ 'currency:XBT': 'no' }).bitcoin.status, + 'denied' + ); + assert.strictEqual( + parsePaymentMethods({ 'payment:bitcoin': 'no' }).bitcoin.status, + 'denied' + ); + }); + }); + + test('it handles mixed tags and sets hasPaymentInfo to true', function (assert) { + const result = parsePaymentMethods({ + 'payment:cash': 'yes', + 'payment:cards': 'no', + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + }); + + assert.true(result.hasPaymentInfo); + assert.strictEqual(result.cash, 'accepted'); + assert.strictEqual(result.cards, 'denied'); + assert.strictEqual(result.bitcoin.status, 'accepted'); + assert.true(result.bitcoin.lightning); + }); +}); -- 2.50.1 From c8b9ddc1e5e0dc16ddcc1c7a99386cd165a4c9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Sep 2026 13:07:27 -0600 Subject: [PATCH 2/3] Improve icons --- app/components/icon.gjs | 21 ++- app/components/place-payment-methods.gjs | 126 +++++++++--------- app/icons/bitcoin.svg | 4 +- app/styles/app.css | 36 +++-- tests/integration/components/icon-test.gjs | 61 +++++++++ .../components/place-details-test.gjs | 9 ++ .../components/place-payment-methods-test.gjs | 68 +++++++++- 7 files changed, 248 insertions(+), 77 deletions(-) create mode 100644 tests/integration/components/icon-test.gjs diff --git a/app/components/icon.gjs b/app/components/icon.gjs index 2c53914..32589f0 100644 --- a/app/components/icon.gjs +++ b/app/components/icon.gjs @@ -2,6 +2,16 @@ import Component from '@glimmer/component'; import { htmlSafe } from '@ember/template'; import { getIcon, isIconFilled } from '../utils/icons'; +function formatDimension(dim) { + if (typeof dim === 'number') { + return `${dim}px`; + } + if (typeof dim === 'string' && /^\d+(\.\d+)?$/.test(dim.trim())) { + return `${dim.trim()}px`; + } + return dim; +} + export default class IconComponent extends Component { get svg() { return getIcon(this.args.name); @@ -11,13 +21,21 @@ export default class IconComponent extends Component { return this.args.size || 16; } + get width() { + return this.args.width !== undefined ? this.args.width : this.size; + } + + get height() { + return this.args.height !== undefined ? this.args.height : this.size; + } + get color() { return this.args.color || '#898989'; } get style() { return htmlSafe( - `width:${this.size}px;height:${this.size}px;color:${this.color}` + `width:${formatDimension(this.width)};height:${formatDimension(this.height)};color:${this.color}` ); } @@ -35,6 +53,7 @@ export default class IconComponent extends Component { class="icon {{if this.isFilled 'icon-filled'}}" style={{this.style}} title={{this.title}} + ...attributes > {{htmlSafe this.svg}} diff --git a/app/components/place-payment-methods.gjs b/app/components/place-payment-methods.gjs index b873aea..dc0ddd2 100644 --- a/app/components/place-payment-methods.gjs +++ b/app/components/place-payment-methods.gjs @@ -1,90 +1,92 @@ import Component from '@glimmer/component'; import Icon from './icon'; import { parsePaymentMethods } from '../utils/payment'; -import { eq } from 'ember-truth-helpers'; +import tooltip from '../modifiers/tooltip'; export default class PlacePaymentMethods extends Component { get payment() { return parsePaymentMethods(this.args.tags); } - get showCash() { - return this.payment.cash !== null; - } - - get showCards() { - return this.payment.cards !== null; - } - - get showBitcoin() { - return this.payment.bitcoin.status !== null; - } - - get cashTitle() { - return this.payment.cash === 'accepted' ? 'Cash accepted' : 'No cash'; - } - - get cardsTitle() { - return this.payment.cards === 'accepted' ? 'Cards accepted' : 'No cards'; - } - - get bitcoinTitle() { - if (this.payment.bitcoin.status === 'denied') { - return 'No Bitcoin'; + get methods() { + const list = []; + if (this.payment.cash !== null) { + const isDenied = this.payment.cash === 'denied'; + list.push({ + id: 'cash', + isDenied, + icon: 'banknote', + color: 'currentColor', + description: isDenied ? 'No cash' : 'Cash accepted', + hasBadge: false, + }); } - if (this.payment.bitcoin.lightning) { - return 'Bitcoin (Lightning)'; + + if (this.payment.cards !== null) { + const isDenied = this.payment.cards === 'denied'; + list.push({ + id: 'cards', + isDenied, + icon: 'payment-card', + color: 'currentColor', + description: isDenied ? 'No cards' : 'Cards accepted', + hasBadge: false, + }); } - return 'Bitcoin (On-chain)'; + + if (this.payment.bitcoin.status !== null) { + const isDenied = this.payment.bitcoin.status === 'denied'; + let description = 'No Bitcoin'; + if (!isDenied) { + description = this.payment.bitcoin.lightning + ? 'Bitcoin (Lightning)' + : 'Bitcoin (On-chain)'; + } + list.push({ + id: 'bitcoin', + isDenied, + icon: 'bitcoin', + width: 17, + height: 22, + color: 'currentColor', + description, + hasBadge: !isDenied && this.payment.bitcoin.lightning, + }); + } + + return list; } diff --git a/app/icons/bitcoin.svg b/app/icons/bitcoin.svg index 4fa6f13..f3bfc86 100644 --- a/app/icons/bitcoin.svg +++ b/app/icons/bitcoin.svg @@ -1,3 +1,3 @@ - - + + diff --git a/app/styles/app.css b/app/styles/app.css index 28b2077..6de37d3 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -1190,8 +1190,9 @@ abbr[title] { display: inline-flex; align-items: center; justify-content: center; - width: 24px; height: 24px; + border-radius: 4px; + outline-offset: 2px; color: var(--secondary-text-color); } @@ -1203,27 +1204,40 @@ abbr[title] { content: ''; position: absolute; top: 50%; - left: -2px; - right: -2px; + left: 50%; + width: 28px; height: 2px; background-color: var(--danger-color); - transform: rotate(-45deg); + transform: translate(-50%, -50%) rotate(-45deg); pointer-events: none; border-radius: 1px; } .payment-method-badge { position: absolute; - bottom: -3px; - right: -5px; - display: inline-flex; + bottom: -2px; + right: -6px; + display: flex; align-items: center; justify-content: center; - background: var(--primary-background-color); + width: 13px; + height: 13px; border-radius: 50%; - width: 14px; - height: 14px; - box-shadow: 0 1px 2px rgb(0 0 0 / 15%); + background: var(--default-list-color); + overflow: hidden; + pointer-events: none; + font-size: 0; + line-height: 0; +} + +.payment-method-badge .icon { + display: flex; + align-items: center; + justify-content: center; +} + +.payment-method-badge .icon svg { + display: block; } .btn { diff --git a/tests/integration/components/icon-test.gjs b/tests/integration/components/icon-test.gjs new file mode 100644 index 0000000..6ba39cb --- /dev/null +++ b/tests/integration/components/icon-test.gjs @@ -0,0 +1,61 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render } from '@ember/test-helpers'; +import Icon from 'marco/components/icon'; + +module('Integration | Component | icon', function (hooks) { + setupRenderingTest(hooks); + + test('it renders default 16px square dimensions', async function (assert) { + await render(); + + assert.dom('.icon').exists(); + assert.dom('.icon').hasAttribute('style', /width:16px;height:16px;/); + }); + + test('it renders custom square size with @size', async function (assert) { + await render(); + + assert.dom('.icon').hasAttribute('style', /width:24px;height:24px;/); + }); + + test('it supports custom @width and @height', async function (assert) { + await render( + + ); + + assert.dom('.icon').hasAttribute('style', /width:17px;height:22px;/); + }); + + test('it allows @width to override while @height falls back to @size', async function (assert) { + await render( + + ); + + assert.dom('.icon').hasAttribute('style', /width:17px;height:22px;/); + }); + + test('it allows @height to override while @width falls back to @size', async function (assert) { + await render( + + ); + + assert.dom('.icon').hasAttribute('style', /width:18px;height:22px;/); + }); + + test('it supports explicit string units for width and height', async function (assert) { + await render( + + ); + + assert.dom('.icon').hasAttribute('style', /width:100%;height:2rem;/); + }); + + test('it splats HTML attributes to the icon element', async function (assert) { + await render( + + ); + + assert.dom('[data-test-custom-icon="true"]').exists(); + }); +}); diff --git a/tests/integration/components/place-details-test.gjs b/tests/integration/components/place-details-test.gjs index e047ca9..5b28284 100644 --- a/tests/integration/components/place-details-test.gjs +++ b/tests/integration/components/place-details-test.gjs @@ -432,6 +432,15 @@ module('Integration | Component | place-details', function (hooks) { assert.dom('[data-test-payment-method="cards"]').hasClass('is-denied'); assert.dom('[data-test-payment-method="bitcoin"]').exists(); assert.dom('[data-test-payment-badge="lightning"]').exists(); + + const methodNames = Array.from( + this.element.querySelectorAll('[data-test-payment-method]') + ).map((el) => el.getAttribute('data-test-payment-method')); + assert.deepEqual( + methodNames, + ['cash', 'cards', 'bitcoin'], + 'renders in stable order: cash, cards, bitcoin' + ); }); test('it does not render payment methods when no payment tags are present on place', async function (assert) { diff --git a/tests/integration/components/place-payment-methods-test.gjs b/tests/integration/components/place-payment-methods-test.gjs index d224cb0..913b96d 100644 --- a/tests/integration/components/place-payment-methods-test.gjs +++ b/tests/integration/components/place-payment-methods-test.gjs @@ -17,7 +17,7 @@ module('Integration | Component | place-payment-methods', function (hooks) { assert.dom('.place-payment-methods').doesNotExist(); }); - test('it renders cash and card icons when accepted', async function (assert) { + test('it renders cash and card icons when accepted with aria-description and no title attribute', async function (assert) { const tags = { 'payment:cash': 'yes', 'payment:cards': 'yes', @@ -30,10 +30,28 @@ module('Integration | Component | place-payment-methods', function (hooks) { assert .dom('[data-test-payment-method="cash"]') .doesNotHaveClass('is-denied'); + assert + .dom('[data-test-payment-method="cash"]') + .doesNotHaveAttribute('title'); + assert + .dom('[data-test-payment-method="cash"]') + .hasAttribute('aria-description', 'Cash accepted'); + assert + .dom('[data-test-payment-method="cash"] > .icon') + .hasAttribute('style', /width:22px;height:22px;color:currentColor/); assert.dom('[data-test-payment-method="cards"]').exists(); assert .dom('[data-test-payment-method="cards"]') .doesNotHaveClass('is-denied'); + assert + .dom('[data-test-payment-method="cards"]') + .doesNotHaveAttribute('title'); + assert + .dom('[data-test-payment-method="cards"]') + .hasAttribute('aria-description', 'Cards accepted'); + assert + .dom('[data-test-payment-method="cards"] > .icon') + .hasAttribute('style', /width:22px;height:22px;color:currentColor/); assert.dom('[data-test-payment-method="bitcoin"]').doesNotExist(); }); @@ -95,7 +113,28 @@ module('Integration | Component | place-payment-methods', function (hooks) { assert .dom('[data-test-payment-method="bitcoin"]') .doesNotHaveClass('is-denied'); + assert + .dom('[data-test-payment-method="bitcoin"]') + .doesNotHaveAttribute('title'); + assert + .dom('[data-test-payment-method="bitcoin"]') + .hasAttribute('aria-description', 'Bitcoin (Lightning)'); + assert + .dom('[data-test-payment-method="bitcoin"] > .icon') + .hasAttribute('style', /width:17px;height:22px;color:currentColor/); assert.dom('[data-test-payment-badge="lightning"]').exists(); + assert + .dom('[data-test-payment-badge="lightning"] .icon') + .hasClass('icon-filled'); + assert + .dom('[data-test-payment-badge="lightning"] .icon') + .hasAttribute('style', /width:13px/); + assert + .dom('[data-test-payment-badge="lightning"] .icon') + .hasAttribute('style', /color:#fff/); + assert + .dom('[data-test-payment-badge="lightning"]') + .doesNotHaveAttribute('title'); }); test('it renders bitcoin with strike-through when explicitly denied', async function (assert) { @@ -107,5 +146,32 @@ module('Integration | Component | place-payment-methods', function (hooks) { assert.dom('[data-test-payment-method="bitcoin"]').exists(); assert.dom('[data-test-payment-method="bitcoin"]').hasClass('is-denied'); + assert + .dom('[data-test-payment-method="bitcoin"]') + .doesNotHaveAttribute('title'); + assert + .dom('[data-test-payment-method="bitcoin"]') + .hasAttribute('aria-description', 'No Bitcoin'); + }); + + test('it preserves stable order (cash, cards, bitcoin) regardless of whether denied or accepted', async function (assert) { + const tags = { + 'payment:cash': 'no', + 'payment:cards': 'yes', + 'currency:XBT': 'yes', + 'payment:lightning': 'yes', + }; + + await render(); + + const methods = Array.from( + this.element.querySelectorAll('[data-test-payment-method]') + ).map((el) => el.getAttribute('data-test-payment-method')); + + assert.deepEqual( + methods, + ['cash', 'cards', 'bitcoin'], + 'icons stay in fixed positions: cash, cards, bitcoin' + ); }); }); -- 2.50.1 From f7eacf58d1d450f2da45f9b696e5776ca1db2100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Sep 2026 15:28:33 -0600 Subject: [PATCH 3/3] Clear wording --- app/components/place-payment-methods.gjs | 4 ++-- tests/integration/components/place-payment-methods-test.gjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/place-payment-methods.gjs b/app/components/place-payment-methods.gjs index dc0ddd2..77a0fa3 100644 --- a/app/components/place-payment-methods.gjs +++ b/app/components/place-payment-methods.gjs @@ -39,8 +39,8 @@ export default class PlacePaymentMethods extends Component { let description = 'No Bitcoin'; if (!isDenied) { description = this.payment.bitcoin.lightning - ? 'Bitcoin (Lightning)' - : 'Bitcoin (On-chain)'; + ? 'Bitcoin (Lightning) accepted' + : 'Bitcoin (On-chain) accepted'; } list.push({ id: 'bitcoin', diff --git a/tests/integration/components/place-payment-methods-test.gjs b/tests/integration/components/place-payment-methods-test.gjs index 913b96d..5230284 100644 --- a/tests/integration/components/place-payment-methods-test.gjs +++ b/tests/integration/components/place-payment-methods-test.gjs @@ -118,7 +118,7 @@ module('Integration | Component | place-payment-methods', function (hooks) { .doesNotHaveAttribute('title'); assert .dom('[data-test-payment-method="bitcoin"]') - .hasAttribute('aria-description', 'Bitcoin (Lightning)'); + .hasAttribute('aria-description', 'Bitcoin (Lightning) accepted'); assert .dom('[data-test-payment-method="bitcoin"] > .icon') .hasAttribute('style', /width:17px;height:22px;color:currentColor/); -- 2.50.1