From 63ae02df22ae0a420c5571f50902e02083b7fb9e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 24 Jul 2026 09:05:16 -0300 Subject: [PATCH] feat(location-map): implement reusable location map component with dynamic markers and directions --- angular.json | 11 +- package-lock.json | 25 +++ package.json | 2 + .../reutilizables-test-page.component.html | 23 ++- .../reutilizables-test-page.component.spec.ts | 34 ++++ .../reutilizables-test-page.component.ts | 28 +++ .../location-map/location-map.component.html | 15 ++ .../location-map/location-map.component.scss | 32 ++++ .../location-map/location-map.component.ts | 172 ++++++++++++++++++ 9 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 src/app/shared/components/location-map/location-map.component.html create mode 100644 src/app/shared/components/location-map/location-map.component.scss create mode 100644 src/app/shared/components/location-map/location-map.component.ts diff --git a/angular.json b/angular.json index 6cb1dc9..f3f2ebc 100644 --- a/angular.json +++ b/angular.json @@ -30,16 +30,11 @@ "input": "public" } ], - "styles": [ - "src/styles.scss" - ], + "styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"], "server": "src/main.server.ts", "outputMode": "server", "security": { - "allowedHosts": [ - "localhost", - "127.0.0.1" - ] + "allowedHosts": ["localhost", "127.0.0.1"] }, "ssr": { "entry": "src/server.ts" @@ -95,4 +90,4 @@ } } } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index aad8da4..ceb58c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@fortawesome/fontawesome-free": "^7.2.0", "bootstrap": "^5.3.8", "express": "^5.1.0", + "leaflet": "^1.9.4", "qrcode": "1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -28,6 +29,7 @@ "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.0", "@types/express": "^5.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^20.17.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", @@ -3816,6 +3818,13 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -3823,6 +3832,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -5936,6 +5955,12 @@ ], "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/listr2": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", diff --git a/package.json b/package.json index 4d80e32..0441d78 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fortawesome/fontawesome-free": "^7.2.0", "bootstrap": "^5.3.8", "express": "^5.1.0", + "leaflet": "^1.9.4", "qrcode": "1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -32,6 +33,7 @@ "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.0", "@types/express": "^5.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^20.17.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index ef75707..18e5862 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -398,8 +398,8 @@

Accordion

- Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene - un solo item abierto. + Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un + solo item abierto.

@@ -409,8 +409,8 @@ - Los medios de pago dependen de la configuración de cada tienda y se muestran durante - el checkout. + Los medios de pago dependen de la configuración de cada tienda y se muestran durante el + checkout. @@ -692,6 +692,21 @@
+
+
+ Reusable Component +

Mapa de ubicaciones

+

+ Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones. +

