Compare commits

...

16 Commits

Author SHA1 Message Date
fa47188898 feat: add ReutilizablesTestPageComponent to showcase and validate shared button and input component variants 2026-06-26 16:58:14 -03:00
e074aeb199 feat: implement tenant branding injection and SSR bootstrap logic with comprehensive testing 2026-06-26 16:25:31 -03:00
86680fc976 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.
2026-06-26 15:55:45 -03:00
7ee103b77b feat: enhance store layout with dynamic logo handling and loading states 2026-06-26 14:27:14 -03:00
4e2dccd2e3 feat: implement useMutation hook for managing asynchronous operations with loading and error states 2026-06-26 14:19:10 -03:00
da01745732 feat: implement store layout with header and footer components, update tenant service URL 2026-06-26 14:02:35 -03:00
ac9ba12045 feat: implement tenant service with bootstrap functionality and error handling 2026-06-26 13:43:39 -03:00
23e8da345a feat: implement store layout and home page components with routing 2026-06-26 13:42:23 -03:00
236a3772a4 feat: standardize font size and weight for input component and labels 2026-06-26 10:35:12 -03:00
bccb1b565f feat: refactor input component to use model binding for value management 2026-06-26 10:22:25 -03:00
0d46172325 feat: enhance input component layout with labels and improve styling 2026-06-26 10:18:43 -03:00
f1a4f51695 feat: add user input type with icon support in input component 2026-06-26 10:09:06 -03:00
d58445e199 feat: implement password toggle functionality in input component 2026-06-26 10:06:41 -03:00
455b7c8506 feat: add search input type with icon support and update styles 2026-06-26 09:56:09 -03:00
a0c35c76a7 style: add min-width to button styles for improved layout consistency 2026-06-26 09:15:18 -03:00
6086070891 style: enhance button showcase layout and add disabled state examples 2026-06-26 08:57:15 -03:00
42 changed files with 2185 additions and 172 deletions

View File

@@ -1,12 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient, withFetch } from '@angular/common/http';
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser'; import { tenantBootstrap } from './core/services/tenant-bootstrap';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideClientHydration() provideRouter(routes),
provideClientHydration(),
provideHttpClient(withFetch()),
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
} }
]; ];

100
src/app/app.routes.spec.ts Normal file
View File

@@ -0,0 +1,100 @@
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';
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),
{
provide: TenantService,
useValue: tenantService
}
]
}).compileComponents();
const router = TestBed.inject(Router);
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
await router.navigateByUrl(url);
await fixture.whenStable();
fixture.detectChanges();
return { fixture, router };
}
describe('app routes', () => {
it('loads the store layout at /', async () => {
const { fixture, router } = await renderAppAt('/');
const compiled = fixture.nativeElement as HTMLElement;
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('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');
});
});

View File

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

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,88 @@
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);
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', () => { 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,66 @@
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';
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({ @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-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
);
}

View File

@@ -0,0 +1,57 @@
<footer class="store-layout__footer mt-auto text-light">
<div class="container-xl px-3 px-md-4 py-4 py-md-5">
<div class="row g-4">
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
aria-label="Logo del comercio"
>
@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>
</div>
}
</div>
<div class="d-grid gap-2">
<div class="d-flex align-items-center gap-2 text-light-emphasis">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span>
</div>
<div class="d-flex align-items-center gap-2 text-light-emphasis">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span>
</div>
</div>
<p class="mb-0 small store-layout__muted-text">Copyright &copy; {{ currentYear }} - Sonder</p>
</div>
@for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
<div class="d-grid gap-2">
@for (link of section.links; track link) {
<span class="store-layout__muted-text">{{ link }}</span>
}
</div>
</div>
}
<div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">Seguinos:</h2>
<div class="d-flex align-items-center gap-3">
@for (social of socialLinks; track social.label) {
<span class="fs-5 text-white">
<i [class]="social.iconClass" aria-hidden="true"></i>
<span class="visually-hidden">{{ social.label }}</span>
</span>
}
</div>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,87 @@
.store-layout__footer {
background-color: var(--tenant-header-footer-bg);
}
.store-layout__brand-slot {
min-width: 10rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
max-height: 2.75rem;
object-fit: contain;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.24) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-footer-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__brand-mark {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 999px;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #ffffff;
}
.store-layout__brand-text {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.08em;
color: #ffffff;
}
.store-layout__footer-heading {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f8f8f8;
}
.store-layout__muted-text {
color: rgba(255, 255, 255, 0.72);
}
@keyframes store-footer-logo-skeleton {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
}
.store-layout__brand-text {
font-size: 1.45rem;
}
}

