Compare commits
3 Commits
refactor/p
...
prueba/cac
| Author | SHA1 | Date | |
|---|---|---|---|
| fa47188898 | |||
| e074aeb199 | |||
| 86680fc976 |
@@ -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)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
<router-outlet />
|
||||
@if (status() === 'ready') {
|
||||
<router-outlet />
|
||||
} @else if (status() === 'not-found') {
|
||||
<section class="tenant-status">
|
||||
<div class="tenant-status__card">
|
||||
<span class="tenant-status__code">404</span>
|
||||
<h1 class="tenant-status__title">No encontramos una tienda para este dominio</h1>
|
||||
<p class="tenant-status__description">
|
||||
Verifica la configuracion del tenant o intenta nuevamente con un dominio valido.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="tenant-status tenant-status--loading" aria-hidden="true">
|
||||
<div class="tenant-status__spinner"></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
renderMode: RenderMode.Server
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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<typeof createTenantServiceStub> = 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,88 @@
|
||||
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);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
|
||||
tenant.primary_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
|
||||
tenant.secondary_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
|
||||
tenant.danger_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
|
||||
'99, 118, 243'
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
|
||||
'160, 160, 160'
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
|
||||
'255, 136, 136'
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
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';
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const cleanHex = hex.replace('#', '').trim();
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (cleanHex.length === 3) {
|
||||
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||
b = parseInt(cleanHex[2] + cleanHex[2], 16);
|
||||
} else if (cleanHex.length >= 6) {
|
||||
r = parseInt(cleanHex.slice(0, 2), 16);
|
||||
g = parseInt(cleanHex.slice(2, 4), 16);
|
||||
b = parseInt(cleanHex.slice(4, 6), 16);
|
||||
}
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
@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-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
|
||||
'[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 tenantPrimaryColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantPrimaryColor())
|
||||
);
|
||||
protected readonly tenantSecondaryColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantSecondaryColor())
|
||||
);
|
||||
protected readonly tenantDangerColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantDangerColor())
|
||||
);
|
||||
protected readonly tenantHeaderFooterBgColor = computed(
|
||||
() =>
|
||||
this.tenantService.tenant()?.header_footer_bg_color ??
|
||||
DEFAULT_TENANT_BRANDING.headerFooterBgColor
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 © {{ currentYear }} - Sonder</p>
|
||||
<p class="mb-0 small store-layout__muted-text">Copyright © {{ 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,18 +5,7 @@ import { TenantService } from './tenant.service';
|
||||
|
||||
describe('tenantBootstrap', () => {
|
||||
it('waits for TenantService.bootstrap to resolve', async () => {
|
||||
const bootstrapSpy = vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tennant',
|
||||
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'
|
||||
});
|
||||
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
||||
@@ -5,5 +5,5 @@ import { TenantService } from './tenant.service';
|
||||
export function tenantBootstrap(): Promise<void> {
|
||||
const tenantService = inject(TenantService);
|
||||
|
||||
return tenantService.bootstrap().then(() => undefined);
|
||||
return tenantService.bootstrap();
|
||||
}
|
||||
|
||||
23
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
23
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { makeStateKey } from '@angular/core';
|
||||
|
||||
import { Tenant } from './tenant.interface';
|
||||
|
||||
export type TenantBootstrapStatus = 'idle' | 'ready' | 'not-found';
|
||||
|
||||
export type TenantSsrState =
|
||||
| {
|
||||
status: 'ready';
|
||||
tenant: Tenant;
|
||||
}
|
||||
| {
|
||||
status: 'not-found';
|
||||
};
|
||||
|
||||
export const DEFAULT_TENANT_BRANDING = {
|
||||
primaryColor: '#102bda',
|
||||
secondaryColor: '#9e9e9e',
|
||||
dangerColor: '#fd4c4c',
|
||||
headerFooterBgColor: '#313131'
|
||||
} as const;
|
||||
|
||||
export const TENANT_SSR_STATE_KEY = makeStateKey<TenantSsrState | null>('tenant-ssr-state');
|
||||
@@ -1,15 +1,37 @@
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { TenantBootstrapResponse } from './tenant.interface';
|
||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
|
||||
import { TenantService } from './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'
|
||||
};
|
||||
|
||||
const tenantResponse: TenantBootstrapResponse = {
|
||||
data: tenant
|
||||
};
|
||||
|
||||
describe('TenantService', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/store');
|
||||
try {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||
} catch (e) {
|
||||
// Ignore SecurityError under test environments
|
||||
}
|
||||
});
|
||||
|
||||
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
||||
@@ -21,30 +43,124 @@ describe('TenantService', () => {
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
|
||||
const response: TenantBootstrapResponse = {
|
||||
data: {
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tennant',
|
||||
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'
|
||||
}
|
||||
};
|
||||
request.flush(tenantResponse);
|
||||
|
||||
request.flush(response);
|
||||
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||
expect(service.status()).toBe('ready');
|
||||
expect(service.tenant()).toEqual(tenant);
|
||||
expect(service.getTenant()).toEqual(tenant);
|
||||
|
||||
await expect(bootstrapPromise).resolves.toEqual(response.data);
|
||||
expect(service.tenant()).toEqual(response.data);
|
||||
expect(service.getTenant()).toEqual(response.data);
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
transferState.set(TENANT_SSR_STATE_KEY, {
|
||||
status: 'ready',
|
||||
tenant
|
||||
});
|
||||
|
||||
await expect(service.bootstrap()).resolves.toBeUndefined();
|
||||
expect(service.status()).toBe('ready');
|
||||
expect(service.tenant()).toEqual(tenant);
|
||||
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toBeNull();
|
||||
|
||||
httpController.expectNone(() => true);
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
it('uses the forwarded host during SSR and stores the resolved tenant in TransferState', async () => {
|
||||
const responseInit: ResponseInit = {};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
TenantService,
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: new Request('https://internal.example/render', {
|
||||
headers: {
|
||||
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
|
||||
host: 'ignored.example.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||
]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
|
||||
|
||||
request.flush(tenantResponse);
|
||||
|
||||
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||
expect(service.status()).toBe('ready');
|
||||
expect(service.tenant()).toEqual(tenant);
|
||||
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||
status: 'ready',
|
||||
tenant
|
||||
});
|
||||
expect(responseInit.status).toBeUndefined();
|
||||
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
it('stores a not-found state and sets the SSR response status to 404 when the tenant does not exist', async () => {
|
||||
const responseInit: ResponseInit = {};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
TenantService,
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: new Request('https://internal.example/render', {
|
||||
headers: {
|
||||
host: 'missing.example.com:8443'
|
||||
}
|
||||
})
|
||||
},
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||
]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
|
||||
|
||||
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
|
||||
|
||||
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||
expect(service.status()).toBe('not-found');
|
||||
expect(service.tenant()).toBeNull();
|
||||
expect(service.getTenant()).toBeNull();
|
||||
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||
status: 'not-found'
|
||||
});
|
||||
expect(responseInit.status).toBe(404);
|
||||
|
||||
httpController.verify();
|
||||
});
|
||||
@@ -58,33 +174,50 @@ describe('TenantService', () => {
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
||||
|
||||
request.flush({});
|
||||
|
||||
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
|
||||
expect(service.status()).toBe('idle');
|
||||
expect(service.tenant()).toBeNull();
|
||||
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
it('skips the HTTP bootstrap on the server platform', async () => {
|
||||
it('propagates non-404 backend failures', async () => {
|
||||
const responseInit: ResponseInit = {};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
TenantService,
|
||||
{ provide: PLATFORM_ID, useValue: 'server' }
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: new Request('https://internal.example/render', {
|
||||
headers: {
|
||||
host: 'broken.example.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||
]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
await expect(service.bootstrap()).resolves.toBeNull();
|
||||
expect(service.tenant()).toBeNull();
|
||||
expect(service.getTenant()).toBeNull();
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
|
||||
|
||||
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
await expect(bootstrapPromise).rejects.toThrow('Http failure response');
|
||||
expect(service.status()).toBe('idle');
|
||||
expect(responseInit.status).toBeUndefined();
|
||||
|
||||
httpController.expectNone(() => true);
|
||||
httpController.verify();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
inject,
|
||||
Injectable,
|
||||
PLATFORM_ID,
|
||||
REQUEST,
|
||||
RESPONSE_INIT,
|
||||
signal,
|
||||
TransferState
|
||||
} from '@angular/core';
|
||||
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||
import {
|
||||
TENANT_SSR_STATE_KEY,
|
||||
TenantBootstrapStatus,
|
||||
TenantSsrState
|
||||
} from './tenant-ssr-cache.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -12,40 +26,141 @@ import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||
export class TenantService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly request = inject(REQUEST, { optional: true });
|
||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||
private readonly transferState = inject(TransferState);
|
||||
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
|
||||
private readonly tenantState = signal<Tenant | null>(null);
|
||||
private readonly statusState = signal<TenantBootstrapStatus>('idle');
|
||||
|
||||
readonly tenant = this.tenantState.asReadonly();
|
||||
readonly status = this.statusState.asReadonly();
|
||||
|
||||
getTenant(): Tenant | null {
|
||||
return this.tenantState();
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<Tenant | null> {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return null;
|
||||
async bootstrap(): Promise<void> {
|
||||
if (this.statusState() !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isDiscoveringRoutes) {
|
||||
this.statusState.set('ready');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const transferredState = this.transferState.get(TENANT_SSR_STATE_KEY, null);
|
||||
|
||||
if (transferredState) {
|
||||
this.transferState.remove(TENANT_SSR_STATE_KEY);
|
||||
this.applyState(transferredState);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = this.resolveDomain();
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
|
||||
);
|
||||
|
||||
if (!response?.data) {
|
||||
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
|
||||
);
|
||||
|
||||
if (!response?.data) {
|
||||
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
||||
}
|
||||
|
||||
this.setReady(response.data);
|
||||
this.persistSsrState({
|
||||
status: 'ready',
|
||||
tenant: response.data
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isNotFoundError(error)) {
|
||||
this.setNotFound();
|
||||
this.persistSsrState({ status: 'not-found' });
|
||||
|
||||
if (this.responseInit) {
|
||||
this.responseInit.status = 404;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.tenantState.set(response.data);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private resolveDomain(): string {
|
||||
const domain = window.location.hostname;
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
const domain = window.location.hostname;
|
||||
|
||||
if (!domain) {
|
||||
if (!domain) {
|
||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||
}
|
||||
|
||||
return this.normalizeHost(domain);
|
||||
}
|
||||
|
||||
const requestHost =
|
||||
this.request?.headers.get('x-forwarded-host') ??
|
||||
this.request?.headers.get('host') ??
|
||||
(this.request ? new URL(this.request.url).host : null);
|
||||
|
||||
if (!requestHost) {
|
||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||
}
|
||||
|
||||
return domain;
|
||||
return this.normalizeHost(requestHost);
|
||||
}
|
||||
|
||||
private normalizeHost(host: string): string {
|
||||
const [firstHost = ''] = host.split(',');
|
||||
const normalizedHost = firstHost.trim().toLowerCase();
|
||||
|
||||
if (!normalizedHost) {
|
||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('[')) {
|
||||
const closingBracketIndex = normalizedHost.indexOf(']');
|
||||
|
||||
if (closingBracketIndex >= 0) {
|
||||
return normalizedHost.slice(1, closingBracketIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedHost.replace(/:\d+$/, '');
|
||||
}
|
||||
|
||||
private applyState(state: TenantSsrState): void {
|
||||
if (state.status === 'ready') {
|
||||
this.setReady(state.tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setNotFound();
|
||||
}
|
||||
|
||||
private setReady(tenant: Tenant): void {
|
||||
this.tenantState.set(tenant);
|
||||
this.statusState.set('ready');
|
||||
}
|
||||
|
||||
private setNotFound(): void {
|
||||
this.tenantState.set(null);
|
||||
this.statusState.set('not-found');
|
||||
}
|
||||
|
||||
private persistSsrState(state: TenantSsrState): void {
|
||||
if (isPlatformServer(this.platformId)) {
|
||||
this.transferState.set(TENANT_SSR_STATE_KEY, state);
|
||||
}
|
||||
}
|
||||
|
||||
private isNotFoundError(error: unknown): error is HttpErrorResponse {
|
||||
return error instanceof HttpErrorResponse && error.status === 404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,4 +176,84 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Tenant Configuration</span>
|
||||
<h2 class="mb-4">Configuración Tenant</h2>
|
||||
|
||||
<!-- Logos section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="tenant-logo-card">
|
||||
<div class="tenant-logo-card__label">Logo del Header</div>
|
||||
<div class="tenant-logo-card__container header-footer-bg">
|
||||
@if (tenant()?.header_logo) {
|
||||
<img [src]="tenant()?.header_logo" alt="Header Logo" class="tenant-logo-card__img" />
|
||||
} @else {
|
||||
<div class="tenant-logo-card__placeholder">
|
||||
<i class="fa-solid fa-image mb-2"></i>
|
||||
<span>Sin Logo de Header</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="tenant-logo-card">
|
||||
<div class="tenant-logo-card__label">Logo del Footer</div>
|
||||
<div class="tenant-logo-card__container header-footer-bg">
|
||||
@if (tenant()?.footer_logo) {
|
||||
<img [src]="tenant()?.footer_logo" alt="Footer Logo" class="tenant-logo-card__img" />
|
||||
} @else {
|
||||
<div class="tenant-logo-card__placeholder">
|
||||
<i class="fa-solid fa-image mb-2"></i>
|
||||
<span>Sin Logo de Footer</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<!-- Colors section -->
|
||||
<div class="colors-section">
|
||||
<h3 class="mb-3">Colores de Marca</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-primary)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
<span class="color-swatch-card__title">Primario</span>
|
||||
<code class="color-swatch-card__value">{{ tenant()?.primary_color || 'Default' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-secondary)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
<span class="color-swatch-card__title">Secundario</span>
|
||||
<code class="color-swatch-card__value">{{ tenant()?.secondary_color || 'Default' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-danger)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
<span class="color-swatch-card__title">Danger</span>
|
||||
<code class="color-swatch-card__value">{{ tenant()?.danger_color || 'Default' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -70,6 +70,12 @@ h1 {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.file-field {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.file-field {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -86,3 +92,109 @@ h1 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.configuracion-tennant {
|
||||
margin-top: 2rem;
|
||||
|
||||
.tenant-logo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-footer-bg {
|
||||
background-color: var(--tenant-header-footer-bg, #f8f9fa);
|
||||
}
|
||||
|
||||
.color-swatch-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 700;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 0.875rem;
|
||||
color: #212529;
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
|
||||
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(currentTenant: Tenant | null) {
|
||||
const tenantState = signal(currentTenant);
|
||||
const statusState = signal<'ready' | 'not-found'>('ready');
|
||||
|
||||
return {
|
||||
tenant: tenantState.asReadonly(),
|
||||
status: statusState.asReadonly(),
|
||||
getTenant: () => tenantState(),
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReutilizablesTestPageComponent', () => {
|
||||
it('renders the tenant configuration section with logo sources and swatch colors', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ReutilizablesTestPageComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub(tenant)
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
// Check section title
|
||||
const sectionTitle = element.querySelector('.configuracion-tennant h2');
|
||||
expect(sectionTitle?.textContent).toContain('Configuración Tenant');
|
||||
|
||||
// Check header logo source
|
||||
const headerImg = element.querySelector('.tenant-logo-card img[alt="Header Logo"]') as HTMLImageElement;
|
||||
expect(headerImg?.src).toBe(tenant.header_logo);
|
||||
|
||||
// Check footer logo source
|
||||
const footerImg = element.querySelector('.tenant-logo-card img[alt="Footer Logo"]') as HTMLImageElement;
|
||||
expect(footerImg?.src).toBe(tenant.footer_logo);
|
||||
|
||||
// Check color hex values
|
||||
expect(element.textContent).toContain(tenant.primary_color);
|
||||
expect(element.textContent).toContain(tenant.secondary_color);
|
||||
expect(element.textContent).toContain(tenant.danger_color);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reutilizables-test-page',
|
||||
@@ -9,6 +10,8 @@ import { InputComponent } from '../../../../shared/components/input/input.compon
|
||||
styleUrl: './reutilizables-test-page.component.scss'
|
||||
})
|
||||
export class ReutilizablesTestPageComponent {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
protected readonly tenant = this.tenantService.tenant;
|
||||
protected textValue = 'Auriculares';
|
||||
protected numberValue = '24';
|
||||
protected email = 'usuario@empresa.com';
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
@use "sass:color";
|
||||
|
||||
@import "../../../../_theme";
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -9,13 +5,15 @@
|
||||
.btn {
|
||||
min-height: 40px;
|
||||
font-weight: 700;
|
||||
font-size:15px;
|
||||
min-width:216px;
|
||||
font-size: 15px;
|
||||
min-width: 216px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger {
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-border-color: transparent;
|
||||
--bs-btn-color: #fff;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-active-color: #fff;
|
||||
@@ -23,44 +21,68 @@
|
||||
|
||||
.btn-outline-primary,
|
||||
.btn-outline-danger {
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-active-color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
$primary-disabled-bg: color.mix(white, $primary, $weight: $primary-button-disabled-tint-amount);
|
||||
|
||||
--bs-btn-hover-bg: #{color.mix(black, $primary, $weight: $primary-button-hover-shade-amount)};
|
||||
--bs-btn-hover-border-color: #{color.mix(black, $primary, $weight: $primary-button-hover-shade-amount)};
|
||||
--bs-btn-bg: var(--tenant-primary);
|
||||
--bs-btn-border-color: var(--tenant-primary);
|
||||
--bs-btn-hover-bg: color-mix(in srgb, black 40%, var(--tenant-primary));
|
||||
--bs-btn-hover-border-color: color-mix(in srgb, black 40%, var(--tenant-primary));
|
||||
--bs-btn-active-bg: color-mix(in srgb, black 44%, var(--tenant-primary));
|
||||
--bs-btn-active-border-color: color-mix(in srgb, black 44%, var(--tenant-primary));
|
||||
--bs-btn-disabled-color: #fff;
|
||||
--bs-btn-disabled-bg: #{$primary-disabled-bg};
|
||||
--bs-btn-disabled-border-color: #{$primary-disabled-bg};
|
||||
--bs-btn-disabled-bg: color-mix(in srgb, white 50%, var(--tenant-primary));
|
||||
--bs-btn-disabled-border-color: color-mix(in srgb, white 50%, var(--tenant-primary));
|
||||
--bs-btn-disabled-opacity: 1;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
$secondary-hover-active-bg: color.mix(black, $secondary, $weight: $secondary-button-hover-active-shade-amount);
|
||||
$secondary-disabled-bg: color.change($secondary, $alpha: $secondary-button-disabled-alpha);
|
||||
|
||||
--bs-btn-hover-bg: #{$secondary-hover-active-bg};
|
||||
--bs-btn-hover-border-color: #{$secondary-hover-active-bg};
|
||||
--bs-btn-active-bg: #{$secondary-hover-active-bg};
|
||||
--bs-btn-active-border-color: #{$secondary-hover-active-bg};
|
||||
--bs-btn-bg: var(--tenant-secondary);
|
||||
--bs-btn-border-color: var(--tenant-secondary);
|
||||
--bs-btn-hover-bg: color-mix(in srgb, black 26%, var(--tenant-secondary));
|
||||
--bs-btn-hover-border-color: color-mix(in srgb, black 26%, var(--tenant-secondary));
|
||||
--bs-btn-active-bg: color-mix(in srgb, black 30%, var(--tenant-secondary));
|
||||
--bs-btn-active-border-color: color-mix(in srgb, black 30%, var(--tenant-secondary));
|
||||
--bs-btn-disabled-color: #fff;
|
||||
--bs-btn-disabled-bg: #{$secondary-disabled-bg};
|
||||
--bs-btn-disabled-border-color: #{$secondary-disabled-bg};
|
||||
--bs-btn-disabled-bg: color-mix(in srgb, transparent 50%, var(--tenant-secondary));
|
||||
--bs-btn-disabled-border-color: color-mix(in srgb, transparent 50%, var(--tenant-secondary));
|
||||
--bs-btn-disabled-opacity: 1;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
$danger-disabled-bg: color.change($danger, $alpha: $danger-button-disabled-alpha);
|
||||
|
||||
--bs-btn-hover-bg: #{$danger-button-hover-active-bg};
|
||||
--bs-btn-hover-border-color: #{$danger-button-hover-active-bg};
|
||||
--bs-btn-active-bg: #{$danger-button-hover-active-bg};
|
||||
--bs-btn-active-border-color: #{$danger-button-hover-active-bg};
|
||||
--bs-btn-bg: var(--tenant-danger);
|
||||
--bs-btn-border-color: var(--tenant-danger);
|
||||
--bs-btn-hover-bg: color-mix(in srgb, black 38%, var(--tenant-danger));
|
||||
--bs-btn-hover-border-color: color-mix(in srgb, black 38%, var(--tenant-danger));
|
||||
--bs-btn-active-bg: color-mix(in srgb, black 44%, var(--tenant-danger));
|
||||
--bs-btn-active-border-color: color-mix(in srgb, black 44%, var(--tenant-danger));
|
||||
--bs-btn-disabled-color: #fff;
|
||||
--bs-btn-disabled-bg: #{$danger-disabled-bg};
|
||||
--bs-btn-disabled-border-color: #{$danger-disabled-bg};
|
||||
--bs-btn-disabled-bg: color-mix(in srgb, transparent 50%, var(--tenant-danger));
|
||||
--bs-btn-disabled-border-color: color-mix(in srgb, transparent 50%, var(--tenant-danger));
|
||||
--bs-btn-disabled-opacity: 1;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: var(--tenant-primary);
|
||||
--bs-btn-border-color: var(--tenant-primary);
|
||||
--bs-btn-hover-bg: var(--tenant-primary);
|
||||
--bs-btn-hover-border-color: var(--tenant-primary);
|
||||
--bs-btn-active-bg: color-mix(in srgb, black 12%, var(--tenant-primary));
|
||||
--bs-btn-active-border-color: color-mix(in srgb, black 12%, var(--tenant-primary));
|
||||
--bs-btn-disabled-color: color-mix(in srgb, transparent 35%, var(--tenant-primary));
|
||||
--bs-btn-disabled-border-color: color-mix(in srgb, transparent 35%, var(--tenant-primary));
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
--bs-btn-color: var(--tenant-danger);
|
||||
--bs-btn-border-color: var(--tenant-danger);
|
||||
--bs-btn-hover-bg: var(--tenant-danger);
|
||||
--bs-btn-hover-border-color: var(--tenant-danger);
|
||||
--bs-btn-active-bg: color-mix(in srgb, black 12%, var(--tenant-danger));
|
||||
--bs-btn-active-border-color: color-mix(in srgb, black 12%, var(--tenant-danger));
|
||||
--bs-btn-disabled-color: color-mix(in srgb, transparent 35%, var(--tenant-danger));
|
||||
--bs-btn-disabled-border-color: color-mix(in srgb, transparent 35%, var(--tenant-danger));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
:root {
|
||||
--bs-body-font-family: "Gotham", sans-serif;
|
||||
--bs-primary: var(--tenant-primary);
|
||||
--bs-primary-rgb: var(--tenant-primary-rgb);
|
||||
--bs-secondary: var(--tenant-secondary);
|
||||
--bs-secondary-rgb: var(--tenant-secondary-rgb);
|
||||
--bs-danger: var(--tenant-danger);
|
||||
--bs-danger-rgb: var(--tenant-danger-rgb);
|
||||
}
|
||||
|
||||
label {
|
||||
|
||||
Reference in New Issue
Block a user