feat: enhance store layout with dynamic logo handling and loading states

This commit is contained in:
2026-06-26 14:27:14 -03:00
parent 4e2dccd2e3
commit 7ee103b77b
10 changed files with 154 additions and 28 deletions

View File

@@ -5,12 +5,19 @@
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
aria-label="Espacio reservado para logo de Sonder"
aria-label="Logo del comercio"
>
<span class="store-layout__brand-mark store-layout__brand-mark--footer" aria-hidden="true">
S
</span>
<span class="store-layout__brand-text store-layout__brand-text--footer">SONDER</span>
@if (logoUrl && logoLoaded) {
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
@if (logoUrl && !logoLoaded) {
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
}
</div>
}
</div>
<div class="d-grid gap-2">

View File

@@ -6,6 +6,42 @@
min-width: 10rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
max-height: 2.75rem;
object-fit: contain;
}
.store-layout__brand-logo--preload {
opacity: 0;
position: absolute;
inset: 0;
pointer-events: none;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.24) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-footer-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__brand-mark {
display: grid;
place-items: center;
@@ -33,6 +69,16 @@
color: #f8f8f8;
}
@keyframes store-footer-logo-skeleton {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;

View File

@@ -17,7 +17,25 @@ export type StoreSocialLink = {
styleUrl: './store-footer.component.scss'
})
export class StoreFooterComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input()
set logoUrl(value: string | null) {
this.logoUrlValue = value;
this.logoLoaded = false;
}
get logoUrl(): string | null {
return this.logoUrlValue;
}
protected logoLoaded = false;
protected onLogoLoad(): void {
this.logoLoaded = true;
}
}

View File

@@ -4,10 +4,19 @@
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
data-testid="store-header-brand-slot"
aria-label="Espacio reservado para logo de Sonder"
aria-label="Logo del comercio"
>
<span class="store-layout__brand-mark" aria-hidden="true">S</span>
<span class="store-layout__brand-text">SONDER</span>
@if (logoUrl && logoLoaded) {
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
@if (logoUrl && !logoLoaded) {
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
}
</div>
}
</div>
<div class="d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 ms-auto w-100 w-lg-auto">

View File

@@ -2,23 +2,34 @@
min-width: 10rem;
}
.store-layout__brand-mark {
display: grid;
place-items: center;
width: 2.75rem;
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
border: 1px dashed #bbbbbb;
border-radius: 999px;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #373737;
}
.store-layout__brand-text {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.08em;
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.store-layout__brand-logo--preload {
opacity: 0;
position: absolute;
inset: 0;
pointer-events: none;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.14) 50%, rgba(0, 0, 0, 0.08) 100%);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
@@ -31,15 +42,21 @@
letter-spacing: 0.02em;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
}
.store-layout__brand-text {
font-size: 1.45rem;
}
.store-layout__search {
min-width: 0;
}

View File

@@ -12,5 +12,23 @@ export type StoreHeaderAction = {
styleUrl: './store-header.component.scss'
})
export class StoreHeaderComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
@Input()
set logoUrl(value: string | null) {
this.logoUrlValue = value;
this.logoLoaded = false;
}
get logoUrl(): string | null {
return this.logoUrlValue;
}
protected logoLoaded = false;
protected onLogoLoad(): void {
this.logoLoaded = true;
}
}

View File

@@ -1,5 +1,5 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header [headerActions]="headerActions" />
<app-store-header [headerActions]="headerActions" [logoUrl]="tenant()?.header_logo ?? null" />
<main class="flex-grow-1 d-block">
<router-outlet />
@@ -9,5 +9,6 @@
[currentYear]="currentYear"
[footerSections]="footerSections"
[socialLinks]="socialLinks"
[logoUrl]="tenant()?.footer_logo ?? null"
/>
</div>

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-header.component';
@@ -10,7 +11,10 @@ import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-he
styleUrl: './store-layout.component.scss'
})
export class StoreLayoutComponent {
private readonly tenantService = inject(TenantService);
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly headerActions: StoreHeaderAction[] = [
{

View File

@@ -44,6 +44,7 @@ describe('TenantService', () => {
await expect(bootstrapPromise).resolves.toEqual(response.data);
expect(service.tenant()).toEqual(response.data);
expect(service.getTenant()).toEqual(response.data);
httpController.verify();
});
@@ -81,6 +82,7 @@ describe('TenantService', () => {
await expect(service.bootstrap()).resolves.toBeNull();
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
httpController.expectNone(() => true);
httpController.verify();

View File

@@ -16,6 +16,10 @@ export class TenantService {
readonly tenant = this.tenantState.asReadonly();
getTenant(): Tenant | null {
return this.tenantState();
}
async bootstrap(): Promise<Tenant | null> {
if (!isPlatformBrowser(this.platformId)) {
return null;