feat(contact-page): implement contact page with dynamic content and map integration
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<section class="contact-page" aria-labelledby="contact-page-title">
|
||||
<div class="contact-page__details">
|
||||
<h2 id="contact-page-title" class="contact-page__title">CONTACTO</h2>
|
||||
|
||||
@if (contact().whatsappUrl && contact().whatsappLabel) {
|
||||
<a
|
||||
class="contact-page__contact-link"
|
||||
[href]="contact().whatsappUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
|
||||
<span>{{ contact().whatsappLabel }}</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (contact().phone; as phone) {
|
||||
<a class="contact-page__contact-link" [href]="phoneUrl(phone)">
|
||||
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
||||
<span>{{ phone }}</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@for (location of contact().locations; track location.id) {
|
||||
<article class="contact-page__location">
|
||||
<h3 class="contact-page__location-title">
|
||||
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
||||
<span>{{ location.label }}</span>
|
||||
</h3>
|
||||
|
||||
<ul class="contact-page__addresses">
|
||||
@for (address of location.addresses; track address.id) {
|
||||
<li>{{ address.address }}</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
|
||||
@if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) {
|
||||
<p class="contact-page__empty">No hay datos de contacto disponibles.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (contact().mapLocations.length > 0) {
|
||||
<app-location-map
|
||||
class="contact-page__map"
|
||||
[locations]="contact().mapLocations"
|
||||
[markerColor]="markerColor()"
|
||||
mapStyle="minimal-light"
|
||||
ariaLabel="Mapa de sucursales y puntos de atención"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ContactPage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactPage],
|
||||
providers: [
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: {
|
||||
tenant: signal<Tenant | null>(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<HTMLAnchorElement>(
|
||||
'a[href="https://wa.me/543412602222"]',
|
||||
);
|
||||
const phone = element.querySelector<HTMLAnchorElement>('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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
maxZoom: 20,
|
||||
},
|
||||
standard: {
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<HTMLDivElement>;
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user