Compare commits

..
15 Commits
Author SHA1 Message Date
raucao 081138bfc0 1.34.0
CI / Lint (push) Successful in 1m5s
CI / Test (push) Successful in 1m23s
2026-09-08 15:37:41 -06:00
raucao 1a490c5d04 Merge pull request 'Add accepted/denied payment methods to place details' (#102) from feature/payment_details into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m23s
Reviewed-on: #102
2026-09-08 21:36:12 +00:00
raucao f7eacf58d1 Clear wording
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m22s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-08 15:28:33 -06:00
raucao c8b9ddc1e5 Improve icons
CI / Lint (pull_request) Successful in 1m5s
CI / Test (pull_request) Successful in 1m23s
2026-09-08 15:23:14 -06:00
raucao e9be615c9a WIP Add payment methods to place details 2026-09-08 12:33:06 -06:00
raucao a4e7e721ac 1.33.1
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m18s
2026-09-03 14:25:12 -06:00
raucao 7d497c9afb Merge pull request 'Immediately load OSM place when selecting a map feature' (#101) from feature/map_feature_clicks into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m20s
Reviewed-on: #101
2026-09-03 20:23:30 +00:00
raucao 0d92fc9937 Fix lint errors
CI / Lint (pull_request) Successful in 1m5s
CI / Test (pull_request) Successful in 1m20s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-03 14:21:22 -06:00
raucao 7e2bce84db Immediately load OSM place when selecting a map feature
CI / Lint (pull_request) Failing after 1m3s
CI / Test (pull_request) Successful in 1m20s
No waiting for Overpass when selecting visible POIs anymore
2026-09-03 14:16:11 -06:00
raucao dccad0b47f Merge pull request 'Prevent activity duplicates when loading more items' (#100) from bugfix/activity_duplicates into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m20s
Reviewed-on: #100
2026-09-03 20:02:42 +00:00
raucao 05160eb5f1 Prevent activity duplicates when loading more items
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m20s
Release Drafter / Update release notes draft (pull_request) Successful in 6s
2026-09-03 13:51:15 -06:00
raucao fb3b8bef39 Merge pull request 'Rename Collections, add special list for all saved' (#99) from feature/saved_places into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m20s
Reviewed-on: #99
2026-09-03 19:49:06 +00:00
raucao c870a71e30 Fix lint error
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m19s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-03 13:34:29 -06:00
raucao ddce25f43a Rename Collections, add special list for all saved
CI / Lint (pull_request) Failing after 1m3s
CI / Test (pull_request) Successful in 1m21s
2026-09-03 13:29:36 -06:00
raucao 4653947454 WIP Saved places 2026-09-03 10:08:48 -06:00
28 changed files with 1191 additions and 37 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<li>
<button type="button" {{on "click" @onSavedPlaces}}>
<Icon @name="bookmark" @size={{20}} />
<span>Collections</span>
<span>Saved Places</span>
</button>
</li>
<li>
+20 -1
View File
@@ -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}}
</span>
+114
View File
@@ -1005,6 +1005,62 @@ export default class MapComponent extends Component {
}
});
decodeVectorTileOsmFeature(feature) {
if (!feature?.getId || !feature?.get) {
return { decoded: null, reason: 'feature does not expose get()/getId()' };
}
// ol-mapbox-style stores the source-layer name on `mvt:layer`.
const sourceLayer = feature.get('mvt:layer') || feature.get('layer');
// OpenFreeMap uses Planetiler/OpenMapTiles tiles, which encode OSM-backed
// feature ids as (osmId * 10) + sourceType.
if (sourceLayer !== 'poi') {
return {
decoded: null,
reason: `unsupported source layer: ${sourceLayer || 'unknown'}`,
};
}
const rawId = feature.getId();
const encodedId = Number(rawId);
if (!Number.isSafeInteger(encodedId) || encodedId <= 0) {
return {
decoded: null,
reason: `feature id is not a positive safe integer: ${rawId}`,
};
}
const osmType = {
1: 'node',
2: 'way',
3: 'relation',
}[encodedId % 10];
if (!osmType) {
return {
decoded: null,
reason: `feature id suffix ${encodedId % 10} is not an OSM type`,
};
}
const osmId = Math.floor(encodedId / 10);
if (osmId <= 0) {
return {
decoded: null,
reason: `decoded OSM id is invalid: ${osmId}`,
};
}
return {
decoded: {
osmId: String(osmId),
osmType,
},
reason: null,
};
}
animateToCrosshair(targetCoords) {
if (!this.mapInstance || !this.crosshairElement) return;
@@ -1141,6 +1197,7 @@ export default class MapComponent extends Component {
});
let clickedBookmark = null;
let clickedSearchResult = null;
let clickedTileOsmFeature = null;
let selectedFeatureName = null;
if (features && features.length > 0) {
@@ -1155,6 +1212,35 @@ export default class MapComponent extends Component {
clickedBookmark = bookmarkFeature.get('originalPlace');
} else if (searchResultFeature) {
clickedSearchResult = searchResultFeature.get('originalPlace');
} else {
for (const feature of features) {
const sourceLayer =
feature.get?.('mvt:layer') || feature.get?.('layer');
const featureName = feature.get?.('name');
const featureId = feature.getId?.();
const { decoded, reason } = this.decodeVectorTileOsmFeature(feature);
if (decoded) {
console.debug(
'Decoded vector tile feature to explicit OSM place:',
{
sourceLayer,
featureName,
featureId,
decoded,
}
);
clickedTileOsmFeature = decoded;
break;
}
console.debug('Vector tile feature could not be decoded directly:', {
sourceLayer,
featureName,
featureId,
reason,
});
}
}
// Also get visual props for standard map click logic later
const props = features[0].getProperties();
@@ -1179,6 +1265,19 @@ export default class MapComponent extends Component {
this.router.transitionTo('place', place);
};
const transitionToExplicitOsmPlace = ({ osmId, osmType }) => {
if (
this.router.currentRouteName === 'search' ||
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
) {
this.mapUi.returnToSearch = true;
}
this.mapUi.preventNextZoom = true;
this.mapUi.showSidebar();
this.router.transitionTo('place', `osm:${osmType}:${osmId}`);
};
// Special handling when sidebar is OPEN
if (this.args.isSidebarOpen) {
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
@@ -1192,6 +1291,15 @@ export default class MapComponent extends Component {
return;
}
if (clickedTileOsmFeature) {
console.debug(
'Clicked vector tile POI while sidebar open (switching):',
clickedTileOsmFeature
);
transitionToExplicitOsmPlace(clickedTileOsmFeature);
return;
}
// Otherwise (empty map or non-bookmark feature), close the sidebar
if (this.args.onOutsideClick) {
this.args.onOutsideClick();
@@ -1212,6 +1320,12 @@ export default class MapComponent extends Component {
return;
}
if (clickedTileOsmFeature) {
console.debug('Clicked vector tile POI:', clickedTileOsmFeature);
transitionToExplicitOsmPlace(clickedTileOsmFeature);
return;
}
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
console.debug('Clearing active search and markers on map click');
this.router.transitionTo('index');
+3
View File
@@ -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}}
</div>
<PlacePaymentMethods @tags={{this.tags}} />
<div class="meta-info">
{{#if this.cuisine}}
+93
View File
@@ -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>
{{! template-lint-disable no-unsupported-role-attributes }}
{{#if this.payment.hasPaymentInfo}}
<div class="place-payment-methods" aria-label="Payment methods">
{{#each this.methods as |method|}}
<span
class="payment-method {{if method.isDenied 'is-denied'}}"
data-test-payment-method={{method.id}}
aria-label={{method.description}}
aria-description={{method.description}}
tabindex="0"
{{tooltip}}
>
<Icon
@name={{method.icon}}
@size={{22}}
@width={{method.width}}
@height={{method.height}}
@color={{method.color}}
/>
{{#if method.hasBadge}}
<span
class="payment-method-badge"
data-test-payment-badge="lightning"
>
<Icon @name="zap" @size={{13}} @color="#fff" @filled={{true}} />
</span>
{{/if}}
</span>
{{/each}}
</div>
{{/if}}
</template>
}
+18
View File
@@ -14,6 +14,8 @@ function getPlaceTime(place) {
return isNaN(parsed) ? 0 : parsed;
}
const SAVED_LIST_ID = 'saved';
export default class ListsListController extends Controller {
@service router;
@service mapUi;
@@ -41,6 +43,12 @@ export default class ListsListController extends Controller {
}
get listColor() {
if (this.listId === SAVED_LIST_ID) {
return getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
}
const list = this.storage.lists.find((l) => l.id === this.listId);
if (list && list.color) {
return list.color;
@@ -51,11 +59,21 @@ export default class ListsListController extends Controller {
}
get listTitle() {
if (this.listId === SAVED_LIST_ID) {
return 'Saved Places';
}
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Collections';
}
get places() {
if (this.listId === SAVED_LIST_ID) {
return [...this.storage.savedPlaces].sort(
(a, b) => getPlaceTime(b) - getPlaceTime(a)
);
}
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(
currentList?.placeRefs?.map((ref) => ref.id) || []
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8.157 6 15.088 19.949">
<path d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

+5 -9
View File
@@ -1,26 +1,22 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsListRoute extends Route {
@service storage;
model(params) {
// Resolve instantly so transition happens in 0ms!
return { list_id: params.list_id };
}
setupController(controller, model) {
console.debug('DEBUG: setupController controller is:', controller);
console.debug(
'DEBUG: controller.loadPlacesTask is:',
controller?.loadPlacesTask
);
controller.model = model;
super.setupController(controller, model);
if (model.list_id === 'saved') {
return;
}
if (controller && controller.loadPlacesTask) {
controller.loadPlacesTask.perform(model.list_id);
} else {
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
console.error('controller.loadPlacesTask is undefined');
}
}
}
+17 -4
View File
@@ -184,11 +184,24 @@ export default class ActivityService extends Service {
const newEntries = groupSocialPhotos(filtered);
for (const entry of newEntries) {
const seenPhotoIds = new Set();
for (const entry of this._socialItems) {
for (const photo of entry.photos) {
seenPhotoIds.add(photo.eventId);
}
}
const dedupedEntries = newEntries.filter(
(entry) => !entry.photos.every((photo) => seenPhotoIds.has(photo.eventId))
);
if (dedupedEntries.length === 0) return false;
for (const entry of dedupedEntries) {
this._resolveSender(entry);
}
for (const entry of newEntries) {
for (const entry of dedupedEntries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
@@ -200,11 +213,11 @@ export default class ActivityService extends Service {
}
}
this._socialItems = [...this._socialItems, ...newEntries];
this._socialItems = [...this._socialItems, ...dedupedEntries];
this._mergeItems();
void this.placeNameResolver
.resolveInBackground(newEntries)
.resolveInBackground(dedupedEntries)
.then(() => this._mergeItems());
return true;
+65
View File
@@ -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
+31 -1
View File
@@ -11,6 +11,12 @@ export default class ListsIndexTemplate extends Component {
@service router;
@service mapUi;
savedList = {
id: 'saved',
title: 'Saved',
color: 'var(--default-list-color)',
};
styleFor(color) {
const finalColor =
color ||
@@ -46,7 +52,7 @@ export default class ListsIndexTemplate extends Component {
<span class="sidebar-header-icon-wrapper">
<Icon @name="bookmark" @size={{20}} @color="#898989" />
</span>
Collections
Saved Places
</h2>
<button type="button" class="close-btn" {{on "click" this.close}}>
<Icon @name="x" @size={{20}} @color="#333" />
@@ -55,6 +61,30 @@ export default class ListsIndexTemplate extends Component {
<div class="sidebar-content">
<ul class="places-list">
<li>
<button
type="button"
class="lists-index-item"
{{on "click" (fn this.selectList this.savedList.id)}}
>
<div class="lists-index-item-left">
{{! template-lint-disable no-inline-styles }}
<span
class="list-color-dot"
style={{this.styleFor this.savedList.color}}
></span>
<div class="lists-index-name">{{this.savedList.title}}</div>
</div>
<div class="lists-index-count">
{{#if this.storage.savedPlaces.length}}
{{this.storage.savedPlaces.length}}
places
{{else}}
empty
{{/if}}
</div>
</button>
</li>
{{#each this.storage.lists as |list|}}
<li>
<button
+7
View File
@@ -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',
+111
View File
@@ -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,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.33.0",
"version": "1.34.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -42,8 +42,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-D9MIfkGZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CZWBbnnc.css">
<script type="module" crossorigin src="/assets/main-B66TikLz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
</head>
<body>
<div id="modal-portal"></div>
+69 -14
View File
@@ -16,8 +16,16 @@ class MockStorageService extends Service {
id: 'place-123',
title: 'Mountain Trail',
geohash: 'u33dc0',
createdAt: '2023-01-02T12:00:00.000Z',
osmTags: { name: 'Mountain Trail' },
},
{
id: 'place-456',
title: 'Beach View',
geohash: 'u33dc1',
createdAt: '2023-01-03T12:00:00.000Z',
osmTags: { name: 'Beach View' },
},
];
lists = [
{
@@ -74,27 +82,56 @@ module('Acceptance | collections navigation', function (hooks) {
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
assert
.dom('.app-menu')
.includesText('Collections', 'Menu contains Collections link');
.includesText('Saved Places', 'Menu contains Saved Places link');
// 3. Transition to Collections Index (List of lists)
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
// 3. Transition to Saved Places index (list of lists)
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Saved Places"
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
assert
.dom('.sidebar-header-text-centered')
.includesText('Collections', 'Header is centered and titled Collections');
.includesText(
'Saved Places',
'Header is centered and titled Saved Places'
);
assert
.dom('.lists-index-item')
.exists({ count: 2 }, 'Renders our 2 mocked list items');
.exists({ count: 3 }, 'Renders Saved plus our 2 mocked list items');
assert
.dom(document.querySelectorAll('.lists-index-item')[0])
.includesText('Saved', 'Saved appears as the first list item');
assert
.dom(document.querySelectorAll('.lists-index-item')[0])
.includesText('2 places', 'Saved shows the total saved places count');
// 4. Transition to a specific list (Want to go)
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
// 4. Transition to the synthetic Saved list
await click(document.querySelectorAll('.lists-index-item')[0]);
assert.strictEqual(
currentURL(),
'/lists/saved',
'Transitions to /lists/saved'
);
await waitFor('.places-list');
assert
.dom(document.querySelectorAll('.places-list .place-name')[0])
.hasText('Beach View', 'Saved Places shows newest saved place first');
assert
.dom(document.querySelectorAll('.places-list .place-name')[1])
.hasText('Mountain Trail', 'Saved Places includes all saved places');
// 5. Go back to the Saved Places index
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
// 6. Transition to a specific real list (Want to go)
await click(document.querySelectorAll('.lists-index-item')[1]); // Click "Want to go"
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Transitions instantly to /lists/to-go'
);
// 5. Verify background loading spinner shows up, then results populate
// 7. Verify background loading spinner shows up, then results populate
await waitFor('.places-list');
assert
.dom('.places-list .place-name')
@@ -103,19 +140,37 @@ module('Acceptance | collections navigation', function (hooks) {
.dom('.places-list .place-type')
.hasText('Saved place', 'Place type displays Saved place correctly');
// 6. Click back button in collection list header
// 8. Click back button in collection list header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
// 7. Click back button in collections index header
// 9. Click back button in collections index header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
// 8. Close sidebar
// 10. Close sidebar
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
});
test('clicking a place inside Saved Places sets returnToRoute and returns gracefully on back click', async function (assert) {
await visit('/lists/saved');
await waitFor('.places-list');
await click('.place-item');
assert.ok(
currentURL().includes('/place/place-456'),
'Transitions to the newest saved place details route'
);
await click('.back-btn');
assert.strictEqual(
currentURL(),
'/lists/saved',
'Returns gracefully back to the Saved Places view'
);
});
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
await visit('/lists/to-go');
await waitFor('.places-list');
@@ -136,7 +191,7 @@ module('Acceptance | collections navigation', function (hooks) {
);
});
test('places inside a collection are sorted by createdAt descending', async function (assert) {
test('places inside Saved Places are sorted by createdAt descending', async function (assert) {
class SortedMockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [
@@ -203,7 +258,7 @@ module('Acceptance | collections navigation', function (hooks) {
this.owner.unregister('service:storage');
this.owner.register('service:storage', SortedMockStorageService);
await visit('/lists/to-go');
await visit('/lists/saved');
await waitFor('.places-list');
const placeNames = Array.from(
@@ -213,7 +268,7 @@ module('Acceptance | collections navigation', function (hooks) {
assert.deepEqual(
placeNames,
['Newest Place', 'Middle Place', 'Oldest Place'],
'Places are ordered by createdAt in descending order'
'Saved Places are ordered by createdAt in descending order'
);
});
});
@@ -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(<template><Icon @name="zap" /></template>);
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(<template><Icon @name="zap" @size={{24}} /></template>);
assert.dom('.icon').hasAttribute('style', /width:24px;height:24px;/);
});
test('it supports custom @width and @height', async function (assert) {
await render(
<template><Icon @name="bitcoin" @width={{17}} @height={{22}} /></template>
);
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(
<template><Icon @name="bitcoin" @width={{17}} @size={{22}} /></template>
);
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(
<template><Icon @name="bitcoin" @height={{22}} @size={{18}} /></template>
);
assert.dom('.icon').hasAttribute('style', /width:18px;height:22px;/);
});
test('it supports explicit string units for width and height', async function (assert) {
await render(
<template><Icon @name="zap" @width="100%" @height="2rem" /></template>
);
assert.dom('.icon').hasAttribute('style', /width:100%;height:2rem;/);
});
test('it splats HTML attributes to the icon element', async function (assert) {
await render(
<template><Icon @name="zap" data-test-custom-icon="true" /></template>
);
assert.dom('[data-test-custom-icon="true"]').exists();
});
});
@@ -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(<template><PlaceDetails @place={{place}} /></template>);
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(<template><PlaceDetails @place={{place}} /></template>);
assert.dom('.place-payment-methods').doesNotExist();
});
});
@@ -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(<template><PlacePaymentMethods @tags={{hash}} /></template>);
assert.dom('.place-payment-methods').doesNotExist();
const otherTags = { amenity: 'restaurant', name: 'Bistro' };
await render(
<template><PlacePaymentMethods @tags={{otherTags}} /></template>
);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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(<template><PlacePaymentMethods @tags={{tags}} /></template>);
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'
);
});
});
+66
View File
@@ -0,0 +1,66 @@
import MapComponent from 'marco/components/map';
import { module, test } from 'qunit';
module('Unit | Component | map', function () {
test('it decodes Planetiler vector tile POI ids into OSM ids and types', function (assert) {
const feature = {
get(key) {
if (key === 'mvt:layer') return 'poi';
return undefined;
},
getId() {
return 12342;
},
};
const result = MapComponent.prototype.decodeVectorTileOsmFeature(feature);
assert.deepEqual(result, {
decoded: {
osmId: '1234',
osmType: 'way',
},
reason: null,
});
});
test('it ignores non-POI vector tile features', function (assert) {
const feature = {
get(key) {
if (key === 'mvt:layer') return 'transportation';
return undefined;
},
getId() {
return 12342;
},
};
assert.deepEqual(
MapComponent.prototype.decodeVectorTileOsmFeature(feature),
{
decoded: null,
reason: 'unsupported source layer: transportation',
}
);
});
test('it ignores unsupported or invalid vector tile ids', function (assert) {
const feature = {
get(key) {
if (key === 'mvt:layer') return 'poi';
return undefined;
},
getId() {
return 12340;
},
};
assert.deepEqual(
MapComponent.prototype.decodeVectorTileOsmFeature(feature),
{
decoded: null,
reason: 'feature id suffix 0 is not an OSM type',
}
);
});
});
+59
View File
@@ -787,6 +787,65 @@ module('Unit | Service | activity', function (hooks) {
assert.false(called, 'no fetch when items is empty');
});
test('loadMore does not append duplicate photo group', async function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === STRANGER_PUBKEY;
const NOW = Math.floor(Date.now() / 1000);
service._since = NOW;
const photo1 = makePhotoEvent({
id: 'd1'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: NOW - 100,
url: 'https://x.com/p1.jpg',
});
const photo2 = makePhotoEvent({
id: 'd2'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: NOW - 200,
url: 'https://x.com/p2.jpg',
});
const photo3 = makePhotoEvent({
id: 'd3'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: NOW - 300,
url: 'https://x.com/p3.jpg',
});
service._updateSocialItems([photo1, photo2, photo3]);
assert.strictEqual(
service.items.length,
1,
'one grouped entry with 3 photos'
);
assert.strictEqual(service.items[0].photos.length, 3);
service.nostrData.fetchActivityPhotos = async () => ({
cacheEvents: [photo1, photo2, photo3],
networkEvents: Promise.resolve([]),
});
await service.loadMore();
assert.strictEqual(service.items.length, 1, 'duplicate group not appended');
assert.strictEqual(
service.items[0].photos.length,
3,
'photo count unchanged'
);
});
test('stop resets isLoadingMore and isLoading', function (assert) {
const service = this.owner.lookup('service:activity');
service._isLoadingMore = true;
+218
View File
@@ -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);
});
});