feat: refactor routing and enhance tenant service integration

- Updated app routing to redirect to the store feature and load children routes correctly.
- Enhanced app styles with tenant-specific branding and loading states.
- Improved app component to dynamically set CSS variables based on tenant colors.
- Refactored store layout components to simplify logo handling and improve accessibility.
- Updated tenant service to manage state more effectively and handle SSR scenarios.
- Added tests for tenant service and layout components to ensure proper functionality.
- Introduced a new SSR cache store for tenant state management.
- Enhanced button styles to utilize tenant colors for better branding consistency.
This commit is contained in:
2026-06-26 15:55:45 -03:00
parent 7ee103b77b
commit 86680fc976
22 changed files with 726 additions and 188 deletions

View File

@@ -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 { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser'; import { provideClientHydration } from '@angular/platform-browser';
@@ -11,7 +11,7 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideClientHydration(), provideClientHydration(),
provideHttpClient(), provideHttpClient(withFetch()),
provideAppInitializer(tenantBootstrap) provideAppInitializer(tenantBootstrap)
] ]
}; };

View File

@@ -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>
}

View File

@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
{ {
path: '**', path: '**',
renderMode: RenderMode.Prerender renderMode: RenderMode.Server
} }
]; ];

View File

@@ -1,15 +1,55 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { App } from './app'; import { App } from './app';
import { routes } from './app.routes'; 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(); TestBed.resetTestingModule();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter(routes)] providers: [
provideRouter(routes),
{
provide: TenantService,
useValue: tenantService
}
]
}).compileComponents(); }).compileComponents();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
@@ -24,21 +64,37 @@ async function renderAppAt(url: string) {
} }
describe('app routes', () => { describe('app routes', () => {
it('loads the store layout at /store', async () => { it('loads the store layout at /', async () => {
const { fixture, router } = await renderAppAt('/store'); const { fixture, router } = await renderAppAt('/');
const compiled = fixture.nativeElement as HTMLElement; 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-layout')).not.toBeNull();
expect(compiled.querySelector('.store-home')).not.toBeNull(); expect(compiled.querySelector('.store-home')).not.toBeNull();
expect(compiled.textContent).toContain('Tienda en construccion'); 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('/'); const { fixture, router } = await renderAppAt('/');
await router.navigateByUrl('/componentes-test/reutilizables');
await fixture.whenStable();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/componentes-test/reutilizables'); expect(router.url).toBe('/componentes-test/reutilizables');
expect(compiled.textContent).toContain('Componentes 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');
});
}); });

View File

@@ -8,11 +8,11 @@ export const routes: Routes = [
}, },
{ {
path: 'store', path: 'store',
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes) pathMatch: 'full',
redirectTo: ''
}, },
{ {
path: '', path: '',
pathMatch: 'full', loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
redirectTo: 'componentes-test'
} }
]; ];

View File

@@ -1,3 +1,68 @@
:host { :host {
display: block; 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);
}
} }

View File

@@ -1,23 +1,72 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app'; 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', () => { describe('App', () => {
beforeEach(async () => { it('creates the app when the tenant is ready', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('ready', tenant)
}
]
}).compileComponents(); }).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', () => { it('renders the tenant not found screen when the tenant is missing', async () => {
const fixture = TestBed.createComponent(App); await TestBed.configureTestingModule({
const app = fixture.componentInstance; imports: [App],
expect(app).toBeTruthy(); providers: [
}); provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('not-found', null)
}
]
}).compileComponents();
it('should render title', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull(); expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
}); });
}); });

View File

@@ -1,10 +1,38 @@
import { Component } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet],
templateUrl: './app.html', 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
);
}

View File

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

View File

