Compare commits
19 Commits
2dfd411837
...
v1.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
9cdd021cda
|
|||
|
ef53870b35
|
|||
|
918a794784
|
|||
|
344a3067fa
|
|||
|
ad3e6ea402
|
|||
|
9e2545da7b
|
|||
|
480c97fb9d
|
|||
|
179cf49370
|
|||
|
aea0388267
|
|||
|
e4d02cda26
|
|||
|
27ebbaca60
|
|||
|
cbdd056dcb
|
|||
|
2423b67f94
|
|||
|
2a3ad26eb9
|
|||
|
9d06898b15
|
|||
|
6df43edbf9
|
|||
|
e2fcdce154
|
|||
|
829dff9839
|
|||
|
e7dfed204e
|
@@ -1,8 +1,12 @@
|
||||
# This file is committed to git and should not contain any secrets.
|
||||
#
|
||||
#
|
||||
# Vite recommends using .env.local or .env.[mode].local if you need to manage secrets
|
||||
# SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
|
||||
|
||||
|
||||
# Default NODE_ENV with vite build --mode=test is production
|
||||
NODE_ENV=development
|
||||
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
|
||||
3
.env.production
Normal file
3
.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
@@ -284,7 +284,9 @@ export default class MapComponent extends Component {
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
});
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
this.searchOverlayElement.className = 'search-pulse';
|
||||
@@ -392,7 +394,10 @@ export default class MapComponent extends Component {
|
||||
const locateElement = document.createElement('div');
|
||||
locateElement.className = 'ol-control ol-locate';
|
||||
const locateBtn = document.createElement('button');
|
||||
locateBtn.innerHTML = '⊙';
|
||||
locateBtn.style.display = 'flex';
|
||||
locateBtn.style.alignItems = 'center';
|
||||
locateBtn.style.justifyContent = 'center';
|
||||
locateBtn.innerHTML = `<span class="icon" style="width: 14px; height: 14px; display: flex;">${getIcon('navigation')}</span>`;
|
||||
locateBtn.title = 'Locate Me';
|
||||
locateElement.appendChild(locateBtn);
|
||||
|
||||
|
||||
@@ -130,15 +130,24 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
formatMultiLine(val, type) {
|
||||
if (!val) return null;
|
||||
const parts = val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const parts = [
|
||||
...new Set(
|
||||
val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
if (type === 'phone') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="tel:${safeTel}">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +157,17 @@ export default class PlaceDetails extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'whatsapp') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
@@ -165,8 +185,27 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get phone() {
|
||||
const val = this.tags.phone || this.tags['contact:phone'];
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
const rawValues = [
|
||||
this.tags.phone,
|
||||
this.tags['contact:phone'],
|
||||
this.tags.mobile,
|
||||
this.tags['contact:mobile'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'phone');
|
||||
}
|
||||
|
||||
get whatsapp() {
|
||||
const rawValues = [
|
||||
this.tags.whatsapp,
|
||||
this.tags['contact:whatsapp'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
|
||||
}
|
||||
|
||||
get email() {
|
||||
@@ -343,6 +382,15 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.whatsapp}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="whatsapp" @title="WhatsApp" />
|
||||
<span>
|
||||
{{this.whatsapp}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class UserMenuComponent extends Component {
|
||||
@service storage;
|
||||
@service osmAuth;
|
||||
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
@@ -15,6 +19,17 @@ export default class UserMenuComponent extends Component {
|
||||
this.args.storage.disconnect();
|
||||
}
|
||||
|
||||
@action
|
||||
connectOsm() {
|
||||
this.args.onClose();
|
||||
this.osmAuth.login();
|
||||
}
|
||||
|
||||
@action
|
||||
disconnectOsm() {
|
||||
this.osmAuth.logout();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-menu-popover">
|
||||
<ul class="account-list">
|
||||
@@ -47,15 +62,32 @@ export default class UserMenuComponent extends Component {
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<li class="account-item">
|
||||
<div class="account-header">
|
||||
<div class="account-info">
|
||||
<Icon @name="map" @size={{18}} />
|
||||
<span>OpenStreetMap</span>
|
||||
</div>
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectOsm}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectOsm}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="account-status">
|
||||
Coming soon
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
<strong>{{this.osmAuth.userDisplayName}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
4
app/icons/whatsapp.svg
Normal file
4
app/icons/whatsapp.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="-1.66 0 740.82 740.82" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m630.06 107.66c-69.329-69.387-161.53-107.62-259.76-107.66-202.4 0-367.13 164.67-367.22 367.07-0.027 64.699 16.883 127.86 49.016 183.52l-52.095 190.23 194.67-51.047c53.634 29.244 114.02 44.656 175.48 44.682h0.151c202.38 0 367.13-164.69 367.21-367.09 0.039-98.088-38.121-190.32-107.45-259.71m-259.76 564.8h-0.125c-54.766-0.021-108.48-14.729-155.34-42.529l-11.146-6.613-115.52 30.293 30.834-112.59-7.258-11.543c-30.552-48.58-46.689-104.73-46.665-162.38 0.067-168.23 136.99-305.1 305.34-305.1 81.521 0.031 158.15 31.81 215.78 89.482s89.342 134.33 89.311 215.86c-0.07 168.24-136.99 305.12-305.21 305.12m167.42-228.51c-9.176-4.591-54.286-26.782-62.697-29.843-8.41-3.061-14.526-4.591-20.644 4.592-6.116 9.182-23.7 29.843-29.054 35.964-5.351 6.122-10.703 6.888-19.879 2.296-9.175-4.591-38.739-14.276-73.786-45.526-27.275-24.32-45.691-54.36-51.043-63.542-5.352-9.183-0.569-14.148 4.024-18.72 4.127-4.11 9.175-10.713 13.763-16.07 4.587-5.356 6.116-9.182 9.174-15.303 3.059-6.122 1.53-11.479-0.764-16.07s-20.643-49.739-28.29-68.104c-7.447-17.886-15.012-15.466-20.644-15.746-5.346-0.266-11.469-0.323-17.585-0.323-6.117 0-16.057 2.296-24.468 11.478-8.41 9.183-32.112 31.374-32.112 76.521s32.877 88.763 37.465 94.885c4.587 6.122 64.699 98.771 156.74 138.5 21.891 9.45 38.982 15.093 52.307 19.323 21.981 6.979 41.983 5.994 57.793 3.633 17.628-2.633 54.285-22.19 61.932-43.616 7.646-21.426 7.646-39.791 5.352-43.617-2.293-3.826-8.41-6.122-17.585-10.714" clip-rule="evenodd" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -10,4 +10,7 @@ Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
});
|
||||
|
||||
17
app/routes/oauth/osm-callback.js
Normal file
17
app/routes/oauth/osm-callback.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class OauthOsmCallbackRoute extends Route {
|
||||
@service osmAuth;
|
||||
@service router;
|
||||
|
||||
async model() {
|
||||
try {
|
||||
await this.osmAuth.handleCallback();
|
||||
} catch (e) {
|
||||
console.error('Failed to handle OSM OAuth callback', e);
|
||||
} finally {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/services/osm-auth.js
Normal file
116
app/services/osm-auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { OAuth2AuthCodePkceClient } from 'oauth2-pkce';
|
||||
|
||||
class MarcoOsmAuthStorage {
|
||||
saveState(serializedState) {
|
||||
localStorage.setItem('marco:osm_auth_state', serializedState);
|
||||
}
|
||||
loadState() {
|
||||
const state = localStorage.getItem('marco:osm_auth_state');
|
||||
if (!state) return false;
|
||||
try {
|
||||
JSON.parse(state);
|
||||
return state;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse OSM auth state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class OsmAuthService extends Service {
|
||||
@tracked isConnected = false;
|
||||
@tracked userDisplayName = null;
|
||||
|
||||
oauthClient;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const clientId =
|
||||
import.meta.env.VITE_OSM_CLIENT_ID || 'YOUR_CLIENT_ID_HERE';
|
||||
const oauthUrl =
|
||||
import.meta.env.VITE_OSM_OAUTH_URL || 'https://www.openstreetmap.org';
|
||||
|
||||
const redirectUrl =
|
||||
import.meta.env.VITE_OSM_REDIRECT_URI ||
|
||||
`${window.location.origin}/oauth/osm/callback`;
|
||||
|
||||
this.oauthClient = new OAuth2AuthCodePkceClient(
|
||||
{
|
||||
scopes: ['read_prefs', 'write_api'],
|
||||
authorizationUrl: `${oauthUrl}/oauth2/authorize`,
|
||||
tokenUrl: `${oauthUrl}/oauth2/token`,
|
||||
clientId: clientId,
|
||||
redirectUrl: redirectUrl,
|
||||
storeRefreshToken: true,
|
||||
},
|
||||
new MarcoOsmAuthStorage()
|
||||
);
|
||||
|
||||
this.restoreSession();
|
||||
}
|
||||
|
||||
async restoreSession() {
|
||||
try {
|
||||
await this.oauthClient.ready;
|
||||
} catch (e) {
|
||||
console.warn('oauthClient.ready failed', e);
|
||||
}
|
||||
const isAuthorized = await this.oauthClient.isAuthorized();
|
||||
if (isAuthorized) {
|
||||
this.isConnected = true;
|
||||
const storedName = localStorage.getItem('marco:osm_user_display_name');
|
||||
if (storedName) {
|
||||
this.userDisplayName = storedName;
|
||||
} else {
|
||||
await this.fetchUserInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
await this.oauthClient.requestAuthorizationCode();
|
||||
}
|
||||
|
||||
async handleCallback() {
|
||||
await this.oauthClient.receiveCode();
|
||||
await this.oauthClient.getTokens();
|
||||
this.isConnected = true;
|
||||
await this.fetchUserInfo();
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.oauthClient.reset();
|
||||
this.isConnected = false;
|
||||
this.userDisplayName = null;
|
||||
localStorage.removeItem('marco:osm_user_display_name');
|
||||
}
|
||||
|
||||
async fetchUserInfo() {
|
||||
try {
|
||||
const tokens = await this.oauthClient.getTokens();
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(
|
||||
'https://api.openstreetmap.org/api/0.6/user/details.json',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.debug('OSM data:', data);
|
||||
const displayName = data.user.display_name;
|
||||
this.userDisplayName = displayName;
|
||||
localStorage.setItem('marco:osm_user_display_name', displayName);
|
||||
} else {
|
||||
console.error('Failed to fetch OSM user info', response.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching OSM user info', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ html,
|
||||
body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -26,6 +28,7 @@ body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@@ -251,6 +254,10 @@ body {
|
||||
margin-left: calc(18px + 0.75rem);
|
||||
}
|
||||
|
||||
.account-status strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-item.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
@@ -260,7 +267,6 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
|
||||
@@ -110,6 +110,7 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||
import whatsapp from '../icons/whatsapp.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
@@ -218,6 +219,7 @@ const ICONS = {
|
||||
'village-buildings': villageBuildings,
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
@@ -229,6 +231,7 @@ const ICONS = {
|
||||
const FILLED_ICONS = [
|
||||
'fork-and-knife',
|
||||
'wikipedia',
|
||||
'whatsapp',
|
||||
'cup-and-saucer',
|
||||
'coffee-bean',
|
||||
'shopping-basket',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.17.2",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -104,6 +104,7 @@
|
||||
"dependencies": {
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"oauth2-pkce": "^2.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
ember-lifeline:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
||||
oauth2-pkce:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.28.5
|
||||
@@ -4150,6 +4153,9 @@ packages:
|
||||
nwsapi@2.2.23:
|
||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||
|
||||
oauth2-pkce@2.1.3:
|
||||
resolution: {integrity: sha512-DcMiG5XUKNabf0qV1GOEPo+zwIdYsO2K1FMhiP8XnSCJbujZLOnSC5BCYiCtD0le+aue2fK6qDDvGjcQNF53jQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -10261,6 +10267,8 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
|
||||
oauth2-pkce@2.1.3: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@1.3.1: {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
release/assets/main-BF2Ls-fG.css
Normal file
1
release/assets/main-BF2Ls-fG.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/main-iQCWrjXX.js
Normal file
2
release/assets/main-iQCWrjXX.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-B8Ckz4Ru.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css">
|
||||
<script type="module" crossorigin src="/assets/main-iQCWrjXX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -255,4 +255,83 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
assert.dom('.actions button').hasText('Save');
|
||||
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||
});
|
||||
|
||||
test('it aggregates phone and mobile tags without duplicates', async function (assert) {
|
||||
const place = {
|
||||
title: 'Phone Shop',
|
||||
osmTags: {
|
||||
phone: '+1-234-567-8900',
|
||||
'contact:phone': '+1-234-567-8900; +1 000 000 0000',
|
||||
mobile: '+1 987 654 3210',
|
||||
'contact:mobile': '+1 987 654 3210',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
// Use specific selector for the phone block since there's no cuisine or opening_hours
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const phoneBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="Phone"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(phoneBlock, 'Phone block is rendered');
|
||||
|
||||
const links = phoneBlock.querySelectorAll('a[href^="tel:"]');
|
||||
assert.strictEqual(
|
||||
links.length,
|
||||
3,
|
||||
'Rendered exactly 3 unique phone links'
|
||||
);
|
||||
|
||||
assert.strictEqual(links[0].getAttribute('href'), 'tel:+12345678900');
|
||||
assert.strictEqual(links[1].getAttribute('href'), 'tel:+10000000000');
|
||||
assert.strictEqual(links[2].getAttribute('href'), 'tel:+19876543210');
|
||||
|
||||
assert.dom(links[0]).hasText('+1-234-567-8900');
|
||||
assert.dom(links[1]).hasText('+1 000 000 0000');
|
||||
assert.dom(links[2]).hasText('+1 987 654 3210');
|
||||
});
|
||||
|
||||
test('it formats whatsapp tags into wa.me links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Chat Shop',
|
||||
osmTags: {
|
||||
'contact:whatsapp': '+1 234-567 8900',
|
||||
whatsapp: '+44 987 654 321', // Also tests multiple values
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const whatsappBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="WhatsApp"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(whatsappBlock, 'WhatsApp block is rendered');
|
||||
|
||||
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||
|
||||
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/+44987654321'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/+12345678900'
|
||||
);
|
||||
|
||||
// Verify it kept the dashes and spaces for the visible text
|
||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
});
|
||||
|
||||
102
tests/integration/components/user-menu-test.gjs
Normal file
102
tests/integration/components/user-menu-test.gjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import UserMenu from 'marco/components/user-menu';
|
||||
|
||||
class MockOsmAuthService extends Service {
|
||||
@tracked isConnected = false;
|
||||
@tracked userDisplayName = null;
|
||||
|
||||
loginCalled = false;
|
||||
logoutCalled = false;
|
||||
|
||||
login() {
|
||||
this.loginCalled = true;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.logoutCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
}
|
||||
|
||||
module('Integration | Component | user-menu', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osmAuth', MockOsmAuthService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.osmAuth = this.owner.lookup('service:osmAuth');
|
||||
});
|
||||
|
||||
test('it renders disconnected OSM state correctly', async function (assert) {
|
||||
this.osmAuth.isConnected = false;
|
||||
|
||||
this.storageArg = {
|
||||
connected: false,
|
||||
userAddress: null,
|
||||
};
|
||||
this.onClose = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-info')
|
||||
.includesText('OpenStreetMap');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) button')
|
||||
.hasText('Connect');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-status')
|
||||
.hasText('Not connected');
|
||||
|
||||
await click('.account-list .account-item:nth-child(2) button');
|
||||
assert.true(
|
||||
this.osmAuth.loginCalled,
|
||||
'osmAuth.login() was called when Connect is clicked'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders connected OSM state correctly', async function (assert) {
|
||||
this.osmAuth.isConnected = true;
|
||||
this.osmAuth.userDisplayName = 'TestMapper';
|
||||
|
||||
this.storageArg = {
|
||||
connected: false,
|
||||
userAddress: null,
|
||||
};
|
||||
this.onClose = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-info')
|
||||
.includesText('OpenStreetMap');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) button')
|
||||
.hasText('Disconnect');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-status')
|
||||
.hasText('TestMapper');
|
||||
|
||||
await click('.account-list .account-item:nth-child(2) button');
|
||||
assert.true(
|
||||
this.osmAuth.logoutCalled,
|
||||
'osmAuth.logout() was called when Disconnect is clicked'
|
||||
);
|
||||
});
|
||||
});
|
||||
47
tests/unit/routes/oauth/osm-callback-test.js
Normal file
47
tests/unit/routes/oauth/osm-callback-test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmAuthService extends Service {
|
||||
handleCallbackCalled = false;
|
||||
|
||||
async handleCallback() {
|
||||
this.handleCallbackCalled = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class MockRouterService extends Service {
|
||||
transitionToArgs = [];
|
||||
|
||||
transitionTo(...args) {
|
||||
this.transitionToArgs.push(args);
|
||||
}
|
||||
}
|
||||
|
||||
module('Unit | Route | oauth/osm-callback', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osmAuth', MockOsmAuthService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
});
|
||||
|
||||
test('it handles the callback and transitions to index', async function (assert) {
|
||||
let route = this.owner.lookup('route:oauth/osm-callback');
|
||||
let osmAuth = this.owner.lookup('service:osmAuth');
|
||||
let router = this.owner.lookup('service:router');
|
||||
|
||||
await route.model();
|
||||
|
||||
assert.true(
|
||||
osmAuth.handleCallbackCalled,
|
||||
'handleCallback was called on the osmAuth service'
|
||||
);
|
||||
assert.deepEqual(
|
||||
router.transitionToArgs,
|
||||
[['index']],
|
||||
'router transitioned back to index route'
|
||||
);
|
||||
});
|
||||
});
|
||||
158
tests/unit/services/osm-auth-test.js
Normal file
158
tests/unit/services/osm-auth-test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
|
||||
module('Unit | Service | osm-auth', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
let originalLocalStorage;
|
||||
let originalFetch;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
// Stub localStorage
|
||||
let mockStorage = {};
|
||||
originalLocalStorage = window.localStorage;
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => {
|
||||
mockStorage[key] = value.toString();
|
||||
},
|
||||
removeItem: (key) => {
|
||||
delete mockStorage[key];
|
||||
},
|
||||
clear: () => {
|
||||
mockStorage = {};
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Stub fetch
|
||||
originalFetch = window.fetch;
|
||||
window.fetch = async () => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { display_name: 'MockedUser' },
|
||||
}),
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage = originalLocalStorage;
|
||||
window.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('it restores session correctly when logged in', async function (assert) {
|
||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||
|
||||
// Stub the underlying oauthClient before it runs restoreSession (actually restoreSession runs in constructor, so we need to intercept it)
|
||||
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
|
||||
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
|
||||
|
||||
await service.restoreSession();
|
||||
|
||||
assert.true(service.isConnected, 'Service becomes connected');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'CachedName',
|
||||
'Restores name from localStorage'
|
||||
);
|
||||
});
|
||||
|
||||
test('it fetches user info when logged in but no cached name', async function (assert) {
|
||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
|
||||
// Ensure localStorage is empty for this key
|
||||
window.localStorage.removeItem('marco:osm_user_display_name');
|
||||
|
||||
await service.restoreSession();
|
||||
|
||||
assert.true(service.isConnected, 'Service becomes connected');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'MockedUser',
|
||||
'Fetched name from API'
|
||||
);
|
||||
assert.strictEqual(
|
||||
window.localStorage.getItem('marco:osm_user_display_name'),
|
||||
'MockedUser',
|
||||
'Saved name to localStorage'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles login()', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
let requestCalled = false;
|
||||
service.oauthClient.requestAuthorizationCode = async () => {
|
||||
requestCalled = true;
|
||||
};
|
||||
|
||||
await service.login();
|
||||
assert.true(
|
||||
requestCalled,
|
||||
'Delegates to oauthClient.requestAuthorizationCode()'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles logout()', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
service.isConnected = true;
|
||||
service.userDisplayName = 'ToBeCleared';
|
||||
window.localStorage.setItem('marco:osm_user_display_name', 'ToBeCleared');
|
||||
|
||||
let resetCalled = false;
|
||||
service.oauthClient.reset = async () => {
|
||||
resetCalled = true;
|
||||
};
|
||||
|
||||
await service.logout();
|
||||
|
||||
assert.true(resetCalled, 'Delegates to oauthClient.reset()');
|
||||
assert.false(service.isConnected, 'isConnected is reset');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
null,
|
||||
'userDisplayName is reset'
|
||||
);
|
||||
assert.strictEqual(
|
||||
window.localStorage.getItem('marco:osm_user_display_name'),
|
||||
null,
|
||||
'localStorage is cleared'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles the callback flow', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
let receiveCodeCalled = false;
|
||||
let getTokensCalled = false;
|
||||
|
||||
service.oauthClient.receiveCode = async () => {
|
||||
receiveCodeCalled = true;
|
||||
};
|
||||
service.oauthClient.getTokens = async () => {
|
||||
getTokensCalled = true;
|
||||
return { accessToken: 'fake-token' };
|
||||
};
|
||||
|
||||
await service.handleCallback();
|
||||
|
||||
assert.true(receiveCodeCalled, 'oauthClient.receiveCode was called');
|
||||
assert.true(getTokensCalled, 'oauthClient.getTokens was called');
|
||||
assert.true(service.isConnected, 'Service is connected after callback');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'MockedUser',
|
||||
'User info fetched and set during callback'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import { extensions, ember } from '@embroider/vite';
|
||||
import { babel } from '@rollup/plugin-babel';
|
||||
|
||||
export default defineConfig({
|
||||
// server: {
|
||||
// host: '0.0.0.0',
|
||||
// },
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
plugins: [
|
||||
ember(),
|
||||
// extra plugins here
|
||||
|
||||
Reference in New Issue
Block a user