refactor: migrate tenant bootstrap to use SSR state caching and update button components to use CSS tenant variables

This commit is contained in:
2026-06-28 23:09:26 +00:00
parent 7ee103b77b
commit 2cbe681995
27 changed files with 1042 additions and 189 deletions

View File

@@ -7,15 +7,11 @@
data-testid="store-footer-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl && logoLoaded) {
@if (logoUrl) {
<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>
@@ -31,7 +27,7 @@
</div>
</div>
<p class="mb-0 small text-white-50">Copyright &copy; {{ currentYear }} - Sonder</p>
<p class="mb-0 small store-layout__muted-text">Copyright &copy; {{ currentYear }} - Sonder</p>
</div>
@for (section of footerSections; track section.heading) {
@@ -39,7 +35,7 @@
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
<div class="d-grid gap-2">
@for (link of section.links; track link) {
<span class="text-light-emphasis">{{ link }}</span>
<span class="store-layout__muted-text">{{ link }}</span>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
.store-layout__footer {
background-color: #323232;
background-color: var(--tenant-header-footer-bg);
}
.store-layout__brand-slot {
@@ -20,13 +20,6 @@
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%;
@@ -69,6 +62,10 @@
color: #f8f8f8;
}
.store-layout__muted-text {
color: rgba(255, 255, 255, 0.72);
}
@keyframes store-footer-logo-skeleton {
from {
background-position: 200% 0;

View File

@@ -17,25 +17,8 @@ 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;
}
@Input() logoUrl: string | null = null;
}

View File

@@ -1,20 +1,16 @@
<header class="store-layout__header bg-white border-bottom">
<div class="border-bottom">
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-wrap align-items-center justify-content-between gap-3">
<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="Logo del comercio"
>
@if (logoUrl && logoLoaded) {
@if (logoUrl) {
<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>
@@ -25,10 +21,14 @@
<input
id="store-search-input"
type="search"
class="form-control border-dark border-end-0 rounded-start"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
/>
<button type="button" class="btn btn-outline-dark border-start-0 rounded-end px-3" aria-label="Buscar">
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@@ -37,7 +37,7 @@
@for (action of headerActions; track action.label) {
<button
type="button"
class="btn btn-link text-dark p-2 text-decoration-none"
class="btn btn-link store-layout__action-button p-2 text-decoration-none"
[attr.aria-label]="action.label"
[attr.title]="action.label"
>
@@ -53,7 +53,7 @@
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-dark text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>

View File

@@ -1,3 +1,13 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-footer-bg);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
}
.store-layout__header-main {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.store-layout__brand-slot {
min-width: 10rem;
}
@@ -15,19 +25,17 @@
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: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
@@ -37,9 +45,47 @@
min-width: 18rem;
}
.store-layout__search-input {
border-color: rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, white 70%, var(--tenant-primary));
}
.store-layout__search-button {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.2);
background-color: color-mix(in srgb, white 10%, var(--tenant-header-footer-bg));
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.28);
background-color: color-mix(in srgb, white 16%, var(--tenant-header-footer-bg));
}
.store-layout__action-button {
color: #ffffff;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #ffffff;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
}
@keyframes store-header-logo-skeleton {

View File

@@ -12,23 +12,6 @@ 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;
}
@Input() logoUrl: string | null = null;
}

View File

@@ -1,6 +1,12 @@
:host {
display: flex;
min-height: 100dvh;
background-color: #f6f6f6;
background:
radial-gradient(
circle at top,
color-mix(in srgb, white 88%, var(--tenant-primary)) 0%,
transparent 58%
),
#f6f6f6;
color: #202020;
}

View File

@@ -1,13 +1,42 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { StoreLayoutComponent } from './store-layout.component';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
};
describe('StoreLayoutComponent', () => {
beforeEach(async () => {
const tenantState = signal(tenant);
await TestBed.configureTestingModule({
imports: [StoreLayoutComponent],
providers: [provideRouter([])]
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
}
]
}).compileComponents();
});
@@ -23,7 +52,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('renders the placeholder branding and action icons', () => {
it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -31,6 +60,12 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect(
compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
).not.toBeNull();
expect(
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();