+ + +
+
+
Reusable Component diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 73b2dcd..8f3fcea 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -141,6 +141,40 @@ describe('ReutilizablesTestPageComponent', () => { ); }); + it('renders the reusable map demo with all configured locations', async () => { + await TestBed.configureTestingModule({ + imports: [ReutilizablesTestPageComponent], + providers: [ + { + provide: TenantService, + useValue: createTenantServiceStub(tenant), + }, + { + provide: ToastService, + useValue: createToastServiceStub(), + }, + { + provide: ModalService, + useValue: createModalServiceStub(), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); + fixture.detectChanges(); + + const mapDemo = fixture.nativeElement.querySelector( + '[data-testid="location-map-demo"]', + ) as HTMLElement; + const accessibleLocations = mapDemo.querySelectorAll('app-location-map .visually-hidden li'); + + expect(mapDemo.querySelector('app-location-map')).not.toBeNull(); + expect(accessibleLocations).toHaveLength(3); + expect(mapDemo.textContent).toContain('Gigante de Arroyito'); + + fixture.destroy(); + }); + it('renders the carousel demo with mocked product images', async () => { await TestBed.configureTestingModule({ imports: [ReutilizablesTestPageComponent], diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 2a18918..09e3915 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -18,6 +18,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component'; import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component'; import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component'; +import { + LocationMapComponent, + MapLocation, +} from '../../../../shared/components/location-map/location-map.component'; interface CarouselProductMock { id: number; @@ -47,6 +51,7 @@ interface CarouselProductMock { MainCarouselComponent, AccordionComponent, AccordionItemComponent, + LocationMapComponent, ], templateUrl: './reutilizables-test-page.component.html', styleUrl: './reutilizables-test-page.component.scss', @@ -79,6 +84,29 @@ export class ReutilizablesTestPageComponent { protected lastModalResult = 'Todavia no se abrio ningun modal.'; protected readonly paginatorTotalPages = 8; protected readonly cartBackgroundColor = '#ffffff'; + protected readonly mapLocations: readonly MapLocation[] = [ + { + id: 'gigante-de-arroyito', + label: 'Gigante de Arroyito', + address: 'Av. Génova 640, Rosario', + latitude: -32.913997, + longitude: -60.674567, + }, + { + id: 'punto-centro', + label: 'Punto Centro', + address: 'Centro, Rosario', + latitude: -32.94682, + longitude: -60.63932, + }, + { + id: 'punto-sur', + label: 'Punto Sur', + address: 'Zona sur, Rosario', + latitude: -32.97151, + longitude: -60.65064, + }, + ]; protected readonly mainCarouselImages = [ 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80', 'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80', diff --git a/src/app/shared/components/location-map/location-map.component.html b/src/app/shared/components/location-map/location-map.component.html new file mode 100644 index 0000000..7acebc0 --- /dev/null +++ b/src/app/shared/components/location-map/location-map.component.html @@ -0,0 +1,15 @@ +
+ +
    + @for (location of locations; track location.id) { +
  • + {{ location.label }} + @if (location.address) { + — {{ location.address }} + } + + Cómo llegar + +
  • + } +
diff --git a/src/app/shared/components/location-map/location-map.component.scss b/src/app/shared/components/location-map/location-map.component.scss new file mode 100644 index 0000000..b05de76 --- /dev/null +++ b/src/app/shared/components/location-map/location-map.component.scss @@ -0,0 +1,32 @@ +:host { + display: block; + width: 100%; +} + +.location-map { + width: 100%; + min-height: 22rem; + overflow: hidden; + background: #e9ecef; + border-radius: 0.75rem; +} + +:host ::ng-deep .leaflet-popup-content { + display: grid; + gap: 0.2rem; + margin: 0.85rem 1rem; + color: #343a40; + line-height: 1.4; + + a { + margin-top: 0.25rem; + color: var(--color-primary, #0d6efd); + font-weight: 600; + } +} + +@media (max-width: 575.98px) { + .location-map { + min-height: 18rem; + } +} diff --git a/src/app/shared/components/location-map/location-map.component.ts b/src/app/shared/components/location-map/location-map.component.ts new file mode 100644 index 0000000..9d76dbf --- /dev/null +++ b/src/app/shared/components/location-map/location-map.component.ts @@ -0,0 +1,172 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + AfterViewInit, + Component, + ElementRef, + Inject, + Input, + OnChanges, + OnDestroy, + PLATFORM_ID, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple } from 'leaflet'; + +export interface MapLocation { + id: string | number; + label: string; + latitude: number; + longitude: number; + address?: string; +} + +@Component({ + selector: 'app-location-map', + templateUrl: './location-map.component.html', + styleUrl: './location-map.component.scss', +}) +export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input({ required: true }) locations: readonly MapLocation[] = []; + @Input() ariaLabel = 'Mapa de ubicaciones'; + @Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932]; + @Input() fallbackZoom = 13; + @Input() maxFitZoom = 15; + + @ViewChild('mapContainer', { static: true }) + private readonly mapContainer!: ElementRef; + + private leaflet: typeof import('leaflet') | null = null; + private map: LeafletMap | null = null; + private markerLayer: LayerGroup | null = null; + + constructor( + @Inject(PLATFORM_ID) private readonly platformId: object, + @Inject(DOCUMENT) private readonly document: Document, + ) {} + + ngAfterViewInit(): void { + if (isPlatformBrowser(this.platformId)) { + void this.initializeMap(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['locations'] && this.map) { + this.renderLocations(); + } + } + + ngOnDestroy(): void { + this.map?.remove(); + this.map = null; + this.markerLayer = null; + } + + protected directionsUrl(location: MapLocation): string { + const destination = `${location.latitude},${location.longitude}`; + return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(destination)}`; + } + + private async initializeMap(): Promise { + const leafletModule = await import('leaflet'); + const leaflet = + ( + leafletModule as unknown as { + default?: typeof import('leaflet'); + } + ).default ?? leafletModule; + + if (!this.mapContainer.nativeElement.isConnected || this.map) { + return; + } + + this.leaflet = leaflet; + this.map = leaflet.map(this.mapContainer.nativeElement, { + center: this.fallbackCenter, + zoom: this.fallbackZoom, + scrollWheelZoom: false, + }); + + leaflet + .tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }) + .addTo(this.map); + + this.markerLayer = leaflet.layerGroup().addTo(this.map); + this.renderLocations(); + } + + private renderLocations(): void { + if (!this.leaflet || !this.map || !this.markerLayer) { + return; + } + + this.markerLayer.clearLayers(); + const validLocations = this.locations.filter( + ({ latitude, longitude }) => + Number.isFinite(latitude) && + Number.isFinite(longitude) && + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180, + ); + + if (validLocations.length === 0) { + this.map.setView(this.fallbackCenter, this.fallbackZoom); + return; + } + + const bounds: LatLngBounds = this.leaflet.latLngBounds([]); + + for (const location of validLocations) { + const coordinates: LatLngTuple = [location.latitude, location.longitude]; + bounds.extend(coordinates); + + const popup = this.document.createElement('div'); + const title = this.document.createElement('strong'); + title.textContent = location.label; + popup.append(title); + + if (location.address) { + const address = this.document.createElement('div'); + address.textContent = location.address; + popup.append(address); + } + + const directions = this.document.createElement('a'); + directions.href = this.directionsUrl(location); + directions.target = '_blank'; + directions.rel = 'noopener noreferrer'; + directions.textContent = 'Cómo llegar'; + popup.append(directions); + + const tooltip = this.document.createElement('span'); + tooltip.textContent = location.label; + + this.leaflet + .circleMarker(coordinates, { + radius: 8, + color: '#ffffff', + weight: 3, + fillColor: '#24a9e0', + fillOpacity: 1, + }) + .bindTooltip(tooltip) + .bindPopup(popup) + .addTo(this.markerLayer); + } + + if (validLocations.length === 1) { + this.map.setView(bounds.getCenter(), this.maxFitZoom); + } else { + this.map.fitBounds(bounds, { + padding: [32, 32], + maxZoom: this.maxFitZoom, + }); + } + } +}