@@ -1,5 +1,5 @@
.store-layout__footer { .store-layout__footer {
background-color: #323232; background-color: var(--tenant-header-footer-bg);
} }
.store-layout__brand-slot { .store-layout__brand-slot {
@@ -20,13 +20,6 @@
object-fit: contain; object-fit: contain;
} }
.store-layout__brand-logo--preload {
opacity: 0;
position: absolute;
inset: 0;
pointer-events: none;
}
.store-layout__brand-logo-skeleton { .store-layout__brand-logo-skeleton {
display: block; display: block;
width: 100%; width: 100%;
@@ -69,6 +62,10 @@
color: #f8f8f8; color: #f8f8f8;
} }
.store-layout__muted-text {
color: rgba(255, 255, 255, 0.72);
}
@keyframes store-footer-logo-skeleton { @keyframes store-footer-logo-skeleton {
from { from {
background-position: 200% 0; background-position: 200% 0;

View File

@@ -17,25 +17,8 @@ export type StoreSocialLink = {
styleUrl: './store-footer.component.scss' styleUrl: './store-footer.component.scss'
}) })
export class StoreFooterComponent { export class StoreFooterComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) currentYear = new Date().getFullYear(); @Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = []; @Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = []; @Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input() logoUrl: string | null = null;
@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,20 +1,16 @@
<header class="store-layout__header bg-white border-bottom"> <header class="store-layout__header">
<div class="border-bottom"> <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="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 <div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
data-testid="store-header-brand-slot" data-testid="store-header-brand-slot"
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@if (logoUrl && logoLoaded) { @if (logoUrl) {
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" /> <img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
} @else { } @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true"> <div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span> <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> </div>
@@ -25,10 +21,14 @@
<input <input
id="store-search-input" id="store-search-input"
type="search" 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" 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> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -37,7 +37,7 @@
@for (action of headerActions; track action.label) { @for (action of headerActions; track action.label) {
<button <button
type="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.aria-label]="action.label"
[attr.title]="action.label" [attr.title]="action.label"
> >
@@ -53,7 +53,7 @@
<div class="container-xl px-3 px-md-4"> <div class="container-xl px-3 px-md-4">
<button <button
type="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" aria-label="Categorias"
> >
<span>Categorias</span> <span>Categorias</span>

View File

@@ -1,3 +1,13 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-footer-bg);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
}
.store-layout__header-main {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 10rem; min-width: 10rem;
} }
@@ -15,19 +25,17 @@
object-fit: contain; object-fit: contain;
} }
.store-layout__brand-logo--preload {
opacity: 0;
position: absolute;
inset: 0;
pointer-events: none;
}
.store-layout__brand-logo-skeleton { .store-layout__brand-logo-skeleton {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 0.75rem; 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%; background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite; animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
} }
@@ -37,9 +45,47 @@
min-width: 18rem; 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 { .store-layout__category-trigger {
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 0.02em; 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 { @keyframes store-header-logo-skeleton {

View File

@@ -12,23 +12,6 @@ export type StoreHeaderAction = {
styleUrl: './store-header.component.scss' styleUrl: './store-header.component.scss'
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) headerActions: StoreHeaderAction[] = []; @Input({ required: true }) headerActions: StoreHeaderAction[] = [];
@Input() logoUrl: string | null = null;
@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,6 +1,12 @@
:host { :host {
display: flex; display: flex;
min-height: 100dvh; 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; color: #202020;
} }

View File

@@ -1,13 +1,42 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { StoreLayoutComponent } from './store-layout.component'; 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', () => { describe('StoreLayoutComponent', () => {
beforeEach(async () => { beforeEach(async () => {
const tenantState = signal(tenant);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreLayoutComponent], 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(); }).compileComponents();
}); });
@@ -23,7 +52,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); 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); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); 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-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-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-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull(); expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
expect(compiled.querySelector('.fa-instagram')).not.toBeNull(); expect(compiled.querySelector('.fa-instagram')).not.toBeNull();

View File