View File

@@ -0,0 +1,24 @@
import { Component, Input } from '@angular/core';
export type StoreFooterSection = {
heading: string;
links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
};
@Component({
selector: 'app-store-footer',
imports: [],
templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss'
})
export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input() logoUrl: string | null = null;
}

View File

@@ -0,0 +1,64 @@
<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) {
<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>
</div>
}
</div>
<div class="d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 ms-auto w-100 w-lg-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
/>
<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>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
@for (action of headerActions; track action.label) {
<button
type="button"
class="btn btn-link store-layout__action-button p-2 text-decoration-none"
[attr.aria-label]="action.label"
[attr.title]="action.label"
>
<i [class]="action.iconClass" aria-hidden="true"></i>
</button>
}
</div>
</div>
</div>
</div>
<div>
<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-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
</div>
</header>

View File

@@ -0,0 +1,109 @@
.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;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
width: min(100%, 24rem);
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 {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
}
.store-layout__search {
min-width: 0;
}
}

View File

@@ -0,0 +1,17 @@
import { Component, Input } from '@angular/core';
export type StoreHeaderAction = {
label: string;
iconClass: string;
};
@Component({
selector: 'app-store-header',
imports: [],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
})
export class StoreHeaderComponent {
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
@Input() logoUrl: string | null = null;
}

View File

@@ -0,0 +1,14 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header [headerActions]="headerActions" [logoUrl]="tenant()?.header_logo ?? null" />
<main class="flex-grow-1 d-block">
<router-outlet />
</main>
<app-store-footer
[currentYear]="currentYear"
[footerSections]="footerSections"
[socialLinks]="socialLinks"
[logoUrl]="tenant()?.footer_logo ?? null"
/>
</div>

View File

@@ -0,0 +1,12 @@
:host {
display: flex;
min-height: 100dvh;
background:
radial-gradient(
circle at top,
color-mix(in srgb, white 88%, var(--tenant-primary)) 0%,
transparent 58%
),
#f6f6f6;
color: #202020;
}

View File

