diff --git a/app/components/toast.gjs b/app/components/toast.gjs new file mode 100644 index 0000000..e7f1a58 --- /dev/null +++ b/app/components/toast.gjs @@ -0,0 +1,14 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; + +export default class ToastComponent extends Component { + @service toast; + + +} diff --git a/app/routes/search.js b/app/routes/search.js index cf876a4..e47eb08 100644 --- a/app/routes/search.js +++ b/app/routes/search.js @@ -9,6 +9,7 @@ export default class SearchRoute extends Route { @service mapUi; @service storage; @service router; + @service toast; queryParams = { lat: { refreshModel: true }, @@ -199,8 +200,12 @@ export default class SearchRoute extends Route { } @action - error() { + error(error, transition) { this.mapUi.stopSearch(); - return true; // Bubble error + this.toast.show('Search request failed. Please try again.'); + if (transition) { + transition.abort(); + } + return false; // Prevent bubble and stop transition } } diff --git a/app/services/osm.js b/app/services/osm.js index 9132a2c..e265178 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -155,7 +155,7 @@ out center; return []; } console.error('Category search failed', e); - return []; + throw e; } } diff --git a/app/services/toast.js b/app/services/toast.js new file mode 100644 index 0000000..4116944 --- /dev/null +++ b/app/services/toast.js @@ -0,0 +1,21 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class ToastService extends Service { + @tracked message = null; + @tracked isVisible = false; + timeoutId = null; + + show(message, duration = 3000) { + this.message = message; + this.isVisible = true; + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + this.timeoutId = setTimeout(() => { + this.isVisible = false; + }, duration); + } +} diff --git a/app/styles/app.css b/app/styles/app.css index 8c53979..f27c8e5 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -1321,3 +1321,36 @@ button.create-place { cursor: not-allowed; pointer-events: none; } + +/* Toast Notification */ +.toast-notification { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + background-color: rgb(51 51 51 / 85%); + backdrop-filter: blur(4px); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 999px; + z-index: 9999; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + animation: fade-in-up 0.3s ease-out forwards; + text-align: center; + max-width: 90%; + font-size: 0.9rem; + font-weight: 500; + pointer-events: none; +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translate(-50%, 1rem); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} diff --git a/app/templates/application.gjs b/app/templates/application.gjs index 8dae51b..8dfd51b 100644 --- a/app/templates/application.gjs +++ b/app/templates/application.gjs @@ -3,6 +3,7 @@ import { pageTitle } from 'ember-page-title'; import Map from '#components/map'; import AppHeader from '#components/app-header'; import AppMenu from '#components/app-menu/index'; +import Toast from '#components/toast'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -89,6 +90,8 @@ export default class ApplicationComponent extends Component { {{/if}} + + {{outlet}} } diff --git a/tests/acceptance/search-test.js b/tests/acceptance/search-test.js index c344631..42ecedb 100644 --- a/tests/acceptance/search-test.js +++ b/tests/acceptance/search-test.js @@ -218,4 +218,56 @@ module('Acceptance | search', function (hooks) { // Ensure it shows "Results" not "Nearby" assert.dom('.sidebar-header h2').includesText('Results'); }); + + test('search error handling prevents opening empty panel and shows toast', async function (assert) { + // Mock Osm Service to throw an error + class MockOsmService extends Service { + async getCategoryPois() { + throw new Error('Overpass request failed'); + } + } + 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); + + class MockMapService extends Service { + getBounds() { + return { + minLat: 52.5, + minLon: 13.4, + maxLat: 52.6, + maxLon: 13.5, + }; + } + } + this.owner.register('service:map', MockMapService); + + await visit('/'); + + try { + await visit('/search?category=coffee&lat=52.52&lon=13.405'); + } catch { + // Aborted transition throws, which is expected + } + + assert.dom('.toast-notification').exists('Toast should be visible'); + assert + .dom('.toast-notification') + .hasText('Search request failed. Please try again.'); + assert.dom('.places-sidebar').doesNotExist('Results panel should not open'); + }); });