@@ -5,18 +5,7 @@ import { TenantService } from './tenant.service';
describe('tenantBootstrap', () => { describe('tenantBootstrap', () => {
it('waits for TenantService.bootstrap to resolve', async () => { it('waits for TenantService.bootstrap to resolve', async () => {
const bootstrapSpy = vi.fn().mockResolvedValue({ const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
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'
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [

View File

@@ -5,5 +5,5 @@ import { TenantService } from './tenant.service';
export function tenantBootstrap(): Promise<void> { export function tenantBootstrap(): Promise<void> {
const tenantService = inject(TenantService); const tenantService = inject(TenantService);
return tenantService.bootstrap().then(() => undefined); return tenantService.bootstrap();
} }

View 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');

View File

@@ -1,15 +1,33 @@
import { PLATFORM_ID } from '@angular/core'; import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../environments/environment'; 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'; 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', () => { describe('TenantService', () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, '', 'http://localhost:4200/store'); window.history.replaceState({}, '', 'http://localhost:4200/');
}); });
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => { it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
@@ -21,30 +39,124 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); 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'); expect(request.request.method).toBe('GET');
const response: TenantBootstrapResponse = { request.flush(tenantResponse);
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(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); httpController.verify();
expect(service.tenant()).toEqual(response.data); });
expect(service.getTenant()).toEqual(response.data);
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(); httpController.verify();
}); });
@@ -58,33 +170,50 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`); const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
request.flush({}); request.flush({});
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data'); await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify(); httpController.verify();
}); });
it('skips the HTTP bootstrap on the server platform', async () => { it('propagates non-404 backend failures', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
TenantService, 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 service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
await expect(service.bootstrap()).resolves.toBeNull(); const bootstrapPromise = service.bootstrap();
expect(service.tenant()).toBeNull(); const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
expect(service.getTenant()).toBeNull();
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(); httpController.verify();
}); });
}); });

View File

@@ -1,10 +1,24 @@
import { HttpClient } from '@angular/common/http'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common'; 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 { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState
} from './tenant-ssr-cache.store';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -12,40 +26,141 @@ import { Tenant, TenantBootstrapResponse } from './tenant.interface';
export class TenantService { export class TenantService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID); 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 tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle');
readonly tenant = this.tenantState.asReadonly(); readonly tenant = this.tenantState.asReadonly();
readonly status = this.statusState.asReadonly();
getTenant(): Tenant | null { getTenant(): Tenant | null {
return this.tenantState(); return this.tenantState();
} }
async bootstrap(): Promise<Tenant | null> { async bootstrap(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) { if (this.statusState() !== 'idle') {
return null; 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 domain = this.resolveDomain();
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) { try {
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`); 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 { 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.'); 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;
} }
} }

View File

