feat(location-map): implement reusable location map component with dynamic markers and directions
This commit is contained in:
11
angular.json
11
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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -398,8 +398,8 @@
|
||||
<div class="accordion-showcase">
|
||||
<h2>Accordion</h2>
|
||||
<p class="text-muted mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<app-accordion>
|
||||
@@ -409,8 +409,8 @@
|
||||
</app-accordion-item>
|
||||
|
||||
<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
|
||||
el checkout.
|
||||
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
|
||||
checkout.
|
||||
</app-accordion-item>
|
||||
|
||||
<app-accordion-item title="Item deshabilitado" [disabled]="true">
|
||||
@@ -692,6 +692,21 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Reusable Component</span>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
172
src/app/shared/components/location-map/location-map.component.ts
Normal file
172
src/app/shared/components/location-map/location-map.component.ts
Normal 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: '© 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user