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,
+ });
+ }
+ }
+}