From b9103b28a951c550cc38dc7776924a45b96e2bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 24 Sep 2026 19:02:27 +0200 Subject: [PATCH] Add operator info to place details --- app/components/place-details.gjs | 103 +++++++++++++++++ app/utils/icons.js | 2 + .../components/place-details-test.gjs | 108 ++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index ef6e3da..3d46e0f 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -316,6 +316,70 @@ export default class PlaceDetails extends Component { return this.formatMultiLine(val); } + get operatorNames() { + const raw = this.tags.operator; + if (!raw) return null; + const values = raw + .split(';') + .map((value) => value.trim()) + .filter( + (value) => + value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no' + ); + if (values.length === 0) return null; + return this.formatMultiLine(values.join(';')); + } + + get operatorWebsite() { + const val = this.tags['operator:website']; + return val ? val.trim() : null; + } + + get operatorWebsiteLabel() { + return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null; + } + + get operatorWikidata() { + const val = this.tags['operator:wikidata']; + if (!val) return null; + return ( + val + .split(';') + .map((v) => v.trim()) + .filter(Boolean)[0] || null + ); + } + + get operatorWikipedia() { + const val = this.tags['operator:wikipedia']; + if (!val) return null; + return ( + val + .split(';') + .map((v) => v.trim()) + .filter(Boolean)[0] || null + ); + } + + get operatorWikipediaUrl() { + const val = this.operatorWikipedia; + if (!val) return null; + const match = val.match(/^([a-z-]+):(.+)$/i); + if (match) { + return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`; + } + return `https://wikipedia.org/wiki/${val}`; + } + + get showOperator() { + return !!( + this.operatorNames || + this.operatorWebsite || + this.operatorWikidata || + this.operatorWikipedia + ); + } + get cuisine() { if (!this.tags.cuisine) return null; return this.tags.cuisine @@ -490,6 +554,45 @@ export default class PlaceDetails extends Component {

{{/if}} + {{#if this.showOperator}} +

+ + + {{this.operatorNames}} + {{#if this.operatorWebsite}} +
+ + {{this.operatorWebsiteLabel}} + + {{/if}} + {{#if this.operatorWikidata}} +
+ + Wikidata + + {{/if}} + {{#if this.operatorWikipedia}} +
+ + Wikipedia + + {{/if}} +
+

+ {{/if}} + {{#if this.phone}}

diff --git a/app/utils/icons.js b/app/utils/icons.js index 7bd5898..85f7d1d 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -5,6 +5,7 @@ import activity from 'feather-icons/dist/icons/activity.svg?raw'; import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw'; import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw'; +import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw'; import featherCamera from 'feather-icons/dist/icons/camera.svg?raw'; import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw'; import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw'; @@ -182,6 +183,7 @@ const ICONS = { 'boxing-glove-up': boxingGloveUp, 'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw, bridge, + briefcase, bus, camera, 'feather-camera': featherCamera, diff --git a/tests/integration/components/place-details-test.gjs b/tests/integration/components/place-details-test.gjs index 5b28284..fdd0024 100644 --- a/tests/integration/components/place-details-test.gjs +++ b/tests/integration/components/place-details-test.gjs @@ -457,4 +457,112 @@ module('Integration | Component | place-details', function (hooks) { assert.dom('.place-payment-methods').doesNotExist(); }); + + test('it renders the operator name with a briefcase icon', async function (assert) { + const place = { + title: 'Operated Cafe', + osmTags: { + operator: 'Le Méridien', + }, + }; + + await render(); + + const operatorBlock = this.element.querySelector( + '.meta-info .content-with-icon span.icon[title="Operator"]' + ); + assert.ok(operatorBlock, 'Operator block is rendered'); + + const row = operatorBlock.closest('.content-with-icon'); + assert.dom(row).includesText('Le Méridien'); + }); + + test('it splits semicolon-separated operators onto separate lines', async function (assert) { + const place = { + title: 'Multi Operator Stop', + osmTags: { + operator: 'De Lijn;TEC', + }, + }; + + await render(); + + const row = this.element + .querySelector('span.icon[title="Operator"]') + .closest('.content-with-icon'); + + assert.dom(row).includesText('De Lijn'); + assert.dom(row).includesText('TEC'); + assert.true( + row.querySelector('span:not(.icon)').innerHTML.includes('
'), + 'Operators are separated by a line break' + ); + }); + + test('it suppresses invalid operator values of yes and no', async function (assert) { + const place = { + title: 'Invalid Operator', + osmTags: { + operator: 'yes', + }, + }; + + await render(); + + assert + .dom('span.icon[title="Operator"]') + .doesNotExist('Operator block is hidden for operator=yes'); + }); + + test('it renders operator wikidata and website links', async function (assert) { + const place = { + title: 'Linked Operator', + osmTags: { + operator: 'Flixbus', + 'operator:wikidata': 'Q15712278', + 'operator:website': 'https://www.flixbus.com/', + 'operator:wikipedia': 'en:FlixBus', + }, + }; + + await render(); + + const row = this.element + .querySelector('span.icon[title="Operator"]') + .closest('.content-with-icon'); + + const wikidataLink = row.querySelector( + 'a[href="https://www.wikidata.org/wiki/Q15712278"]' + ); + assert.ok(wikidataLink, 'Wikidata link is rendered'); + assert.dom(wikidataLink).hasText('Wikidata'); + + const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]'); + assert.ok(websiteLink, 'Operator website link is rendered'); + assert.dom(websiteLink).hasText('www.flixbus.com'); + + const wikipediaLink = row.querySelector( + 'a[href="https://en.wikipedia.org/wiki/FlixBus"]' + ); + assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered'); + assert.dom(wikipediaLink).hasText('Wikipedia'); + }); + + test('it renders the operator block when only operator subkeys are present', async function (assert) { + const place = { + title: 'Subkey Only', + osmTags: { + 'operator:wikidata': 'Q42', + }, + }; + + await render(); + + assert + .dom('span.icon[title="Operator"]') + .exists('Operator block is rendered from subkeys alone'); + assert + .dom('a[href="https://www.wikidata.org/wiki/Q42"]') + .exists('Wikidata link is rendered'); + }); });