Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
878387b9a8
|
||
|
|
0f6bfb7e74
|
||
|
|
faea3d26cd
|
||
|
|
a0eea6024a
|
||
|
|
ec0751de14
|
||
|
|
b295636799
|
||
|
|
7a8d4531f7
|
||
|
|
12e746c2a3
|
||
|
|
cb60e1d9b5
|
||
|
|
75c0862134
|
||
|
|
6ba2f1d132
|
||
|
|
a41ad8c99c
|
||
|
|
081138bfc0
|
||
|
|
1a490c5d04
|
||
|
|
f7eacf58d1
|
||
|
|
c8b9ddc1e5
|
||
|
|
e9be615c9a
|
||
|
|
a4e7e721ac
|
||
|
|
7d497c9afb
|
||
|
|
0d92fc9937
|
||
|
|
7e2bce84db
|
||
|
|
dccad0b47f
|
||
|
|
05160eb5f1
|
||
|
|
fb3b8bef39
|
||
|
|
c870a71e30
|
||
|
|
ddce25f43a
|
||
|
|
4653947454
|
@@ -9,7 +9,7 @@ import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -43,7 +43,7 @@ export default class AppHeaderComponent extends Component {
|
||||
if (qp?.q) {
|
||||
this.searchQuery = qp.q;
|
||||
} else if (qp?.category) {
|
||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||
const category = getCategoryById(qp.category);
|
||||
this.searchQuery = category ? category.label : qp.category;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
|
||||
@@ -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,6 +20,7 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
export default class AppMenuSettingsNostr extends Component {
|
||||
@service settings;
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service toast;
|
||||
|
||||
@tracked newReadRelay = '';
|
||||
@@ -367,109 +368,111 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
{{#if this.nostrAuth.isConnected}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to
|
||||
fallback servers</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
+20
-1
@@ -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>
|
||||
|
||||
+116
-1
@@ -21,6 +21,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIconNameForTags } from '../utils/osm-icons';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@@ -1005,6 +1006,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 +1198,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 +1213,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 +1266,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 +1292,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 +1321,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');
|
||||
@@ -1255,7 +1370,7 @@ export default class MapComponent extends Component {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
category: null, // Clear category to force spatial search
|
||||
category: NEARBY_CATEGORY.id, // Represent nearby as a first-class category
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { encodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
const MAX_NEVENT_RELAY_HINTS = 3;
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
let value = this.currentPhoto.eventId;
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
const nevent = encodePointer({
|
||||
id: this.currentPhoto.eventId,
|
||||
relays: this.nostrData
|
||||
.getEventRelays(this.currentPhoto.eventId)
|
||||
.slice(0, MAX_NEVENT_RELAY_HINTS),
|
||||
author: this.currentPhoto.pubkey,
|
||||
kind: 360,
|
||||
});
|
||||
if (nevent) value = nevent;
|
||||
} catch (err) {
|
||||
console.warn('Failed to encode nevent, copying raw event ID:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
@@ -290,7 +308,14 @@ export default class PhotoGallery extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish deletion event.');
|
||||
}
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish event.');
|
||||
}
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
this.toast.show('Photo published successfully');
|
||||
|
||||
@@ -11,6 +11,7 @@ import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@@ -150,7 +151,11 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
// New searches use ?category=nearby; keep supporting legacy lat/lon-only URLs
|
||||
return (
|
||||
qp.category === NEARBY_CATEGORY.id ||
|
||||
(!qp.q && !qp.category && qp.lat && qp.lon)
|
||||
);
|
||||
}
|
||||
|
||||
get hasHeaderPhoto() {
|
||||
|
||||
@@ -270,6 +270,7 @@ export default class SearchBoxComponent extends Component {
|
||||
(or
|
||||
(eq this.mapUi.loadingState.type "text")
|
||||
(eq this.mapUi.loadingState.type "category")
|
||||
(eq this.mapUi.loadingState.type "nearby")
|
||||
)
|
||||
}}
|
||||
<Icon @name="loading-ring" @size={{20}} />
|
||||
|
||||
@@ -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) || []
|
||||
|
||||
+64
-40
@@ -2,6 +2,7 @@ import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { getDistance } from '../utils/geo';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
@service osm;
|
||||
@@ -19,6 +20,40 @@ export default class SearchController extends Controller {
|
||||
selected = null;
|
||||
category = null;
|
||||
|
||||
async fetchNearbyPois(lat, lon) {
|
||||
const searchRadius = 50;
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from the search center
|
||||
return pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
@@ -50,8 +85,22 @@ export default class SearchController extends Controller {
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
// Case 0: Nearby Search (map click or ?category=nearby)
|
||||
const isNearbySearch =
|
||||
lat &&
|
||||
lon &&
|
||||
!params.q &&
|
||||
(!params.category || params.category === NEARBY_CATEGORY.id);
|
||||
|
||||
if (isNearbySearch) {
|
||||
loadingType = 'nearby';
|
||||
loadingValue = 'nearby';
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
pois = await this.fetchNearbyPois(lat, lon);
|
||||
}
|
||||
// Case 1: Category Search (category parameter present)
|
||||
else if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
@@ -88,7 +137,7 @@ export default class SearchController extends Controller {
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
// Case 2: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
@@ -121,43 +170,6 @@ export default class SearchController extends Controller {
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search request failed.', error);
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
@@ -176,6 +188,18 @@ export default class SearchController extends Controller {
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
// A search with results is a restorable context (e.g. for "up" navigation
|
||||
// from place details). Empty searches are intentionally not restorable, so
|
||||
// they don't reopen an invisible "no results" drawer later on.
|
||||
if (pois.length > 0) {
|
||||
this.mapUi.currentSearch = {
|
||||
q: params.q,
|
||||
category: params.category,
|
||||
lat: params.lat,
|
||||
lon: params.lon,
|
||||
};
|
||||
}
|
||||
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
|
||||
@@ -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 |
@@ -8,6 +8,10 @@ export default class ActivityRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
// Re-request profiles for senders that couldn't be resolved when the feed
|
||||
// was first loaded (e.g. metadata published since then). No-op on the first
|
||||
// entry, when there are no items yet.
|
||||
void this.activity.retryUnresolvedProfiles();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@ export default class SearchRoute extends Route {
|
||||
|
||||
// Trigger the background task to fetch results
|
||||
controller.fetchResultsTask.perform(model);
|
||||
|
||||
// Store current search params to allow "Up" navigation from place details
|
||||
const { q, category, lat, lon } = this.paramsFor('search');
|
||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
|
||||
@@ -119,6 +119,27 @@ export default class ActivityService extends Service {
|
||||
this.isLoading = false;
|
||||
this._cacheReadyCallback = null;
|
||||
}
|
||||
|
||||
void this.retryUnresolvedProfiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-requests kind 0 metadata for senders whose profiles could not be
|
||||
* resolved. Called when the activity view is re-entered or the source mode
|
||||
* changes, so a profile published after the feed was first loaded can still
|
||||
* show up in the same session.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async retryUnresolvedProfiles() {
|
||||
const pubkeys = new Set();
|
||||
for (const item of this.items) {
|
||||
if (item.senderPubkey && item.senderProfileLoading) {
|
||||
pubkeys.add(item.senderPubkey);
|
||||
}
|
||||
}
|
||||
if (pubkeys.size === 0) return;
|
||||
await this.nostrData.refreshProfiles([...pubkeys]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,11 +205,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 +234,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;
|
||||
|
||||
@@ -95,6 +95,10 @@ export default class NostrDataService extends Service {
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
// Pubkeys whose profiles have already been requested at least once. Used to
|
||||
// distinguish a first lookup (handled by the ProfileModel loader) from a
|
||||
// retry on place re-entry, so we don't duplicate the initial request.
|
||||
_attemptedProfilePubkeys = new Set();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
@@ -335,6 +339,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
// Public getter for the normalized relays an event is known to have been
|
||||
// seen on. Used to build shareable `nevent` pointers with relay hints.
|
||||
getEventRelays(eventId) {
|
||||
const relays = this._eventRelays.get(eventId);
|
||||
return relays ? uniqNormalizedRelays([...relays]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the result of publishing an event to relays.
|
||||
*
|
||||
* Successful relays are merged into the provenance map so the event gets
|
||||
* correct relay hints (e.g. in `nevent` pointers) and trust checks. The full
|
||||
* per-relay responses are only debug-logged for now; this is the intended
|
||||
* hook for a future publishing-status store/UI.
|
||||
*
|
||||
* @param {string} eventId The published event id
|
||||
* @param {Array<{ok: boolean, message?: string, from: string}>} responses
|
||||
*/
|
||||
recordPublishResult(eventId, responses = []) {
|
||||
console.debug('[nostr-data] Publish result', eventId, responses);
|
||||
for (const res of responses || []) {
|
||||
if (res?.ok && res.from) {
|
||||
this._recordProvenance(eventId, res.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
@@ -922,6 +953,8 @@ export default class NostrDataService extends Service {
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
);
|
||||
|
||||
const retryPubkeys = [];
|
||||
|
||||
for (const pubkey of newPubkeys) {
|
||||
const sub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
@@ -929,7 +962,60 @@ export default class NostrDataService extends Service {
|
||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||
});
|
||||
this._profileModelSubs.set(pubkey, sub);
|
||||
|
||||
// If we already tried this pubkey before (e.g. selecting the same place
|
||||
// again) and it still isn't in the store, explicitly re-request it. The
|
||||
// ProfileModel loader only runs once per cached model, so without this a
|
||||
// profile published after the first lookup would never be retried.
|
||||
if (
|
||||
this._attemptedProfilePubkeys.has(pubkey) &&
|
||||
!this.store.getReplaceable(0, pubkey)
|
||||
) {
|
||||
retryPubkeys.push(pubkey);
|
||||
}
|
||||
this._attemptedProfilePubkeys.add(pubkey);
|
||||
}
|
||||
|
||||
if (retryPubkeys.length > 0) {
|
||||
void this.refreshProfiles(retryPubkeys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-requests kind 0 metadata for the given pubkeys from the directory and
|
||||
* active read relays, adding any results to the event store.
|
||||
*
|
||||
* Used to retry profiles that were not available on a first lookup (e.g. the
|
||||
* author published their metadata after their content was loaded, or a
|
||||
* cached applesauce model prevented the fallback loader from running again).
|
||||
* Inserting the events into the store notifies any active `ProfileModel`
|
||||
* subscriptions, so callers don't need to manage subscriptions themselves.
|
||||
*
|
||||
* @param {string[]} pubkeys Pubkeys to refresh
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshProfiles(pubkeys) {
|
||||
const unique = [...new Set(pubkeys)].filter(Boolean);
|
||||
if (unique.length === 0) return;
|
||||
|
||||
const relays = uniqNormalizedRelays([
|
||||
...DIRECTORY_RELAYS,
|
||||
...this.activeReadRelays,
|
||||
]);
|
||||
const filters = this._batchAuthorFilters(unique, [0]);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
this.nostrRelay.pool
|
||||
.request(relays, filters)
|
||||
.pipe(timeout({ first: 15_000 }))
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: () => resolve(),
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(pubkey) {
|
||||
@@ -1257,6 +1343,7 @@ export default class NostrDataService extends Service {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
this._attemptedProfilePubkeys.clear();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
|
||||
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
|
||||
if (!relays || relays.length === 0) {
|
||||
throw new Error('No relays provided to publish the event.');
|
||||
}
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
// The publish method is a wrapper around the event method that returns a
|
||||
// Promise<PublishResponse[]> and automatically handles reconnecting and
|
||||
// retrying. It resolves with the per-relay responses even when no relay
|
||||
// accepted the event; callers are responsible for checking `ok` so that
|
||||
// failed attempts can still be recorded.
|
||||
return await this.pool.publish(relays, event);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ out center;
|
||||
|
||||
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||
const category = getCategoryById(categoryId);
|
||||
if (!category || !bounds) return [];
|
||||
if (!category || !Array.isArray(category.filter) || !bounds) return [];
|
||||
|
||||
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,9 @@ export default class ApplicationComponent extends Component {
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
} else if (name === 'search' && this.mapUi.searchResults.length === 0) {
|
||||
// An empty search has no markers worth keeping, so clear the route
|
||||
this.router.replaceWith('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,14 +18,20 @@ export default class SearchTemplate extends Component {
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
// We don't need to manually set currentSearch here because
|
||||
// it was already set in the route's setupController
|
||||
// it was already set in the search controller
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.mapUi.hideSidebar();
|
||||
// With no results there is nothing to keep around (no map markers), so
|
||||
// dismissing the drawer clears the search route entirely.
|
||||
if (this.mapUi.searchResults.length === 0) {
|
||||
this.router.replaceWith('index');
|
||||
} else {
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,18 @@ export const POI_CATEGORIES = [
|
||||
},
|
||||
];
|
||||
|
||||
// Special-cased category for spatial "Nearby" searches (triggered by clicking
|
||||
// the map). It intentionally lives outside POI_CATEGORIES so it does not show
|
||||
// up as a quick-search chip or in the search autocomplete. Like other
|
||||
// categories it is represented in the URL as ?category=nearby, which lets the
|
||||
// UI display its label and offer a clear button.
|
||||
export const NEARBY_CATEGORY = {
|
||||
id: 'nearby',
|
||||
label: 'Nearby',
|
||||
icon: 'target',
|
||||
};
|
||||
|
||||
export function getCategoryById(id) {
|
||||
if (id === NEARBY_CATEGORY.id) return NEARBY_CATEGORY;
|
||||
return POI_CATEGORIES.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.2",
|
||||
"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
@@ -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-etcSle_0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -31,6 +31,8 @@ class MockActivityService extends Service {
|
||||
|
||||
async setSourceMode() {}
|
||||
|
||||
async retryUnresolvedProfiles() {}
|
||||
|
||||
loadMore() {}
|
||||
|
||||
stop() {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,4 +129,47 @@ module('Acceptance | navigation', function (hooks) {
|
||||
backStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty search is not restored when navigating back from a place', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// Nearby search that yields no results, but with a fetchable place
|
||||
this.owner.register(
|
||||
'service:osm',
|
||||
class extends Service {
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
assert.strictEqual(
|
||||
mapUi.currentSearch,
|
||||
null,
|
||||
'Empty search is not restorable'
|
||||
);
|
||||
|
||||
// Simulate opening a place while the (empty) search route is active
|
||||
mapUi.returnToSearch = true;
|
||||
|
||||
await visit('/place/osm:node:123');
|
||||
await click('.back-btn');
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/',
|
||||
'Back navigation does not reopen the empty search'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,19 @@ class MockOsmService extends Service {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
return new Promise((resolve) => {
|
||||
osmResolve = () => {
|
||||
resolve([
|
||||
{
|
||||
title: 'Nearby Place',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
},
|
||||
]);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +80,7 @@ module('Acceptance | search loading', function (hooks) {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||
test('search shows loading indicator for text, category and nearby searches', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// 1. Text Search
|
||||
@@ -121,11 +133,25 @@ module('Acceptance | search loading', function (hooks) {
|
||||
);
|
||||
|
||||
// 3. Nearby Search
|
||||
await visit('/search?lat=1&lon=1');
|
||||
const nearbyPromise = visit('/search?category=nearby&lat=1&lon=1');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
assert.deepEqual(
|
||||
mapUi.loadingState,
|
||||
{ type: 'nearby', value: 'nearby' },
|
||||
'Loading state is set for nearby search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise
|
||||
osmResolve();
|
||||
|
||||
await nearbyPromise;
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
'Loading state is NOT set for nearby search'
|
||||
'Loading state is cleared after nearby search'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL } from '@ember/test-helpers';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
@@ -277,6 +277,9 @@ module('Acceptance | search', function (hooks) {
|
||||
async getCategoryPois() {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
@@ -336,10 +339,117 @@ module('Acceptance | search', function (hooks) {
|
||||
'Search input is populated with mapped category label'
|
||||
);
|
||||
|
||||
// 3. Go back to index
|
||||
// 3. Visit a nearby search URL
|
||||
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('Nearby', 'Search input is populated with the Nearby label');
|
||||
|
||||
// 4. Go back to index
|
||||
await visit('/');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('', 'Search input is cleared on transitioning to index');
|
||||
});
|
||||
|
||||
test('closing the drawer clears the search route when there are no results', async function (assert) {
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?q=nowhere');
|
||||
|
||||
assert.dom('.empty-state').hasText('No results found.');
|
||||
assert.strictEqual(
|
||||
mapUi.currentSearch,
|
||||
null,
|
||||
'An empty search is not kept as a restorable search context'
|
||||
);
|
||||
|
||||
await click('.close-btn');
|
||||
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.strictEqual(currentURL(), '/', 'Search route is cleared');
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||
});
|
||||
|
||||
test('nearby search state is shown in the search box and can be dismissed', async function (assert) {
|
||||
class MockOsmService extends Service {
|
||||
cancelAll() {}
|
||||
async getNearbyPois() {
|
||||
return [
|
||||
{
|
||||
title: 'Nearby Cafe',
|
||||
lat: 52.521,
|
||||
lon: 13.406,
|
||||
osmId: '789',
|
||||
osmType: 'N',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||
|
||||
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||
assert.dom('.search-input').hasValue('Nearby', 'Input indicates Nearby');
|
||||
|
||||
// Closing the sidebar keeps the search (and its markers) active, so the
|
||||
// search box must still indicate the Nearby search and offer dismissal.
|
||||
await click('.close-btn');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('category=nearby'),
|
||||
'Still on the nearby search route with markers visible'
|
||||
);
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('Nearby', 'Input still indicates Nearby after closing sidebar');
|
||||
assert.dom('.search-clear-btn').exists('Clear button remains available');
|
||||
|
||||
await click('.search-clear-btn');
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Dismissing nearby clears the route');
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,16 @@ export class MockNostrDataService extends Service {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
getEventRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
recordPublishResult() {}
|
||||
|
||||
refreshProfiles() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
@@ -105,14 +115,14 @@ export class MockNostrDataService extends Service {
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true }];
|
||||
return [{ ok: true, from: 'wss://relay.test' }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
@@ -67,6 +68,10 @@ class MockNostrDataService extends Service {
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked isConnected = true;
|
||||
}
|
||||
|
||||
function readRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[0];
|
||||
return [...list.querySelectorAll('li')];
|
||||
@@ -88,6 +93,7 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
localStorage.removeItem('marco:settings');
|
||||
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
this.settings = this.owner.lookup('service:settings');
|
||||
this.onChange = () => {};
|
||||
});
|
||||
@@ -283,6 +289,37 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('publish-only settings are hidden when no nostr key is connected', async function (assert) {
|
||||
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
assert.dom('#new-write-relay').doesNotExist('write relay input hidden');
|
||||
assert
|
||||
.dom('#nostr-media-server')
|
||||
.doesNotExist('media server select hidden');
|
||||
assert
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.doesNotExist('fallback uploads select hidden');
|
||||
assert.strictEqual(
|
||||
element.querySelectorAll('.relay-list').length,
|
||||
1,
|
||||
'only the read relay list is rendered'
|
||||
);
|
||||
});
|
||||
|
||||
test('read relays and cached data stay visible when no nostr key is connected', async function (assert) {
|
||||
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
assert.dom('#new-read-relay').exists('read relay input visible');
|
||||
assert.dom(readRows(element)[0]).exists('read relay list visible');
|
||||
assert
|
||||
.dom(element.querySelector('.btn-outline'))
|
||||
.includesText('Clear profiles, photos, and reviews');
|
||||
});
|
||||
|
||||
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
|
||||
@@ -22,6 +22,9 @@ module('Integration | Component | category-chips', function (hooks) {
|
||||
// Check for some expected labels
|
||||
assert.dom(this.element).includesText('Restaurants');
|
||||
assert.dom(this.element).includesText('Coffee');
|
||||
|
||||
// Nearby is an internal-only category and must not appear as a chip
|
||||
assert.dom(this.element).doesNotIncludeText('Nearby');
|
||||
});
|
||||
|
||||
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { decodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
@@ -163,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
const publishStub = sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||
const toastSpy = sinon.spy(this.toast, 'show');
|
||||
|
||||
await render(
|
||||
@@ -224,6 +228,17 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
'added kind 5 event to local store'
|
||||
);
|
||||
|
||||
// Check publish result recording
|
||||
assert.ok(
|
||||
recordPublishSpy.calledOnce,
|
||||
'nostrData.recordPublishResult was called'
|
||||
);
|
||||
assert.strictEqual(
|
||||
recordPublishSpy.firstCall.args[0],
|
||||
publishedEvent.id,
|
||||
'publish result recorded for the deletion event'
|
||||
);
|
||||
|
||||
// Check UX
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Photo deleted successfully'),
|
||||
@@ -261,7 +276,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
sinon.stub(window, 'confirm').returns(true);
|
||||
sinon.stub(this.blossom, 'delete').resolves();
|
||||
sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
sinon.stub(this.nostrData.store, 'add');
|
||||
|
||||
await render(
|
||||
@@ -284,9 +301,27 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
const eventId = '1'.repeat(64);
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.photos = [
|
||||
{
|
||||
eventId,
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo.jpg',
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
sinon
|
||||
.stub(this.nostrData, 'getEventRelays')
|
||||
.returns([
|
||||
'wss://a.test',
|
||||
'wss://b.test',
|
||||
'wss://c.test',
|
||||
'wss://d.test',
|
||||
'wss://e.test',
|
||||
]);
|
||||
const clipboardStub = sinon
|
||||
.stub(navigator.clipboard, 'writeText')
|
||||
.resolves();
|
||||
@@ -317,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
await click(copyBtn);
|
||||
|
||||
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||
const copied = clipboardStub.firstCall.args[0];
|
||||
assert.ok(
|
||||
copied.startsWith('nevent1'),
|
||||
'copied value is an nevent identifier'
|
||||
);
|
||||
const decoded = decodePointer(copied);
|
||||
assert.strictEqual(decoded.type, 'nevent', 'decoded value is an nevent');
|
||||
assert.strictEqual(
|
||||
decoded.data.id,
|
||||
eventId,
|
||||
'decoded nevent references the correct event id'
|
||||
);
|
||||
assert.deepEqual(
|
||||
decoded.data.relays,
|
||||
['wss://a.test', 'wss://b.test', 'wss://c.test'],
|
||||
'decoded nevent includes at most 3 relay hints'
|
||||
);
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||
'success toast was shown'
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,12 @@ function makePhotoEvent(opts = {}) {
|
||||
class MockNostrDataService extends Service {
|
||||
@tracked profiles = {};
|
||||
_contactPubkeys = new Set();
|
||||
refreshProfilesCalls = [];
|
||||
|
||||
refreshProfiles(pubkeys) {
|
||||
this.refreshProfilesCalls.push(pubkeys);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
store = {
|
||||
events: new Map(),
|
||||
@@ -301,6 +307,115 @@ module('Unit | Service | activity', function (hooks) {
|
||||
assert.false(entry.senderProfileLoading);
|
||||
});
|
||||
|
||||
test('retryUnresolvedProfiles refreshes only unresolved sender pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service._sourceMode = 'explore';
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
|
||||
const OTHER_PUBKEY = 'c'.repeat(64);
|
||||
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||
|
||||
const resolvedPhoto = makePhotoEvent({
|
||||
id: 'rp'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
});
|
||||
const unresolvedPhoto = makePhotoEvent({
|
||||
id: 'up'.padEnd(64, '0'),
|
||||
author: OTHER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:200',
|
||||
created_at: 2000,
|
||||
});
|
||||
service._updateSocialItems([resolvedPhoto, unresolvedPhoto]);
|
||||
|
||||
assert.strictEqual(service.items.length, 2, 'both entries present');
|
||||
assert.false(
|
||||
service.items.find((i) => i.senderPubkey === SENDER_PUBKEY)
|
||||
.senderProfileLoading,
|
||||
'resolved sender not loading'
|
||||
);
|
||||
assert.true(
|
||||
service.items.find((i) => i.senderPubkey === OTHER_PUBKEY)
|
||||
.senderProfileLoading,
|
||||
'unresolved sender still loading'
|
||||
);
|
||||
|
||||
await service.retryUnresolvedProfiles();
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
1,
|
||||
'refresh called once'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.nostrData.refreshProfilesCalls[0],
|
||||
[OTHER_PUBKEY],
|
||||
'only the unresolved sender is refreshed'
|
||||
);
|
||||
});
|
||||
|
||||
test('retryUnresolvedProfiles does nothing when all senders are resolved', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service._sourceMode = 'explore';
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||
|
||||
service._updateSocialItems([
|
||||
makePhotoEvent({
|
||||
id: 'rp2'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.retryUnresolvedProfiles();
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
0,
|
||||
'no refresh when every sender is resolved'
|
||||
);
|
||||
});
|
||||
|
||||
test('setSourceMode retries unresolved profiles after fetching', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
|
||||
const photo = makePhotoEvent({
|
||||
id: 'sm1'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
});
|
||||
|
||||
service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => {
|
||||
if (mode === 'explore')
|
||||
return { cacheEvents: [photo], networkEvents: Promise.resolve([]) };
|
||||
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||
};
|
||||
|
||||
await service.setSourceMode('explore');
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
1,
|
||||
'refresh triggered by mode switch'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.nostrData.refreshProfilesCalls[0],
|
||||
[SENDER_PUBKEY],
|
||||
'refreshes the unresolved sender'
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateSocialItems groups photos by author + place', function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
@@ -787,6 +902,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;
|
||||
|
||||
@@ -498,6 +498,56 @@ module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
|
||||
test('getEventRelays returns normalized relays for an event', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(904);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example/');
|
||||
service._recordProvenance(eventId, 'WSS://Two.Example');
|
||||
|
||||
assert.deepEqual(
|
||||
service.getEventRelays(eventId).sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'returns normalized, unique relays'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.getEventRelays('unknown-event'),
|
||||
[],
|
||||
'returns an empty array for unknown events'
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPublishResult records only successful relays into provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(905);
|
||||
|
||||
service.recordPublishResult(eventId, [
|
||||
{ ok: true, from: 'wss://accepted.example' },
|
||||
{ ok: false, from: 'wss://rejected.example', message: 'blocked' },
|
||||
]);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://accepted.example'),
|
||||
'accepted relay is recorded'
|
||||
);
|
||||
assert.false(
|
||||
relays.has('wss://rejected.example'),
|
||||
'rejected relay is not recorded'
|
||||
);
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted,
|
||||
['wss://accepted.example'],
|
||||
'accepted relay is persisted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
@@ -1287,3 +1337,87 @@ module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | profiles', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
function makeProfileEvent(pubkey, content, opts = {}) {
|
||||
return {
|
||||
id: opts.id || makeEventId(3000),
|
||||
pubkey,
|
||||
kind: 0,
|
||||
created_at: opts.created_at || 1000,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
test('refreshProfiles requests kind 0 for the given pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(80);
|
||||
|
||||
await service.refreshProfiles([pubkey]);
|
||||
|
||||
const filter = this.requestedFilters.find((f) => f.kinds?.includes(0));
|
||||
assert.ok(filter, 'requested kind 0');
|
||||
assert.deepEqual(filter.authors, [pubkey], 'requested the pubkey');
|
||||
});
|
||||
|
||||
test('refreshProfiles adds returned profiles to the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(81);
|
||||
const event = makeProfileEvent(pubkey, { name: 'Alice' });
|
||||
|
||||
service.nostrRelay.pool.request = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return of(event);
|
||||
};
|
||||
|
||||
await service.refreshProfiles([pubkey]);
|
||||
|
||||
assert.true(service.store.hasReplaceable(0, pubkey), 'profile stored');
|
||||
});
|
||||
|
||||
test('refreshProfiles deduplicates input and skips empty input', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(82);
|
||||
|
||||
await service.refreshProfiles([pubkey, pubkey, null]);
|
||||
await service.refreshProfiles([]);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) => f.kinds?.includes(0));
|
||||
assert.strictEqual(filters.length, 1, 'one request for deduped input');
|
||||
assert.deepEqual(filters[0].authors, [pubkey], 'deduped authors');
|
||||
});
|
||||
|
||||
test('loadProfiles re-requests an unresolved profile on a second call', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(83);
|
||||
|
||||
// First call: the ProfileModel loader handles it, no explicit refresh.
|
||||
service.loadProfiles([pubkey]);
|
||||
assert.strictEqual(
|
||||
this.requestedFilters.filter((f) => f.kinds?.includes(0)).length,
|
||||
0,
|
||||
'first lookup is not refreshed explicitly'
|
||||
);
|
||||
|
||||
// Re-entering the place clears the model subscriptions, but the ProfileModel
|
||||
// stays cached and its loader won't run again. The second call should retry.
|
||||
service._clearProfileSubs();
|
||||
service.loadProfiles([pubkey]);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) => f.kinds?.includes(0));
|
||||
assert.strictEqual(
|
||||
filters.length,
|
||||
1,
|
||||
'second lookup refreshes the profile'
|
||||
);
|
||||
assert.deepEqual(
|
||||
filters[0].authors,
|
||||
[pubkey],
|
||||
'refreshes the missing pubkey'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user