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-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..77a0fa3
--- /dev/null
+++ b/app/components/place-payment-methods.gjs
@@ -0,0 +1,93 @@
+import Component from '@glimmer/component';
+import Icon from './icon';
+import { parsePaymentMethods } from '../utils/payment';
+import tooltip from '../modifiers/tooltip';
+
+export default class PlacePaymentMethods extends Component {
+ get payment() {
+ return parsePaymentMethods(this.args.tags);
+ }
+
+ 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.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,
+ });
+ }
+
+ 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) accepted'
+ : 'Bitcoin (On-chain) accepted';
+ }
+ list.push({
+ id: 'bitcoin',
+ isDenied,
+ icon: 'bitcoin',
+ width: 17,
+ height: 22,
+ color: 'currentColor',
+ description,
+ hasBadge: !isDenied && this.payment.bitcoin.lightning,
+ });
+ }
+
+ return list;
+ }
+
+
+ {{! template-lint-disable no-unsupported-role-attributes }}
+ {{#if this.payment.hasPaymentInfo}}
+
+ {{#each this.methods as |method|}}
+
+
+ {{#if method.hasBadge}}
+
+
+
+ {{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+}
diff --git a/app/icons/bitcoin.svg b/app/icons/bitcoin.svg
new file mode 100644
index 0000000..f3bfc86
--- /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..6de37d3 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -1175,6 +1175,71 @@ 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;
+ height: 24px;
+ border-radius: 4px;
+ outline-offset: 2px;
+ color: var(--secondary-text-color);
+}
+
+.payment-method.is-denied {
+ opacity: 0.5;
+}
+
+.payment-method.is-denied::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 28px;
+ height: 2px;
+ background-color: var(--danger-color);
+ transform: translate(-50%, -50%) rotate(-45deg);
+ pointer-events: none;
+ border-radius: 1px;
+}
+
+.payment-method-badge {
+ position: absolute;
+ bottom: -2px;
+ right: -6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 13px;
+ height: 13px;
+ border-radius: 50%;
+ 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 {
/* 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/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 59f1c12..5b28284 100644
--- a/tests/integration/components/place-details-test.gjs
+++ b/tests/integration/components/place-details-test.gjs
@@ -411,4 +411,50 @@ 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();
+
+ 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) {
+ 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..5230284
--- /dev/null
+++ b/tests/integration/components/place-payment-methods-test.gjs
@@ -0,0 +1,177 @@
+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 with aria-description and no title attribute', 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="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();
+ });
+
+ 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-method="bitcoin"]')
+ .doesNotHaveAttribute('title');
+ assert
+ .dom('[data-test-payment-method="bitcoin"]')
+ .hasAttribute('aria-description', 'Bitcoin (Lightning) accepted');
+ 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) {
+ 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');
+ 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'
+ );
+ });
+});
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);
+ });
+});