From fa0e9de88660352507d12fe163fb723648285796 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 24 Jul 2026 09:41:09 -0300 Subject: [PATCH] feat(contact-page): implement contact page with dynamic content and map integration --- .../pages/contact-page/contact-page.html | 53 +++++++ .../pages/contact-page/contact-page.scss | 129 +++++++++++++++ .../pages/contact-page/contact-page.spec.ts | 137 ++++++++++++++++ .../pages/contact-page/contact-page.ts | 148 ++++++++++++++++++ .../location-map/location-map.component.ts | 65 ++++++-- 5 files changed, 522 insertions(+), 10 deletions(-) create mode 100644 src/app/features/store/pages/help-page/pages/contact-page/contact-page.html create mode 100644 src/app/features/store/pages/help-page/pages/contact-page/contact-page.scss create mode 100644 src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts create mode 100644 src/app/features/store/pages/help-page/pages/contact-page/contact-page.ts diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html new file mode 100644 index 0000000..b3cc069 --- /dev/null +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html @@ -0,0 +1,53 @@ +
+
+

CONTACTO

+ + @if (contact().whatsappUrl && contact().whatsappLabel) { + + + {{ contact().whatsappLabel }} + + } + + @if (contact().phone; as phone) { + + + {{ phone }} + + } + + @for (location of contact().locations; track location.id) { +
+

+ + {{ location.label }} +

+ +
    + @for (address of location.addresses; track address.id) { +
  • {{ address.address }}
  • + } +
+
+ } + + @if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) { +

No hay datos de contacto disponibles.

+ } +
+ + @if (contact().mapLocations.length > 0) { + + } +
diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.scss b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.scss new file mode 100644 index 0000000..7957c4b --- /dev/null +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.scss @@ -0,0 +1,129 @@ +:host { + display: block; + width: 100%; +} + +.contact-page { + display: grid; + grid-template-columns: minmax(13rem, 0.72fr) minmax(20rem, 1.45fr); + align-items: start; + gap: clamp(2rem, 5vw, 4.75rem); + width: 100%; + color: #666666; + font-weight: 400; +} + +.contact-page__details { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; +} + +.contact-page__title { + margin: 0 0 1.25rem; + color: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.3; +} + +.contact-page__contact-link { + display: inline-flex; + align-items: center; + gap: 0.65rem; + margin-bottom: 1rem; + color: inherit; + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.35; + text-decoration: none; + transition: color 150ms ease; + + i { + width: 0.9rem; + color: inherit; + font-size: 0.875rem; + text-align: center; + transition: color 150ms ease; + } + + &:hover, + &:focus-visible, + &:active { + color: var(--color-primary); + } + + &:focus-visible { + border-radius: 2px; + outline: 2px solid var(--color-primary); + outline-offset: 4px; + } +} + +.contact-page__location { + margin-top: 0.15rem; + + & + & { + margin-top: 1rem; + } +} + +.contact-page__location-title { + display: flex; + align-items: center; + gap: 0.65rem; + margin: 0 0 0.3rem; + color: inherit; + font-size: 0.8125rem; + font-weight: 700; + line-height: 1.35; + + i { + width: 0.9rem; + font-size: 0.875rem; + text-align: center; + } +} + +.contact-page__addresses { + display: grid; + gap: 0.15rem; + margin: 0; + padding-left: 2.3rem; + color: inherit; + font-size: 0.75rem; + font-weight: 400; + line-height: 1.35; +} + +.contact-page__empty { + margin: 0; + color: inherit; + font-size: 0.8125rem; + font-weight: 400; +} + +.contact-page__map { + display: block; + min-width: 0; + + &::ng-deep .location-map { + min-height: clamp(15rem, 26vw, 20rem); + border-radius: 0.25rem; + filter: saturate(0.72) contrast(0.96); + } +} + +@media (max-width: 991.98px) { + .contact-page { + grid-template-columns: 1fr; + gap: 2rem; + } +} + +@media (max-width: 575.98px) { + .contact-page__map::ng-deep .location-map { + min-height: 16rem; + } +} diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts new file mode 100644 index 0000000..25648e7 --- /dev/null +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts @@ -0,0 +1,137 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Tenant } from '../../../../../../core/services/tenant.interface'; +import { TenantService } from '../../../../../../core/services/tenant.service'; +import { ContactPage } from './contact-page'; + +const tenant: Tenant = { + id: 1, + codigo: 'test', + nombre: 'Test', + dominio: 'localhost', + primary_color: '#3366ff', + secondary_color: '#a0a0a0', + danger_color: '#ff8888', + success_color: '#198754', + header_bg_color: '#ffffff', + footer_bg_color: '#202020', + header_logo: '', + footer_logo: '', + menues: [ + { + id: 1, + code: 'help', + parent_menu_code: null, + content_type: 'dynamic', + route: '/ayuda', + submenues: [ + { + id: 2, + code: 'help.contact', + parent_menu_code: 'help', + content_type: 'static', + route: '/ayuda/contacto', + static_content: { + whatsapp: { + whatsapp_url: 'https://wa.me/543412602222', + whatsapp_label: 'Chatea con nosotros', + }, + phone: '+54 9 (0341) 6658247', + locations: { + rosario: { + label: 'Rosario', + addresses: [ + { + label: 'Gigante de Arroyito', + address: 'Av. Génova 640, Rosario', + coordinates: [-32.913997, -60.674567], + }, + { + label: 'Telepagos', + address: 'Rioja 1150, piso 12, dpto. 3, Rosario', + coordinates: [-32.94682, -60.63932], + }, + ], + }, + santa_fe: { + label: 'Santa Fe', + addresses: [ + { + label: 'San Martín 2450', + address: 'San Martín 2450, Santa Fe', + coordinates: [-31.63333, -60.7], + }, + ], + }, + }, + }, + submenues: [], + }, + ], + }, + ], +}; + +describe('ContactPage', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ContactPage], + providers: [ + { + provide: TenantService, + useValue: { + tenant: signal(tenant).asReadonly(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContactPage); + fixture.detectChanges(); + }); + + it('renders the contact content configured for the tenant', () => { + const element = fixture.nativeElement as HTMLElement; + const whatsapp = element.querySelector( + 'a[href="https://wa.me/543412602222"]', + ); + const phone = element.querySelector('a[href="tel:+54903416658247"]'); + + expect(element.textContent).toContain('CONTACTO'); + expect(whatsapp?.textContent).toContain('Chatea con nosotros'); + expect(phone?.textContent).toContain('+54 9 (0341) 6658247'); + expect(element.textContent).toContain('Rosario'); + expect(element.textContent).toContain('Gigante de Arroyito'); + expect(element.textContent).toContain('Telepagos'); + expect(element.querySelector('.contact-page__addresses')?.textContent).toContain( + 'Av. Génova 640, Rosario', + ); + expect(element.querySelector('.contact-page__addresses')?.textContent).toContain( + 'Rioja 1150, piso 12, dpto. 3, Rosario', + ); + expect(element.textContent).not.toContain('Santa Fe'); + expect(element.textContent).not.toContain('San Martín 2450'); + }); + + it('passes the tenant map coordinates to the reusable map component', () => { + const element = fixture.nativeElement as HTMLElement; + const map = element.querySelector('app-location-map'); + const accessibleLocations = element.querySelectorAll('app-location-map .visually-hidden li'); + const directionsLinks = element.querySelectorAll( + 'app-location-map .visually-hidden a[target="_blank"]', + ); + + expect(map).not.toBeNull(); + expect(map?.getAttribute('mapstyle')).toBe('minimal-light'); + expect(accessibleLocations).toHaveLength(2); + expect(map?.textContent).toContain('Gigante de Arroyito'); + expect(map?.textContent).toContain('Telepagos'); + expect(map?.textContent).toContain('Av. Génova 640, Rosario'); + expect(map?.textContent).toContain('Rioja 1150, piso 12, dpto. 3, Rosario'); + expect(directionsLinks).toHaveLength(2); + expect(map?.textContent).not.toContain('San Martín 2450'); + }); +}); diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.ts b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.ts new file mode 100644 index 0000000..1f54738 --- /dev/null +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.ts @@ -0,0 +1,148 @@ +import { Component, computed, inject } from '@angular/core'; + +import { findMenu } from '../../../../../../core/services/menu.utils'; +import { TenantService } from '../../../../../../core/services/tenant.service'; +import { + LocationMapComponent, + MapLocation, +} from '../../../../../../shared/components/location-map/location-map.component'; + +type ContactLocation = { + id: string; + label: string; + addresses: ContactAddress[]; +}; + +type ContactAddress = { + id: string; + label: string; + address: string; + coordinates: [number, number]; +}; + +type ContactContent = { + whatsappUrl: string | null; + whatsappLabel: string | null; + phone: string | null; + locations: ContactLocation[]; + mapLocations: MapLocation[]; +}; + +@Component({ + selector: 'app-contact-page', + imports: [LocationMapComponent], + templateUrl: './contact-page.html', + styleUrl: './contact-page.scss', +}) +export class ContactPage { + private readonly tenantService = inject(TenantService); + + protected readonly markerColor = computed( + () => this.tenantService.tenant()?.primary_color ?? '#6376f3', + ); + + protected readonly contact = computed(() => + this.parseContactContent( + findMenu(this.tenantService.tenant()?.menues ?? [], 'help.contact')?.static_content, + ), + ); + + protected phoneUrl(phone: string): string { + const normalizedPhone = phone.replace(/[^\d+]/g, ''); + return `tel:${normalizedPhone}`; + } + + private parseContactContent(value: unknown): ContactContent { + const empty: ContactContent = { + whatsappUrl: null, + whatsappLabel: null, + phone: null, + locations: [], + mapLocations: [], + }; + + if (!this.isRecord(value)) { + return empty; + } + + const whatsapp = this.isRecord(value['whatsapp']) ? value['whatsapp'] : null; + const locations = this.parseLocations(value['locations']).slice(0, 1); + + return { + whatsappUrl: whatsapp ? this.stringValue(whatsapp['whatsapp_url']) : null, + whatsappLabel: whatsapp ? this.stringValue(whatsapp['whatsapp_label']) : null, + phone: this.stringValue(value['phone']), + locations, + mapLocations: locations.flatMap((location) => + location.addresses.map( + (address): MapLocation => ({ + id: address.id, + label: address.label, + latitude: address.coordinates[0], + longitude: address.coordinates[1], + address: address.address, + }), + ), + ), + }; + } + + private parseLocations(value: unknown): ContactLocation[] { + return this.recordEntries(value).flatMap(([id, item]): ContactLocation[] => { + if (!this.isRecord(item)) { + return []; + } + + const label = this.stringValue(item['label']); + const addresses: ContactAddress[] = Array.isArray(item['addresses']) + ? item['addresses'].flatMap((address, index): ContactAddress[] => { + if (!this.isRecord(address)) { + return []; + } + + const addressLabel = this.stringValue(address['label']); + const addressText = this.stringValue(address['address']); + const coordinates = address['coordinates']; + + if ( + !addressLabel || + !addressText || + !Array.isArray(coordinates) || + coordinates.length !== 2 || + typeof coordinates[0] !== 'number' || + typeof coordinates[1] !== 'number' + ) { + return []; + } + + return [ + { + id: `${id}-${index}`, + label: addressLabel, + address: addressText, + coordinates: [coordinates[0], coordinates[1]], + }, + ]; + }) + : []; + + return label && addresses.length > 0 ? [{ id, label, addresses }] : []; + }); + } + + private recordEntries(value: unknown): [string, unknown][] { + if (Array.isArray(value)) { + return value.map((item, index) => [String(index), item]); + } + + return this.isRecord(value) ? Object.entries(value) : []; + } + + private stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } +} diff --git a/src/app/shared/components/location-map/location-map.component.ts b/src/app/shared/components/location-map/location-map.component.ts index 9d76dbf..7b9ab19 100644 --- a/src/app/shared/components/location-map/location-map.component.ts +++ b/src/app/shared/components/location-map/location-map.component.ts @@ -11,7 +11,31 @@ import { SimpleChanges, ViewChild, } from '@angular/core'; -import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple } from 'leaflet'; +import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet'; + +export type MapStyle = 'minimal-light' | 'standard'; + +const MAP_LAYERS: Record< + MapStyle, + { + url: string; + attribution: string; + maxZoom: number; + } +> = { + 'minimal-light': { + url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + attribution: + '© OpenStreetMap contributors © CARTO', + maxZoom: 20, + }, + standard: { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: + '© OpenStreetMap contributors', + maxZoom: 19, + }, +}; export interface MapLocation { id: string | number; @@ -32,12 +56,15 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy @Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932]; @Input() fallbackZoom = 13; @Input() maxFitZoom = 15; + @Input() mapStyle: MapStyle = 'minimal-light'; + @Input() markerColor = '#24a9e0'; @ViewChild('mapContainer', { static: true }) private readonly mapContainer!: ElementRef; private leaflet: typeof import('leaflet') | null = null; private map: LeafletMap | null = null; + private baseLayer: TileLayer | null = null; private markerLayer: LayerGroup | null = null; constructor( @@ -52,7 +79,11 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy } ngOnChanges(changes: SimpleChanges): void { - if (changes['locations'] && this.map) { + if (changes['mapStyle'] && this.map) { + this.renderBaseLayer(); + } + + if ((changes['locations'] || changes['markerColor']) && this.map) { this.renderLocations(); } } @@ -60,6 +91,7 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy ngOnDestroy(): void { this.map?.remove(); this.map = null; + this.baseLayer = null; this.markerLayer = null; } @@ -88,17 +120,30 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy scrollWheelZoom: false, }); - leaflet - .tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors', - }) - .addTo(this.map); - + this.renderBaseLayer(); this.markerLayer = leaflet.layerGroup().addTo(this.map); this.renderLocations(); } + private renderBaseLayer(): void { + if (!this.leaflet || !this.map) { + return; + } + + if (this.baseLayer) { + this.map.removeLayer(this.baseLayer); + } + + const layer = MAP_LAYERS[this.mapStyle]; + this.baseLayer = this.leaflet + .tileLayer(layer.url, { + maxZoom: layer.maxZoom, + attribution: layer.attribution, + crossOrigin: true, + }) + .addTo(this.map); + } + private renderLocations(): void { if (!this.leaflet || !this.map || !this.markerLayer) { return; @@ -152,7 +197,7 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy radius: 8, color: '#ffffff', weight: 3, - fillColor: '#24a9e0', + fillColor: this.markerColor, fillOpacity: 1, }) .bindTooltip(tooltip)