diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e0d9b22..ec3a18a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withFetch } from '@angular/common/http'; import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideClientHydration } from '@angular/platform-browser'; @@ -11,7 +11,7 @@ export const appConfig: ApplicationConfig = { provideBrowserGlobalErrorListeners(), provideRouter(routes), provideClientHydration(), - provideHttpClient(), + provideHttpClient(withFetch()), provideAppInitializer(tenantBootstrap) ] }; diff --git a/src/app/app.html b/src/app/app.html index 67e7bd4..210e47b 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1 +1,17 @@ - +@if (status() === 'ready') { + +} @else if (status() === 'not-found') { +
+
+ 404 +

No encontramos una tienda para este dominio

+

+ Verifica la configuracion del tenant o intenta nuevamente con un dominio valido. +

+
+
+} @else { + +} diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index ffd37b1..2c5a11d 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ { path: '**', - renderMode: RenderMode.Prerender + renderMode: RenderMode.Server } ]; diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 110d968..e4ada11 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -1,15 +1,55 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { App } from './app'; import { routes } from './app.routes'; +import { Tenant } from './core/services/tenant.interface'; +import { TenantService } from './core/services/tenant.service'; -async function renderAppAt(url: string) { +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' = 'ready', + currentTenant: Tenant | null = tenant +) { + const tenantState = signal(currentTenant); + const statusState = signal(status); + + return { + tenant: tenantState.asReadonly(), + status: statusState.asReadonly(), + getTenant: () => tenantState(), + bootstrap: vi.fn().mockResolvedValue(undefined) + }; +} + +async function renderAppAt( + url: string, + tenantService: ReturnType = 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 { } @@ -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 @@ -
-
+
+
- @if (logoUrl && logoLoaded) { + @if (logoUrl) { } @else { }
@@ -25,10 +21,14 @@ -
@@ -37,7 +37,7 @@ @for (action of headerActions; track action.label) {