@@ -0,0 +1,76 @@
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([]),
{
provide: TenantService,
useValue: {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
}
]
}).compileComponents();
});
it('renders the main layout sections', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-store-header .store-layout__header')).not.toBeNull();
expect(compiled.querySelector('.store-layout__category-trigger')).not.toBeNull();
expect(compiled.querySelector('router-outlet')).not.toBeNull();
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
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();
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-header.component';
@Component({
selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss'
})
export class StoreLayoutComponent {
private readonly tenantService = inject(TenantService);
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly headerActions: StoreHeaderAction[] = [
{
label: 'Carrito',
iconClass: 'fa-solid fa-cart-shopping'
},
{
label: 'Mi cuenta',
iconClass: 'fa-solid fa-circle-user'
}
];
protected readonly footerSections: StoreFooterSection[] = [
{
heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
},
{
heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}
];
protected readonly socialLinks: StoreSocialLink[] = [
{
label: 'Instagram',
iconClass: 'fa-brands fa-instagram'
},
{
label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp'
},
{
label: 'Facebook',
iconClass: 'fa-brands fa-facebook'
},
{
label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in'
}
];
}

View File

@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { tenantBootstrap } from './tenant-bootstrap';
import { TenantService } from './tenant.service';
describe('tenantBootstrap', () => {
it('waits for TenantService.bootstrap to resolve', async () => {
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [
{
provide: TenantService,
useValue: {
bootstrap: bootstrapSpy
}
}
]
});
await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).resolves.toBeUndefined();
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
});
it('propagates tenant bootstrap failures', async () => {
const bootstrapSpy = vi.fn().mockRejectedValue(new Error('bootstrap failed'));
TestBed.configureTestingModule({
providers: [
{
provide: TenantService,
useValue: {
bootstrap: bootstrapSpy
}
}
]
});
await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).rejects.toThrow(
'bootstrap failed'
);
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { TenantService } from './tenant.service';
export function tenantBootstrap(): Promise<void> {
const tenantService = inject(TenantService);
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

@@ -0,0 +1,16 @@
export interface Tenant {
id: number;
codigo: string;
nombre: string;
dominio: string;
primary_color: string;
secondary_color: string;
danger_color: string;
header_footer_bg_color: string;
header_logo: string;
footer_logo: string;
}
export interface TenantBootstrapResponse {
data: Tenant;
}

View File

@@ -0,0 +1,223 @@
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 { 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(() => {
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 () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
expect(request.request.method).toBe('GET');
request.flush(tenantResponse);
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
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();
});
it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
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('propagates non-404 backend failures', 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: 'broken.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
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.verify();
});
});

View File

