feat(location-map): implement reusable location map component with dynamic markers and directions

This commit is contained in:
2026-07-24 09:05:16 -03:00
parent 1d5c94b9bc
commit 63ae02df22
9 changed files with 330 additions and 12 deletions

View File

@@ -30,16 +30,11 @@
"input": "public" "input": "public"
} }
], ],
"styles": [ "styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
"src/styles.scss"
],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"outputMode": "server", "outputMode": "server",
"security": { "security": {
"allowedHosts": [ "allowedHosts": ["localhost", "127.0.0.1"]
"localhost",
"127.0.0.1"
]
}, },
"ssr": { "ssr": {
"entry": "src/server.ts" "entry": "src/server.ts"
@@ -95,4 +90,4 @@
} }
} }
} }
} }

25
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"express": "^5.1.0", "express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -28,6 +29,7 @@
"@angular/cli": "^22.0.4", "@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -3816,6 +3818,13 @@
"@types/send": "*" "@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": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3823,6 +3832,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5936,6 +5955,12 @@
], ],
"license": "MIT" "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": { "node_modules/listr2": {
"version": "10.2.1", "version": "10.2.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",

View File

@@ -23,6 +23,7 @@
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"express": "^5.1.0", "express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -32,6 +33,7 @@
"@angular/cli": "^22.0.4", "@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",

View File

@@ -398,8 +398,8 @@
<div class="accordion-showcase"> <div class="accordion-showcase">
<h2>Accordion</h2> <h2>Accordion</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
un solo item abierto. solo item abierto.
</p> </p>
<app-accordion> <app-accordion>
@@ -409,8 +409,8 @@
</app-accordion-item> </app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?"> <app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
el checkout. checkout.
</app-accordion-item> </app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true"> <app-accordion-item title="Item deshabilitado" [disabled]="true">
@@ -692,6 +692,21 @@
</div> </div>
</section> </section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4"> <section class="component-demo card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>

View File

@@ -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 () => { it('renders the carousel demo with mocked product images', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent], imports: [ReutilizablesTestPageComponent],

View File

@@ -18,6 +18,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component'; import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component'; import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component'; import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
import {
LocationMapComponent,
MapLocation,
} from '../../../../shared/components/location-map/location-map.component';
interface CarouselProductMock { interface CarouselProductMock {
id: number; id: number;
@@ -47,6 +51,7 @@ interface CarouselProductMock {
MainCarouselComponent, MainCarouselComponent,
AccordionComponent, AccordionComponent,
AccordionItemComponent, AccordionItemComponent,
LocationMapComponent,
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss', styleUrl: './reutilizables-test-page.component.scss',
@@ -79,6 +84,29 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.'; protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8; protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff'; 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 = [ protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80', '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', 'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',

View File

@@ -0,0 +1,15 @@
<div #mapContainer class="location-map" role="region" [attr.aria-label]="ariaLabel"></div>
<ul class="visually-hidden">
@for (location of locations; track location.id) {
<li>
{{ location.label }}
@if (location.address) {
— {{ location.address }}
}
<a [href]="directionsUrl(location)" target="_blank" rel="noopener noreferrer">
Cómo llegar
</a>
</li>
}
</ul>

View File

@@ -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;
}
}

View File

@@ -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<HTMLDivElement>;
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<void> {
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: '&copy; 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,
});
}
}
}