Compare commits

...

2 Commits

12 changed files with 227 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;

View File

@@ -0,0 +1,31 @@
import { of, throwError } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { useMutation } from './use-mutation';
describe('useMutation', () => {
it('stores the resolved value and clears loading after a successful execution', async () => {
const mutation = useMutation<{ id: number }, { ok: boolean; id: number }>();
const factory = vi.fn((payload: { id: number }) => of({ ok: true, id: payload.id }));
await expect(mutation.execute(factory, { id: 7 })).resolves.toEqual({ ok: true, id: 7 });
expect(factory).toHaveBeenCalledWith({ id: 7 });
expect(mutation.loading()).toBe(false);
expect(mutation.error()).toBeNull();
expect(mutation.data()).toEqual({ ok: true, id: 7 });
});
it('stores the thrown error and clears loading after a failed execution', async () => {
const mutation = useMutation<{ id: number }, { ok: boolean }>();
const failure = new Error('request failed');
const factory = vi.fn(() => throwError(() => failure));
await expect(mutation.execute(factory, { id: 7 })).rejects.toThrow('request failed');
expect(factory).toHaveBeenCalledWith({ id: 7 });
expect(mutation.loading()).toBe(false);
expect(mutation.error()).toBe(failure);
expect(mutation.data()).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import { signal, WritableSignal } from '@angular/core';
import { Observable, firstValueFrom } from 'rxjs';
export interface UseMutationResult<TData, TResult> {
loading: WritableSignal<boolean>;
error: WritableSignal<unknown | null>;
data: WritableSignal<TResult | null>;
execute: (factory: (payload: TData) => Observable<TResult>, payload: TData) => Promise<TResult>;
}
export function useMutation<TData, TResult>(): UseMutationResult<TData, TResult> {
const loading = signal(false);
const error = signal<unknown | null>(null);
const data = signal<TResult | null>(null);
async function execute(
factory: (payload: TData) => Observable<TResult>,
payload: TData
): Promise<TResult> {
loading.set(true);
error.set(null);
try {
const result = await firstValueFrom(factory(payload));
data.set(result);
return result;
} catch (err) {
error.set(err);
throw err;
} finally {
loading.set(false);
}
}
return {
loading,
error,
data,
execute
};
}