@@ -0,0 +1,166 @@
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'
})
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<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();
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;
}
}
private resolveDomain(): string {
if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname;
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 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

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

View File

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

View File

@@ -7,129 +7,253 @@
Pagina dedicada a probar variantes de los componentes compartidos. Pagina dedicada a probar variantes de los componentes compartidos.
</p> </p>
<div class="button-grid"> <div class="button-showcase">
<app-button>Primary</app-button> <div class="button-group">
<app-button variant="secondary">Secondary</app-button> <h2>Comunes</h2>
<app-button variant="danger">Danger</app-button> <div class="button-grid">
<app-button variant="danger-secondary">Danger secondary</app-button> <app-button>Primary</app-button>
<app-button variant="cancel">Cancel</app-button> <app-button variant="secondary">Secondary</app-button>
<app-button variant="danger">Danger</app-button>
<app-button variant="danger-secondary">Danger secondary</app-button>
<app-button variant="cancel">Cancel</app-button>
</div>
</div>
<div class="button-group">
<h2>Disabled</h2>
<div class="button-grid">
<app-button [disabled]="true">Primary</app-button>
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">Danger secondary</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
<div class="button-group">
<h2>Una letra</h2>
<div class="button-grid button-grid--single-char">
<app-button>A</app-button>
<app-button variant="secondary">B</app-button>
<app-button variant="danger">C</app-button>
<app-button variant="danger-secondary">D</app-button>
<app-button variant="cancel">E</app-button>
</div>
</div>
</div> </div>
<hr class="section-divider" /> <hr class="section-divider" />
<div class="input-grid"> <div class="input-grid">
<app-input <div class="input-field">
placeholder="Buscar producto" <label for="search-input">Texto</label>
[value]="textValue" <app-input
[maxlength]="24" id="search-input"
(valueChange)="textValue = $event" type="search"
> placeholder="Buscar producto"
Texto [(value)]="textValue"
</app-input> [maxlength]="24"
/>
</div>
<app-input <div class="input-field">
type="number" <label for="number-input">Numero</label>
placeholder="0" <app-input
[value]="numberValue" id="number-input"
(valueChange)="numberValue = $event" type="number"
> placeholder="0"
Numero [(value)]="numberValue"
</app-input> />
</div>
<app-input <div class="input-field">
type="email" <label for="email-input">Email</label>
placeholder="mail@empresa.com" <app-input
[value]="email" id="email-input"
(valueChange)="email = $event" type="email"
> placeholder="mail@empresa.com"
Email [(value)]="email"
</app-input> />
</div>
<app-input <div class="input-field">
type="password" <label for="user-input">Usuario</label>
placeholder="********" <app-input
[value]="password" id="user-input"
(valueChange)="password = $event" type="user"
> placeholder="Nombre de usuario"
Password [(value)]="user"
</app-input> />
</div>
<app-input <div class="input-field">
type="date" <label for="password-input">Password</label>
[value]="date" <app-input
(valueChange)="date = $event" id="password-input"
> type="password-toggle"
Fecha placeholder="********"
</app-input> [(value)]="password"
/>
</div>
<app-input <div class="input-field">
type="period" <label for="date-input">Fecha</label>
[value]="period" <app-input id="date-input" type="date" [(value)]="date" />
(valueChange)="period = $event" </div>
>
Periodo
</app-input>
<app-input <div class="input-field">
type="currency" <label for="period-input">Periodo</label>
placeholder="0" <app-input id="period-input" type="period" [(value)]="period" />
[value]="amount" </div>
(valueChange)="amount = $event"
>
Moneda
</app-input>
<app-input <div class="input-field">
type="percent" <label for="amount-input">Moneda</label>
placeholder="0" <app-input
[value]="percent" id="amount-input"
(valueChange)="percent = $event" type="currency"
> placeholder="0"
Porcentaje [(value)]="amount"
</app-input> />
</div>
<app-input <div class="input-field">
placeholder="Campo deshabilitado" <label for="percent-input">Porcentaje</label>
[value]="disabledValue" <app-input
[disabled]="true" id="percent-input"
> type="percent"
Deshabilitado placeholder="0"
</app-input> [(value)]="percent"
/>
</div>
<app-input <div class="input-field">
type="editable" <label for="disabled-input">Deshabilitado</label>
[value]="editableValue" <app-input
[disabled]="editableDisabled" id="disabled-input"
(valueChange)="editableValue = $event" placeholder="Campo deshabilitado"
(disabledChange)="editableDisabled = $event" [value]="disabledValue"
> [disabled]="true"
Editable />
</app-input> </div>
<app-input <div class="input-field">
type="email" <label for="editable-input">Editable</label>
placeholder="mail@empresa.com" <app-input
[value]="invalidEmail" id="editable-input"
[invalid]="true" type="editable"
(valueChange)="invalidEmail = $event" [(value)]="editableValue"
> [disabled]="editableDisabled"
Invalido (disabledChange)="editableDisabled = $event"
</app-input> />
</div>
<div class="input-field">
<label for="invalid-input">Invalido</label>
<app-input
id="invalid-input"
type="email"
placeholder="mail@empresa.com"
[(value)]="invalidEmail"
[invalid]="true"
/>
</div>
<div class="file-field"> <div class="file-field">
<app-input <div class="input-field">
type="file" <label for="file-input">Archivo</label>
accept=".pdf,.png,.jpg" <app-input
[fileName]="fileName" id="file-input"
[clearTrigger]="clearTrigger" type="file"
(fileChange)="onFileChange($event)" accept=".pdf,.png,.jpg"
> [fileName]="fileName"
Archivo [clearTrigger]="clearTrigger"
</app-input> (fileChange)="onFileChange($event)"
/>
</div>
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button> <app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
</div> </div>
</div> </div>
</div> </div>
</section> </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> </main>

View File

@@ -35,6 +35,26 @@ h1 {
gap: 0.75rem; gap: 0.75rem;
} }
.button-showcase {
display: grid;
gap: 1.25rem;
}
.button-group {
display: grid;
gap: 0.75rem;
}
.button-group h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.button-grid--single-char app-button {
min-width: 3rem;
}
.section-divider { .section-divider {
margin: 2rem 0; margin: 2rem 0;
} }
@@ -45,6 +65,17 @@ h1 {
gap: 1rem; gap: 1rem;
} }
.input-field {
display: grid;
gap: 0;
}
.file-field {
display: grid;
gap: 0.75rem;
}
.file-field { .file-field {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -61,3 +92,109 @@ h1 {
grid-template-columns: minmax(0, 1fr); 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);
}
}
}

