= createTenantServiceStub()
+) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [App],
- providers: [provideRouter(routes)]
+ providers: [
+ provideRouter(routes),
+ {
+ provide: TenantService,
+ useValue: tenantService
+ }
+ ]
}).compileComponents();
const router = TestBed.inject(Router);
@@ -24,21 +64,37 @@ async function renderAppAt(url: string) {
}
describe('app routes', () => {
- it('loads the store layout at /store', async () => {
- const { fixture, router } = await renderAppAt('/store');
+ it('loads the store layout at /', async () => {
+ const { fixture, router } = await renderAppAt('/');
const compiled = fixture.nativeElement as HTMLElement;
- expect(router.url).toBe('/store');
+ expect(router.url).toBe('/');
expect(compiled.querySelector('.store-layout')).not.toBeNull();
expect(compiled.querySelector('.store-home')).not.toBeNull();
expect(compiled.textContent).toContain('Tienda en construccion');
+ expect(compiled.style.getPropertyValue('--tenant-primary')).toBe(tenant.primary_color);
});
- it('keeps the root redirect under componentes-test', async () => {
+ it('loads componentes-test under a valid tenant', async () => {
const { fixture, router } = await renderAppAt('/');
+ await router.navigateByUrl('/componentes-test/reutilizables');
+ await fixture.whenStable();
+ fixture.detectChanges();
+
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/componentes-test/reutilizables');
expect(compiled.textContent).toContain('Componentes reutilizables');
});
+
+ it('renders the tenant not found screen for any route when the tenant is missing', async () => {
+ const { fixture } = await renderAppAt(
+ '/componentes-test/reutilizables',
+ createTenantServiceStub('not-found', null)
+ );
+ const compiled = fixture.nativeElement as HTMLElement;
+
+ expect(compiled.querySelector('.tenant-status')).not.toBeNull();
+ expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
+ });
});
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 4d3c5b6..1bf87ad 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -8,11 +8,11 @@ export const routes: Routes = [
},
{
path: 'store',
- loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
+ pathMatch: 'full',
+ redirectTo: ''
},
{
path: '',
- pathMatch: 'full',
- redirectTo: 'componentes-test'
+ loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
}
];
diff --git a/src/app/app.scss b/src/app/app.scss
index 5d4e87f..e54216c 100644
--- a/src/app/app.scss
+++ b/src/app/app.scss
@@ -1,3 +1,68 @@
:host {
display: block;
+ min-height: 100dvh;
+}
+
+.tenant-status {
+ min-height: 100dvh;
+ display: grid;
+ place-items: center;
+ padding: 2rem;
+ background:
+ radial-gradient(circle at top, color-mix(in srgb, white 82%, var(--tenant-primary)) 0%, transparent 55%),
+ linear-gradient(180deg, #f8f8f8 0%, #efefef 100%);
+ color: #202020;
+}
+
+.tenant-status__card {
+ width: min(100%, 34rem);
+ padding: 2rem;
+ border: 1px solid rgba(32, 32, 32, 0.1);
+ border-radius: 1.5rem;
+ background-color: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.1);
+}
+
+.tenant-status__code {
+ display: inline-block;
+ margin-bottom: 1rem;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ color: var(--tenant-primary);
+}
+
+.tenant-status__title {
+ margin: 0 0 0.75rem;
+ font-size: clamp(2rem, 4vw, 2.75rem);
+ line-height: 1.05;
+}
+
+.tenant-status__description {
+ margin: 0;
+ font-size: 1rem;
+ line-height: 1.6;
+ color: rgba(32, 32, 32, 0.72);
+}
+
+.tenant-status--loading {
+ background:
+ radial-gradient(circle at center, color-mix(in srgb, white 88%, var(--tenant-primary)) 0%, transparent 60%),
+ #f5f5f5;
+}
+
+.tenant-status__spinner {
+ width: 3rem;
+ height: 3rem;
+ border: 0.25rem solid rgba(32, 32, 32, 0.08);
+ border-top-color: var(--tenant-primary);
+ border-radius: 999px;
+ animation: tenant-status-spin 0.8s linear infinite;
+}
+
+@keyframes tenant-status-spin {
+ to {
+ transform: rotate(360deg);
+ }
}
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index d26bfac..60c2626 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,23 +1,72 @@
+import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+
import { App } from './app';
+import { Tenant } from './core/services/tenant.interface';
+import { TenantService } from './core/services/tenant.service';
+
+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'
+};
+
+function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
+ const tenantState = signal(currentTenant);
+ const statusState = signal(status);
+
+ return {
+ tenant: tenantState.asReadonly(),
+ status: statusState.asReadonly(),
+ getTenant: () => tenantState(),
+ bootstrap: vi.fn().mockResolvedValue(undefined)
+ };
+}
describe('App', () => {
- beforeEach(async () => {
+ it('creates the app when the tenant is ready', async () => {
await TestBed.configureTestingModule({
imports: [App],
+ providers: [
+ provideRouter([]),
+ {
+ provide: TenantService,
+ useValue: createTenantServiceStub('ready', tenant)
+ }
+ ]
}).compileComponents();
+
+ const fixture = TestBed.createComponent(App);
+
+ expect(fixture.componentInstance).toBeTruthy();
+ expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
+ tenant.primary_color
+ );
});
- it('should create the app', () => {
- const fixture = TestBed.createComponent(App);
- const app = fixture.componentInstance;
- expect(app).toBeTruthy();
- });
+ it('renders the tenant not found screen when the tenant is missing', async () => {
+ await TestBed.configureTestingModule({
+ imports: [App],
+ providers: [
+ provideRouter([]),
+ {
+ provide: TenantService,
+ useValue: createTenantServiceStub('not-found', null)
+ }
+ ]
+ }).compileComponents();
- it('should render title', async () => {
const fixture = TestBed.createComponent(App);
- await fixture.whenStable();
- const compiled = fixture.nativeElement as HTMLElement;
- expect(compiled.querySelector('router-outlet')).not.toBeNull();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
});
});
diff --git a/src/app/app.ts b/src/app/app.ts
index fb4c9d5..50f30a1 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,10 +1,38 @@
-import { Component } from '@angular/core';
+import { Component, computed, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+import { TenantService } from './core/services/tenant.service';
+import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
+
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
- styleUrl: './app.scss'
+ styleUrl: './app.scss',
+ host: {
+ '[style.--tenant-primary]': 'tenantPrimaryColor()',
+ '[style.--tenant-secondary]': 'tenantSecondaryColor()',
+ '[style.--tenant-danger]': 'tenantDangerColor()',
+ '[style.--tenant-header-footer-bg]': 'tenantHeaderFooterBgColor()'
+ }
})
-export class App {}
+export class App {
+ private readonly tenantService = inject(TenantService);
+
+ protected readonly status = this.tenantService.status;
+
+ protected readonly tenantPrimaryColor = computed(
+ () => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
+ );
+ protected readonly tenantSecondaryColor = computed(
+ () => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
+ );
+ protected readonly tenantDangerColor = computed(
+ () => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
+ );
+ protected readonly tenantHeaderFooterBgColor = computed(
+ () =>
+ this.tenantService.tenant()?.header_footer_bg_color ??
+ DEFAULT_TENANT_BRANDING.headerFooterBgColor
+ );
+}
diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.html b/src/app/core/layout/store-layout/store-footer/store-footer.component.html
index ef60575..b672e3a 100644
--- a/src/app/core/layout/store-layout/store-footer/store-footer.component.html
+++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.html
@@ -7,15 +7,11 @@
data-testid="store-footer-brand-slot"
aria-label="Logo del comercio"
>
- @if (logoUrl && logoLoaded) {
+ @if (logoUrl) {
} @else {
-
- @if (logoUrl && !logoLoaded) {
-
![]()
- }
}
@@ -31,7 +27,7 @@
- Copyright © {{ currentYear }} - Sonder
+ Copyright © {{ currentYear }} - Sonder
@for (section of footerSections; track section.heading) {
@@ -39,7 +35,7 @@
@for (link of section.links; track link) {
- {{ link }}
+ {{ link }}
}
diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss
index 6eea43d..a3453f3 100644
--- a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss
+++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss
@@ -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;
diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.ts b/src/app/core/layout/store-layout/store-footer/store-footer.component.ts
index 4de54a7..febc6eb 100644
--- a/src/app/core/layout/store-layout/store-footer/store-footer.component.ts
+++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.ts
@@ -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;
}
diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html
index 86abb35..35df4ba 100644
--- a/src/app/core/layout/store-layout/store-header/store-header.component.html
+++ b/src/app/core/layout/store-layout/store-header/store-header.component.html
@@ -1,20 +1,16 @@
-