@@ -1,7 +1,3 @@
@use "sass:color";
@import "../../../../_theme";
:host { :host {
display: inline-block; display: inline-block;
} }
@@ -9,13 +5,15 @@
.btn { .btn {
min-height: 40px; min-height: 40px;
font-weight: 700; font-weight: 700;
font-size:15px; font-size: 15px;
min-width:216px; min-width: 216px;
} }
.btn-primary, .btn-primary,
.btn-secondary, .btn-secondary,
.btn-danger { .btn-danger {
--bs-btn-bg: transparent;
--bs-btn-border-color: transparent;
--bs-btn-color: #fff; --bs-btn-color: #fff;
--bs-btn-hover-color: #fff; --bs-btn-hover-color: #fff;
--bs-btn-active-color: #fff; --bs-btn-active-color: #fff;
@@ -23,44 +21,68 @@
.btn-outline-primary, .btn-outline-primary,
.btn-outline-danger { .btn-outline-danger {
--bs-btn-bg: transparent;
--bs-btn-hover-color: #fff; --bs-btn-hover-color: #fff;
--bs-btn-active-color: #fff; --bs-btn-active-color: #fff;
} }
.btn-primary { .btn-primary {
$primary-disabled-bg: color.mix(white, $primary, $weight: $primary-button-disabled-tint-amount); --bs-btn-bg: var(--tenant-primary);
--bs-btn-border-color: var(--tenant-primary);
--bs-btn-hover-bg: #{color.mix(black, $primary, $weight: $primary-button-hover-shade-amount)}; --bs-btn-hover-bg: color-mix(in srgb, black 40%, var(--tenant-primary));
--bs-btn-hover-border-color: #{color.mix(black, $primary, $weight: $primary-button-hover-shade-amount)}; --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-color: #fff;
--bs-btn-disabled-bg: #{$primary-disabled-bg}; --bs-btn-disabled-bg: color-mix(in srgb, white 50%, var(--tenant-primary));
--bs-btn-disabled-border-color: #{$primary-disabled-bg}; --bs-btn-disabled-border-color: color-mix(in srgb, white 50%, var(--tenant-primary));
--bs-btn-disabled-opacity: 1; --bs-btn-disabled-opacity: 1;
} }
.btn-secondary { .btn-secondary {
$secondary-hover-active-bg: color.mix(black, $secondary, $weight: $secondary-button-hover-active-shade-amount); --bs-btn-bg: var(--tenant-secondary);
$secondary-disabled-bg: color.change($secondary, $alpha: $secondary-button-disabled-alpha); --bs-btn-border-color: var(--tenant-secondary);
--bs-btn-hover-bg: color-mix(in srgb, black 26%, var(--tenant-secondary));
--bs-btn-hover-bg: #{$secondary-hover-active-bg}; --bs-btn-hover-border-color: color-mix(in srgb, black 26%, var(--tenant-secondary));
--bs-btn-hover-border-color: #{$secondary-hover-active-bg}; --bs-btn-active-bg: color-mix(in srgb, black 30%, var(--tenant-secondary));
--bs-btn-active-bg: #{$secondary-hover-active-bg}; --bs-btn-active-border-color: color-mix(in srgb, black 30%, var(--tenant-secondary));
--bs-btn-active-border-color: #{$secondary-hover-active-bg};
--bs-btn-disabled-color: #fff; --bs-btn-disabled-color: #fff;
--bs-btn-disabled-bg: #{$secondary-disabled-bg}; --bs-btn-disabled-bg: color-mix(in srgb, transparent 50%, var(--tenant-secondary));
--bs-btn-disabled-border-color: #{$secondary-disabled-bg}; --bs-btn-disabled-border-color: color-mix(in srgb, transparent 50%, var(--tenant-secondary));
--bs-btn-disabled-opacity: 1; --bs-btn-disabled-opacity: 1;
} }
.btn-danger { .btn-danger {
$danger-disabled-bg: color.change($danger, $alpha: $danger-button-disabled-alpha); --bs-btn-bg: var(--tenant-danger);
--bs-btn-border-color: var(--tenant-danger);
--bs-btn-hover-bg: #{$danger-button-hover-active-bg}; --bs-btn-hover-bg: color-mix(in srgb, black 38%, var(--tenant-danger));
--bs-btn-hover-border-color: #{$danger-button-hover-active-bg}; --bs-btn-hover-border-color: color-mix(in srgb, black 38%, var(--tenant-danger));
--bs-btn-active-bg: #{$danger-button-hover-active-bg}; --bs-btn-active-bg: color-mix(in srgb, black 44%, var(--tenant-danger));
--bs-btn-active-border-color: #{$danger-button-hover-active-bg}; --bs-btn-active-border-color: color-mix(in srgb, black 44%, var(--tenant-danger));
--bs-btn-disabled-color: #fff; --bs-btn-disabled-color: #fff;
--bs-btn-disabled-bg: #{$danger-disabled-bg}; --bs-btn-disabled-bg: color-mix(in srgb, transparent 50%, var(--tenant-danger));
--bs-btn-disabled-border-color: #{$danger-disabled-bg}; --bs-btn-disabled-border-color: color-mix(in srgb, transparent 50%, var(--tenant-danger));
--bs-btn-disabled-opacity: 1; --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));
}