View File

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

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component'; import { InputComponent } from '../../../../shared/components/input/input.component';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({ @Component({
selector: 'app-reutilizables-test-page', selector: 'app-reutilizables-test-page',
@@ -9,9 +10,12 @@ import { InputComponent } from '../../../../shared/components/input/input.compon
styleUrl: './reutilizables-test-page.component.scss' styleUrl: './reutilizables-test-page.component.scss'
}) })
export class ReutilizablesTestPageComponent { export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
protected readonly tenant = this.tenantService.tenant;
protected textValue = 'Auriculares'; protected textValue = 'Auriculares';
protected numberValue = '24'; protected numberValue = '24';
protected email = 'usuario@empresa.com'; protected email = 'usuario@empresa.com';
protected user = 'Juan Perez';
protected password = '12345678'; protected password = '12345678';
protected date = '2026-06-24'; protected date = '2026-06-24';
protected amount = '125000'; protected amount = '125000';

View File

@@ -0,0 +1,21 @@
<section class="store-home">
<div class="store-home__inner">
<div class="store-home__hero">
<span class="store-home__eyebrow">Store Layout</span>
<h1>Tienda en construccion</h1>
<p class="store-home__lead">
Esta pagina placeholder deja visible el shell del store mientras se conectan categorias,
catalogo y acciones reales.
</p>
</div>
<div class="store-home__highlights">
@for (highlight of highlights; track highlight) {
<article class="store-home__card">
<span class="store-home__card-index" aria-hidden="true">0{{ $index + 1 }}</span>
<p>{{ highlight }}</p>
</article>
}
</div>
</div>
</section>

View File

@@ -0,0 +1,96 @@
:host {
display: block;
}
.store-home {
padding: 3rem 0 4rem;
}
.store-home__inner {
width: min(100% - 3rem, 75rem);
margin: 0 auto;
display: grid;
gap: 1.5rem;
}
.store-home__hero {
padding: clamp(2rem, 4vw, 3rem);
border: 1px solid #e4e4e4;
border-radius: 1.5rem;
background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
box-shadow: 0 1.2rem 2.2rem rgba(17, 17, 17, 0.06);
}
.store-home__eyebrow {
display: inline-block;
margin-bottom: 0.75rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #666666;
}
h1 {
margin-bottom: 0.75rem;
font-size: clamp(2rem, 4vw, 3.25rem);
font-weight: 700;
letter-spacing: -0.05em;
}
.store-home__lead {
max-width: 42rem;
margin: 0;
font-size: 1rem;
line-height: 1.6;
color: #4a4a4a;
}
.store-home__highlights {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.store-home__card {
display: grid;
gap: 0.9rem;
padding: 1.5rem;
border: 1px solid #e4e4e4;
border-radius: 1.25rem;
background-color: #ffffff;
box-shadow: 0 0.75rem 1.5rem rgba(17, 17, 17, 0.04);
}
.store-home__card-index {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
color: #707070;
}
.store-home__card p {
margin: 0;
line-height: 1.6;
color: #333333;
}
@media (max-width: 991.98px) {
.store-home__highlights {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 767.98px) {
.store-home {
padding: 2rem 0 3rem;
}
.store-home__inner {
width: min(100% - 1.5rem, 75rem);
}
.store-home__highlights {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-store-home-page',
templateUrl: './store-home-page.component.html',
styleUrl: './store-home-page.component.scss'
})
export class StoreHomePageComponent {
protected readonly highlights = [
'Header y footer listos para conectar con datos reales.',
'Barra de categorias reservada para sumar dropdown despues.',
'Placeholder inicial para validar layout, espaciados y responsive.'
];
}

View File

@@ -0,0 +1,17 @@
import { Routes } from '@angular/router';
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
export const routes: Routes = [
{
path: '',
component: StoreLayoutComponent,
children: [
{
path: '',
component: StoreHomePageComponent
}
]
}
];

View File

@@ -1,20 +1,19 @@
@use "sass:color";
@import "../../../../_theme";
:host { :host {
display: inline-block; display: inline-block;
} }
.btn { .btn {
min-height: 40px; min-height: 40px;
font-weight: 400; font-weight: 700;
font-size:15px; font-size: 15px;
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;
@@ -22,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));
}

View File

@@ -1,7 +1,3 @@
<label [for]="id" class="input-label">
<ng-content />
</label>
<div <div
class="input-control" class="input-control"
[ngClass]="{ [ngClass]="{
@@ -9,6 +5,7 @@
'input-control-percent': type() === 'percent', 'input-control-percent': type() === 'percent',
'input-control-file': type() === 'file', 'input-control-file': type() === 'file',
'input-control-editable': type() === 'editable', 'input-control-editable': type() === 'editable',
'input-control-password-toggle': type() === 'password-toggle',
'input-control-disabled': isInputDisabled(), 'input-control-disabled': isInputDisabled(),
'input-control-editable-active': isEditableActive() 'input-control-editable-active': isEditableActive()
}" }"
@@ -19,8 +16,8 @@
<input <input
#inputElement #inputElement
[id]="id" [id]="id()"
[name]="id" [name]="id()"
[type]="inputType()" [type]="inputType()"
[ngClass]="inputClasses()" [ngClass]="inputClasses()"
[disabled]="isInputDisabled()" [disabled]="isInputDisabled()"
@@ -32,6 +29,34 @@
[attr.accept]="type() === 'file' ? accept() : null" [attr.accept]="type() === 'file' ? accept() : null"
/> />
@if (type() === 'search') {
<span class="input-search-icon" aria-hidden="true">
<i class="fa-solid fa-magnifying-glass"></i>
</span>
}
@if (type() === 'user') {
<span class="input-user-icon" aria-hidden="true">
<i class="fa-solid fa-user"></i>
</span>
}
@if (type() === 'password-toggle') {
<button
type="button"
class="input-icon"
[disabled]="disabled()"
[attr.aria-label]="isPasswordVisible() ? 'Ocultar contrasena' : 'Mostrar contrasena'"
(click)="togglePasswordVisibility()"
>
<i
class="fa-solid"
[ngClass]="isPasswordVisible() ? 'fa-eye' : 'fa-eye-slash'"
aria-hidden="true"
></i>
</button>
}
@if (type() === 'file') { @if (type() === 'file') {
<span class="input-file-name" [class.input-file-name-empty]="!resolvedFileName()"> <span class="input-file-name" [class.input-file-name-empty]="!resolvedFileName()">
{{ resolvedFileName() || 'Sin archivo...' }} {{ resolvedFileName() || 'Sin archivo...' }}

View File

@@ -4,21 +4,28 @@
padding-bottom: 0 !important; padding-bottom: 0 !important;
} }
.input-label {
display: block;
margin-bottom: 0.375rem;
font-weight: 500;
}
.input-control { .input-control {
position: relative; position: relative;
} }
.input-search-icon,
.input-user-icon {
position: absolute;
right: 1rem;
top: 50%;
z-index: 1;
color: #495057;
transform: translateY(-50%);
pointer-events: none;
}
.form-control { .form-control {
min-height: 40px; min-height: 40px;
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
color: #212529; color: #212529;
font-size: 15px;
font-weight: 400;
&:focus { &:focus {
box-shadow: none; box-shadow: none;
@@ -29,6 +36,14 @@
} }
} }
.input-with-search-icon {
padding-right: 2.5rem;
}
.input-with-action-icon {
padding-right: 2.5rem;
}
input[type='file'].form-control { input[type='file'].form-control {
padding-right: 1rem; padding-right: 1rem;
color: transparent; color: transparent;
@@ -86,7 +101,8 @@ input[type='file'].form-control {
} }
} }
.input-control-editable { .input-control-editable,
.input-control-password-toggle {
.input-icon { .input-icon {
position: absolute; position: absolute;
right: 0.875rem; right: 0.875rem;

View File

@@ -5,6 +5,7 @@ import {
computed, computed,
effect, effect,
input, input,
model,
output, output,
signal, signal,
untracked, untracked,
@@ -13,10 +14,13 @@ import {
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
export type AppInputType = export type AppInputType =
| 'search'
| 'user'
| 'text' | 'text'
| 'number' | 'number'
| 'email' | 'email'
| 'password' | 'password'
| 'password-toggle'
| 'currency' | 'currency'
| 'percent' | 'percent'
| 'date' | 'date'
@@ -26,10 +30,13 @@ export type AppInputType =
const DEFAULT_TYPE: AppInputType = 'text'; const DEFAULT_TYPE: AppInputType = 'text';
const INPUT_TYPES = new Set<AppInputType>([ const INPUT_TYPES = new Set<AppInputType>([
'search',
'user',
'text', 'text',
'number', 'number',
'email', 'email',
'password', 'password',
'password-toggle',
'currency', 'currency',
'percent', 'percent',
'date', 'date',
@@ -58,30 +65,42 @@ export class InputComponent {
private readonly inputElement = viewChild<ElementRef<HTMLInputElement>>('inputElement'); private readonly inputElement = viewChild<ElementRef<HTMLInputElement>>('inputElement');
private readonly selectedFileName = signal(''); private readonly selectedFileName = signal('');
private readonly editableDisabled = signal(false); private readonly editableDisabled = signal(false);
private readonly passwordVisible = signal(false);
private lastClearTrigger = 0; private lastClearTrigger = 0;
private readonly defaultId = `app-input-${globalThis.crypto.randomUUID()}`;
readonly invalid = input(false); readonly invalid = input(false);
readonly id = input(this.defaultId);
readonly placeholder = input(''); readonly placeholder = input('');
readonly value = input<string | number>(''); readonly value = model<string | number>('');
readonly maxlength = input<number | null>(null); readonly maxlength = input<number | null>(null);
readonly accept = input(''); readonly accept = input('');
readonly fileName = input(''); readonly fileName = input('');
readonly clearTrigger = input(0); readonly clearTrigger = input(0);
readonly disabled = input(false); readonly disabled = input(false);
readonly visible = input<boolean | null>(null);
readonly type = input<AppInputType, AppInputType | string>(DEFAULT_TYPE, { readonly type = input<AppInputType, AppInputType | string>(DEFAULT_TYPE, {
transform: normalizeType transform: normalizeType
}); });
readonly disabledChange = output<boolean>(); readonly disabledChange = output<boolean>();
readonly valueChange = output<string>();
readonly fileChange = output<File | null>(); readonly fileChange = output<File | null>();
readonly visibleChange = output<boolean>();
protected readonly id = `app-input-${globalThis.crypto.randomUUID()}`;
protected readonly inputType = computed(() => { protected readonly inputType = computed(() => {
const type = this.type(); const type = this.type();
if (type === 'currency' || type === 'percent' || type === 'editable') { if (type === 'password-toggle') {
return this.isPasswordVisible() ? 'text' : 'password';
}
if (
type === 'search' ||
type === 'user' ||
type === 'currency' ||
type === 'percent' ||
type === 'editable'
) {
return 'text'; return 'text';
} }
@@ -95,6 +114,8 @@ export class InputComponent {
protected readonly inputClasses = computed(() => ({ protected readonly inputClasses = computed(() => ({
'form-control': true, 'form-control': true,
'is-invalid': this.invalid(), 'is-invalid': this.invalid(),
'input-with-search-icon': this.type() === 'search',
'input-with-action-icon': this.type() === 'user' || this.type() === 'password-toggle',
'input-with-prefix': this.type() === 'currency', 'input-with-prefix': this.type() === 'currency',
'input-with-suffix': this.type() === 'percent', 'input-with-suffix': this.type() === 'percent',
'input-with-icon': this.type() === 'editable' 'input-with-icon': this.type() === 'editable'
@@ -108,6 +129,11 @@ export class InputComponent {
() => this.type() === 'editable' && !this.editableDisabled() () => this.type() === 'editable' && !this.editableDisabled()
); );
protected readonly isPasswordVisible = computed(() => {
const visible = this.visible();
return visible === null ? this.passwordVisible() : visible;
});
protected readonly resolvedFileName = computed( protected readonly resolvedFileName = computed(
() => this.selectedFileName() || this.fileName() || '' () => this.selectedFileName() || this.fileName() || ''
); );
@@ -141,7 +167,7 @@ export class InputComponent {
} }
protected onValueChange(event: Event): void { protected onValueChange(event: Event): void {
this.valueChange.emit((event.target as HTMLInputElement).value); this.value.set((event.target as HTMLInputElement).value);
} }
protected onFileChange(event: Event): void { protected onFileChange(event: Event): void {
@@ -160,6 +186,20 @@ export class InputComponent {
this.disabledChange.emit(nextState); this.disabledChange.emit(nextState);
} }
protected togglePasswordVisibility(): void {
if (this.type() !== 'password-toggle' || this.disabled()) {
return;
}
const nextState = !this.isPasswordVisible();
if (this.visible() === null) {
this.passwordVisible.set(nextState);
}
this.visibleChange.emit(nextState);
}
private clearFileInput(): void { private clearFileInput(): void {
this.inputElement()?.nativeElement && (this.inputElement()!.nativeElement.value = ''); this.inputElement()?.nativeElement && (this.inputElement()!.nativeElement.value = '');
this.selectedFileName.set(''); this.selectedFileName.set('');

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
nombre:"Desarrollo - activo",
url:"http://localhost:8000/api/",
urlDescarga:"url/"
};

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
nombre:"Desarrollo - activo",
url:"http://localhost:8000/api/",
urlDescarga:"url/"
};

View File

@@ -2,5 +2,24 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
function renderBootstrapError(error: unknown): void {
const message = error instanceof Error ? error.message : 'No se pudo cargar la configuracion del tenant.';
const root = document.querySelector('app-root');
if (root) {
root.innerHTML = `
<section style="font-family: Arial, sans-serif; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #f5f5f5; color: #1f1f1f;">
<div style="max-width: 480px; width: 100%; background: #ffffff; border: 1px solid #d9d9d9; border-radius: 12px; padding: 24px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);">
<h1 style="margin: 0 0 12px; font-size: 24px;">Error al cargar el tenant</h1>
<p style="margin: 0; line-height: 1.5;">${message}</p>
</div>
</section>
`;
}
}
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig)
.catch((err) => console.error(err)); .catch((err) => {
console.error(err);
renderBootstrapError(err);
});

View File

@@ -1,10 +1,25 @@
@use "./fonts"; @use "./fonts";
@import "@fortawesome/fontawesome-free/css/all.min.css";
@import "./theme"; @import "./theme";
@import "../node_modules/bootstrap/scss/bootstrap"; @import "../node_modules/bootstrap/scss/bootstrap";
:root { :root {
--bs-body-font-family: "Gotham", sans-serif; --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 {
display: block;
margin-bottom: 0.375rem;
font-size: 13px;
font-weight: 400;
} }