Compare commits

...

10 Commits

52 changed files with 2179 additions and 385 deletions

View File

@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
@@ -35,7 +36,10 @@
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": ["localhost", "127.0.0.1"]
"allowedHosts": [
"localhost",
"127.0.0.1"
]
},
"ssr": {
"entry": "src/server.ts"
@@ -58,6 +62,11 @@
"outputHashing": "all"
},
"development": {
"optimization": true,
"extractLicenses": false,
"sourceMap": true
},
"local": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
@@ -73,6 +82,9 @@
},
"development": {
"buildTarget": "shopit-front:build:development"
},
"local": {
"buildTarget": "shopit-front:build:local"
}
},
"defaultConfiguration": "development"
@@ -83,4 +95,4 @@
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

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

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[] = [
{
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 { provideRouter, Router } from '@angular/router';
import { App } from './app';
import { routes } from './app.routes';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
async function renderAppAt(url: string) {
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
};
function createTenantServiceStub(
status: 'ready' | 'not-found' = 'ready',
currentTenant: Tenant | null = tenant
) {
const tenantState = signal(currentTenant);
const statusState = signal(status);
return {
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
};
}
async function renderAppAt(
url: string,
tenantService: ReturnType<typeof createTenantServiceStub> = createTenantServiceStub()
) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(routes)]
providers: [
provideRouter(routes),
{
provide: TenantService,
useValue: tenantService
}
]
}).compileComponents();
const router = TestBed.inject(Router);
@@ -24,21 +64,37 @@ async function renderAppAt(url: string) {
}
describe('app routes', () => {
it('loads the store layout at /store', async () => {
const { fixture, router } = await renderAppAt('/store');
it('loads the store layout at /', async () => {
const { fixture, router } = await renderAppAt('/');
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/store');
expect(router.url).toBe('/');
expect(compiled.querySelector('.store-layout')).not.toBeNull();
expect(compiled.querySelector('.store-home')).not.toBeNull();
expect(compiled.textContent).toContain('Tienda en construccion');
expect(compiled.style.getPropertyValue('--tenant-primary')).toBe(tenant.primary_color);
});
it('keeps the root redirect under componentes-test', async () => {
it('loads componentes-test under a valid tenant', async () => {
const { fixture, router } = await renderAppAt('/');
await router.navigateByUrl('/componentes-test/reutilizables');
await fixture.whenStable();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/componentes-test/reutilizables');
expect(compiled.textContent).toContain('Componentes reutilizables');
});
it('renders the tenant not found screen for any route when the tenant is missing', async () => {
const { fixture } = await renderAppAt(
'/componentes-test/reutilizables',
createTenantServiceStub('not-found', null)
);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
});

View File

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

View File

@@ -1,3 +1,68 @@
:host {
display: block;
min-height: 100dvh;
}
.tenant-status {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 2rem;
background:
radial-gradient(circle at top, color-mix(in srgb, white 82%, var(--tenant-primary)) 0%, transparent 55%),
linear-gradient(180deg, #f8f8f8 0%, #efefef 100%);
color: #202020;
}
.tenant-status__card {
width: min(100%, 34rem);
padding: 2rem;
border: 1px solid rgba(32, 32, 32, 0.1);
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.92);
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.1);
}
.tenant-status__code {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--tenant-primary);
}
.tenant-status__title {
margin: 0 0 0.75rem;
font-size: clamp(2rem, 4vw, 2.75rem);
line-height: 1.05;
}
.tenant-status__description {
margin: 0;
font-size: 1rem;
line-height: 1.6;
color: rgba(32, 32, 32, 0.72);
}
.tenant-status--loading {
background:
radial-gradient(circle at center, color-mix(in srgb, white 88%, var(--tenant-primary)) 0%, transparent 60%),
#f5f5f5;
}
.tenant-status__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border-top-color: var(--tenant-primary);
border-radius: 999px;
animation: tenant-status-spin 0.8s linear infinite;
}
@keyframes tenant-status-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,23 +1,88 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
const tenantState = signal(currentTenant);
const statusState = signal(status);
return {
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
};
}
describe('App', () => {
beforeEach(async () => {
it('creates the app when the tenant is ready', async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('ready', tenant)
}
]
}).compileComponents();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
tenant.primary_color
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
tenant.secondary_color
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
tenant.danger_color
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
'99, 118, 243'
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
'160, 160, 160'
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136'
);
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('renders the tenant not found screen when the tenant is missing', async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('not-found', null)
}
]
}).compileComponents();
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
});
});

View File

@@ -1,10 +1,66 @@
import { Component } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
function hexToRgb(hex: string): string {
const cleanHex = hex.replace('#', '').trim();
let r = 0, g = 0, b = 0;
if (cleanHex.length === 3) {
r = parseInt(cleanHex[0] + cleanHex[0], 16);
g = parseInt(cleanHex[1] + cleanHex[1], 16);
b = parseInt(cleanHex[2] + cleanHex[2], 16);
} else if (cleanHex.length >= 6) {
r = parseInt(cleanHex.slice(0, 2), 16);
g = parseInt(cleanHex.slice(2, 4), 16);
b = parseInt(cleanHex.slice(4, 6), 16);
}
return `${r}, ${g}, ${b}`;
}
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrl: './app.scss',
host: {
'[style.--tenant-primary]': 'tenantPrimaryColor()',
'[style.--tenant-secondary]': 'tenantSecondaryColor()',
'[style.--tenant-danger]': 'tenantDangerColor()',
'[style.--tenant-primary-rgb]': 'tenantPrimaryColorRgb()',
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
'[style.--tenant-header-footer-bg]': 'tenantHeaderFooterBgColor()'
}
})
export class App {}
export class App {
private readonly tenantService = inject(TenantService);
protected readonly status = this.tenantService.status;
protected readonly tenantPrimaryColor = computed(
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
);
protected readonly tenantSecondaryColor = computed(
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
);
protected readonly tenantDangerColor = computed(
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
);
protected readonly tenantPrimaryColorRgb = computed(() =>
hexToRgb(this.tenantPrimaryColor())
);
protected readonly tenantSecondaryColorRgb = computed(() =>
hexToRgb(this.tenantSecondaryColor())
);
protected readonly tenantDangerColorRgb = computed(() =>
hexToRgb(this.tenantDangerColor())
);
protected readonly tenantHeaderFooterBgColor = computed(
() =>
this.tenantService.tenant()?.header_footer_bg_color ??
DEFAULT_TENANT_BRANDING.headerFooterBgColor
);
}

View File

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

View File

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

View File

@@ -17,25 +17,8 @@ export type StoreSocialLink = {
styleUrl: './store-footer.component.scss'
})
export class StoreFooterComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input()
set logoUrl(value: string | null) {
this.logoUrlValue = value;
this.logoLoaded = false;
}
get logoUrl(): string | null {
return this.logoUrlValue;
}
protected logoLoaded = false;
protected onLogoLoad(): void {
this.logoLoaded = true;
}
@Input() logoUrl: string | null = null;
}

View File

@@ -1,20 +1,16 @@
<header class="store-layout__header bg-white border-bottom">
<div class="border-bottom">
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-wrap align-items-center justify-content-between gap-3">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl && logoLoaded) {
@if (logoUrl) {
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
@if (logoUrl && !logoLoaded) {
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
}
</div>
}
</div>
@@ -25,10 +21,14 @@
<input
id="store-search-input"
type="search"
class="form-control border-dark border-end-0 rounded-start"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
/>
<button type="button" class="btn btn-outline-dark border-start-0 rounded-end px-3" aria-label="Buscar">
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@@ -37,7 +37,7 @@
@for (action of headerActions; track action.label) {
<button
type="button"
class="btn btn-link text-dark p-2 text-decoration-none"
class="btn btn-link store-layout__action-button p-2 text-decoration-none"
[attr.aria-label]="action.label"
[attr.title]="action.label"
>
@@ -53,7 +53,7 @@
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-dark text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>

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 {
min-width: 10rem;
}
@@ -15,19 +25,17 @@
object-fit: contain;
}
.store-layout__brand-logo--preload {
opacity: 0;
position: absolute;
inset: 0;
pointer-events: none;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.14) 50%, rgba(0, 0, 0, 0.08) 100%);
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
@@ -37,9 +45,47 @@
min-width: 18rem;
}
.store-layout__search-input {
border-color: rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, white 70%, var(--tenant-primary));
}
.store-layout__search-button {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.2);
background-color: color-mix(in srgb, white 10%, var(--tenant-header-footer-bg));
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.28);
background-color: color-mix(in srgb, white 16%, var(--tenant-header-footer-bg));
}
.store-layout__action-button {
color: #ffffff;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #ffffff;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
}
@keyframes store-header-logo-skeleton {

View File

@@ -12,23 +12,6 @@ export type StoreHeaderAction = {
styleUrl: './store-header.component.scss'
})
export class StoreHeaderComponent {
private logoUrlValue: string | null = null;
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
@Input()
set logoUrl(value: string | null) {
this.logoUrlValue = value;
this.logoLoaded = false;
}
get logoUrl(): string | null {
return this.logoUrlValue;
}
protected logoLoaded = false;
protected onLogoLoad(): void {
this.logoLoaded = true;
}
@Input() logoUrl: string | null = null;
}

View File

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

View File

@@ -1,13 +1,42 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { StoreLayoutComponent } from './store-layout.component';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
};
describe('StoreLayoutComponent', () => {
beforeEach(async () => {
const tenantState = signal(tenant);
await TestBed.configureTestingModule({
imports: [StoreLayoutComponent],
providers: [provideRouter([])]
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
}
]
}).compileComponents();
});
@@ -23,7 +52,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('renders the placeholder branding and action icons', () => {
it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -31,6 +60,12 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect(
compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
).not.toBeNull();
expect(
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();

View File

@@ -0,0 +1,30 @@
import { ApiResponse } from './api-response.interface';
export interface ApiPaginationMetaLink {
url: string | null;
label: string;
active: boolean;
}
export interface ApiPaginationMeta {
current_page: number;
from: number | null;
last_page: number;
links: ApiPaginationMetaLink[];
path: string;
per_page: number;
to: number | null;
total: number;
}
export interface ApiPaginationLinks {
first: string;
last: string;
prev: string | null;
next: string | null;
}
export interface ApiPaginatedResponse<T> extends ApiResponse<T> {
meta: ApiPaginationMeta;
links: ApiPaginationLinks;
}

View File

@@ -0,0 +1,4 @@
export interface ApiPaginationQueryParams {
page?: number;
per_page?: number;
}

View File

@@ -0,0 +1,3 @@
export interface ApiResponse<T> {
data: T;
}

View File

@@ -0,0 +1,11 @@
export interface Product {
id: number;
tenant_codigo: string;
categoria_id: number;
slug: string;
nombre: string;
descripcion?: string;
precio: string;
created_at?: string;
updated_at?: string;
}

View File

@@ -0,0 +1,62 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { Product } from './catalog.interface';
type HttpParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[];
@Injectable({
providedIn: 'root'
})
export class CatalogService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string {
return this.tenantService.getTenantApiUrl();
}
getProductos(
params?: ApiPaginationQueryParams
): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`,
{ params: this.buildHttpParams(params) }
);
}
getProducto(id: number): Observable<Product> {
return this.http
.get<ApiResponse<Product>>(`${this.tenantApiUrl}/productos/${id}`)
.pipe(map((response) => response.data));
}
private buildHttpParams(params?: ApiPaginationQueryParams): HttpParams {
if (!params) {
return new HttpParams();
}
const fromObject = Object.entries(params).reduce<Record<string, HttpParamValue>>(
(acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
},
{}
);
return new HttpParams({ fromObject });
}
}

View File

@@ -5,18 +5,7 @@ import { TenantService } from './tenant.service';
describe('tenantBootstrap', () => {
it('waits for TenantService.bootstrap to resolve', async () => {
const bootstrapSpy = vi.fn().mockResolvedValue({
id: 1,
codigo: 'test',
nombre: 'Test Tennant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
});
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [

View File

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

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,3 +1,5 @@
import { ApiResponse } from './api-response.interface';
export interface Tenant {
id: number;
codigo: string;
@@ -11,6 +13,5 @@ export interface Tenant {
footer_logo: string;
}
export interface TenantBootstrapResponse {
data: Tenant;
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -1,15 +1,37 @@
import { PLATFORM_ID } from '@angular/core';
import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../environments/environment';
import { TenantBootstrapResponse } from './tenant.interface';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
};
const tenantResponse: TenantBootstrapResponse = {
data: tenant
};
describe('TenantService', () => {
beforeEach(() => {
window.history.replaceState({}, '', 'http://localhost:4200/store');
try {
window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) {
// Ignore SecurityError under test environments
}
});
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
@@ -21,30 +43,124 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
expect(request.request.method).toBe('GET');
const response: TenantBootstrapResponse = {
data: {
id: 1,
codigo: 'test',
nombre: 'Test Tennant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
header_footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
}
};
request.flush(tenantResponse);
request.flush(response);
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
await expect(bootstrapPromise).resolves.toEqual(response.data);
expect(service.tenant()).toEqual(response.data);
expect(service.getTenant()).toEqual(response.data);
httpController.verify();
});
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready',
tenant
});
await expect(service.bootstrap()).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toBeNull();
httpController.expectNone(() => true);
httpController.verify();
});
it('uses the forwarded host during SSR and stores the resolved tenant in TransferState', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
host: 'ignored.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
request.flush(tenantResponse);
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready',
tenant
});
expect(responseInit.status).toBeUndefined();
httpController.verify();
});
it('stores a not-found state and sets the SSR response status to 404 when the tenant does not exist', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'missing.example.com:8443'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('not-found');
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found'
});
expect(responseInit.status).toBe(404);
httpController.verify();
});
@@ -58,33 +174,50 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
request.flush({});
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify();
});
it('skips the HTTP bootstrap on the server platform', async () => {
it('propagates non-404 backend failures', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' }
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'broken.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
await expect(service.bootstrap()).resolves.toBeNull();
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
await expect(bootstrapPromise).rejects.toThrow('Http failure response');
expect(service.status()).toBe('idle');
expect(responseInit.status).toBeUndefined();
httpController.expectNone(() => true);
httpController.verify();
});
});

View File

@@ -1,10 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
inject,
Injectable,
PLATFORM_ID,
REQUEST,
RESPONSE_INIT,
signal,
TransferState
} from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState
} from './tenant-ssr-cache.store';
@Injectable({
providedIn: 'root'
@@ -12,40 +26,150 @@ import { Tenant, TenantBootstrapResponse } from './tenant.interface';
export class TenantService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
private readonly tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle');
readonly tenant = this.tenantState.asReadonly();
readonly status = this.statusState.asReadonly();
getTenant(): Tenant | null {
return this.tenantState();
}
async bootstrap(): Promise<Tenant | null> {
if (!isPlatformBrowser(this.platformId)) {
return null;
getTenantApiUrl(): string {
const tenant = this.tenantState();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return `${environment.url}tenants/${tenant.codigo}`;
}
async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') {
return;
}
if (this.isDiscoveringRoutes) {
this.statusState.set('ready');
return;
}
const transferredState = this.transferState.get(TENANT_SSR_STATE_KEY, null);
if (transferredState) {
this.transferState.remove(TENANT_SSR_STATE_KEY);
this.applyState(transferredState);
return;
}
const domain = this.resolveDomain();
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) {
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
try {
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) {
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
}
this.setReady(response.data);
this.persistSsrState({
status: 'ready',
tenant: response.data
});
} catch (error) {
if (this.isNotFoundError(error)) {
this.setNotFound();
this.persistSsrState({ status: 'not-found' });
if (this.responseInit) {
this.responseInit.status = 404;
}
return;
}
throw error;
}
this.tenantState.set(response.data);
return response.data;
}
private resolveDomain(): string {
const domain = window.location.hostname;
if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname;
if (!domain) {
if (!domain) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return this.normalizeHost(domain);
}
const requestHost =
this.request?.headers.get('x-forwarded-host') ??
this.request?.headers.get('host') ??
(this.request ? new URL(this.request.url).host : null);
if (!requestHost) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return domain;
return this.normalizeHost(requestHost);
}
private normalizeHost(host: string): string {
const [firstHost = ''] = host.split(',');
const normalizedHost = firstHost.trim().toLowerCase();
if (!normalizedHost) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
if (normalizedHost.startsWith('[')) {
const closingBracketIndex = normalizedHost.indexOf(']');
if (closingBracketIndex >= 0) {
return normalizedHost.slice(1, closingBracketIndex);
}
}
return normalizedHost.replace(/:\d+$/, '');
}
private applyState(state: TenantSsrState): void {
if (state.status === 'ready') {
this.setReady(state.tenant);
return;
}
this.setNotFound();
}
private setReady(tenant: Tenant): void {
this.tenantState.set(tenant);
this.statusState.set('ready');
}
private setNotFound(): void {
this.tenantState.set(null);
this.statusState.set('not-found');
}
private persistSsrState(state: TenantSsrState): void {
if (isPlatformServer(this.platformId)) {
this.transferState.set(TENANT_SSR_STATE_KEY, state);
}
}
private isNotFoundError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 404;
}
}

View File

@@ -1,31 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -174,6 +174,144 @@
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
</div>
</div>
<hr class="section-divider" />
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
[totalPages]="paginatorTotalPages"
(pageChange)="currentPage = $event"
/>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
<div class="card-body">
<span class="eyebrow">Tenant Configuration</span>
<h2 class="mb-4">Configuración Tenant</h2>
<!-- Logos section -->
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<div class="tenant-logo-card">
<div class="tenant-logo-card__label">Logo del Header</div>
<div class="tenant-logo-card__container header-footer-bg">
@if (tenant()?.header_logo) {
<img [src]="tenant()?.header_logo" alt="Header Logo" class="tenant-logo-card__img" />
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
<span>Sin Logo de Header</span>
</div>
}
</div>
</div>
</div>
<div class="col-md-6">
<div class="tenant-logo-card">
<div class="tenant-logo-card__label">Logo del Footer</div>
<div class="tenant-logo-card__container header-footer-bg">
@if (tenant()?.footer_logo) {
<img [src]="tenant()?.footer_logo" alt="Footer Logo" class="tenant-logo-card__img" />
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
<span>Sin Logo de Footer</span>
</div>
}
</div>
</div>
</div>
</div>
<hr class="section-divider" />
<!-- Colors section -->
<div class="colors-section">
<h3 class="mb-3">Colores de Marca</h3>
<div class="row">
<div class="col-md-4 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-primary)'"></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Primario</span>
<code class="color-swatch-card__value">{{ tenant()?.primary_color || 'Default' }}</code>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-secondary)'"></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Secundario</span>
<code class="color-swatch-card__value">{{ tenant()?.secondary_color || 'Default' }}</code>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-danger)'"></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Danger</span>
<code class="color-swatch-card__value">{{ tenant()?.danger_color || 'Default' }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamaño del contenedor padre. A continuación se presentan dentro de una grilla de Bootstrap.
</p>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
}
</div>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item">
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
}
</div>
</app-store-section>
</main>

View File

@@ -15,6 +15,10 @@
border: 0;
}
.store-section-demo {
width: min(100%, 64rem);
}
.eyebrow {
display: inline-block;
margin-bottom: 0.75rem;
@@ -51,10 +55,23 @@ h1 {
font-weight: 700;
}
.paginator-showcase {
display: grid;
justify-items: center;
gap: 0.75rem;
text-align: center;
}
.button-grid--single-char app-button {
min-width: 3rem;
}
.store-section-demo__item {
app-product-card {
width: 100%;
}
}
.section-divider {
margin: 2rem 0;
}
@@ -70,6 +87,12 @@ h1 {
gap: 0;
}
.file-field {
display: grid;
gap: 0.75rem;
}
.file-field {
display: grid;
gap: 0.75rem;
@@ -86,3 +109,109 @@ h1 {
grid-template-columns: minmax(0, 1fr);
}
}
.configuracion-tennant {
margin-top: 2rem;
.tenant-logo-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__label {
font-size: 0.875rem;
font-weight: 600;
color: #495057;
}
&__container {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
border-radius: 8px;
border: 1px solid #dee2e6;
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
&__img {
max-height: 100%;
max-width: 100%;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 0.875rem;
i {
font-size: 2rem;
opacity: 0.6;
}
}
}
.header-footer-bg {
background-color: var(--tenant-header-footer-bg, #f8f9fa);
}
.color-swatch-card {
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid #dee2e6;
overflow: hidden;
background-color: #ffffff;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
}
&__preview {
height: 80px;
width: 100%;
border-bottom: 1px solid #dee2e6;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.95;
}
}
&__body {
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: #6c757d;
}
&__value {
font-size: 0.875rem;
color: #212529;
font-family: var(--bs-font-monospace);
}
}
}

View File

@@ -0,0 +1,87 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
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);
});
it('renders the paginator demo with the initial page status', 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;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
});
});

View File

@@ -1,14 +1,20 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-reutilizables-test-page',
imports: [ButtonComponent, InputComponent],
imports: [ButtonComponent, InputComponent, PaginatorComponent, ProductCardComponent, StoreSectionComponent],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss'
})
export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
protected readonly tenant = this.tenantService.tenant;
protected textValue = 'Auriculares';
protected numberValue = '24';
protected email = 'usuario@empresa.com';
@@ -24,6 +30,37 @@ export class ReutilizablesTestPageComponent {
protected fileName = '';
protected clearTrigger = 0;
protected editableDisabled = true;
protected currentPage = 1;
protected readonly paginatorTotalPages = 8;
protected readonly testProducts = [
{
title: 'Pantalón recto de scuba negro',
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png'
},
{
title: 'Zapatillas de running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png'
},
{
title: 'Remera básica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
imageUrl: null
}
];
protected onProductBuy(productTitle: string): void {
console.log(`Comprando: ${productTitle}`);
alert(`Comprando: ${productTitle}`);
}
protected onFileChange(file: File | null): void {
this.fileName = file?.name ?? '';
@@ -34,3 +71,4 @@ export class ReutilizablesTestPageComponent {
this.clearTrigger += 1;
}
}

View File

@@ -1,21 +1,45 @@
<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>
<section class="py-5">
<div class="container-xl px-3 px-md-4">
<app-store-section title="Productos">
<div class="d-grid gap-4">
@if (error()) {
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
} @else if (loading() && !productCards().length) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (!productCards().length) {
<p class="alert alert-light border text-center mb-0">No hay productos disponibles en este momento.</p>
} @else {
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id) {
<div class="col">
<app-product-card
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl"
(buy)="onBuyProduct(product.id)"
/>
</div>
}
</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>
@if (loading() && productCards().length) {
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
}
@if (totalPages() > 1) {
<div class="d-flex justify-content-center">
<app-paginator
[page]="currentPage()"
[totalPages]="totalPages()"
[disabled]="loading()"
(pageChange)="onPageChange($event)"
/>
</div>
}
</div>
</app-store-section>
</div>
</section>

View File

@@ -2,95 +2,9 @@
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;
.store-home__alert-error {
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);
}
border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18);
background-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.08);
color: var(--tenant-danger, #dc3545);
}

View File

@@ -0,0 +1,227 @@
import { TestBed } from '@angular/core/testing';
import { Subject, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { StoreHomePageComponent } from './store-home-page.component';
function createPaginatedResponse(
products: Product[],
currentPage: number,
lastPage: number
): ApiPaginatedResponse<Product[]> {
return {
data: products,
meta: {
current_page: currentPage,
from: products.length ? 1 : null,
last_page: lastPage,
links: [],
path: '/productos',
per_page: 12,
to: products.length || null,
total: products.length
},
links: {
first: '/productos?page=1',
last: `/productos?page=${lastPage}`,
prev: currentPage > 1 ? `/productos?page=${currentPage - 1}` : null,
next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null
}
};
}
describe('StoreHomePageComponent', () => {
const pageOneProducts: Product[] = [
{
id: 1,
tenant_codigo: 'test',
categoria_id: 10,
slug: 'auriculares-bluetooth',
nombre: 'Auriculares Bluetooth',
precio: '24999'
},
{
id: 2,
tenant_codigo: 'test',
categoria_id: 11,
slug: 'teclado-mecanico',
nombre: 'Teclado Mecanico',
precio: '18999'
}
];
beforeEach(() => {
vi.restoreAllMocks();
});
it('loads page 1 on init, renders the products title and the fetched product cards', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse(pageOneProducts, 1, 3)))
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 1 });
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
expect(element.querySelectorAll('app-product-card')).toHaveLength(2);
expect(element.textContent).toContain('Auriculares Bluetooth');
expect(element.textContent).toContain('Teclado Mecanico');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/3');
});
it('requests the next page when the paginator emits a page change', async () => {
const catalogServiceStub = {
getProductos: vi
.fn()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(
of(
createPaginatedResponse(
[
{
id: 3,
tenant_codigo: 'test',
categoria_id: 12,
slug: 'mouse-gamer',
nombre: 'Mouse Gamer',
precio: '15999'
}
],
2,
3
)
)
)
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges();
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(1, { page: 1 });
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(2, { page: 2 });
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('2/3');
expect(element.textContent).toContain('Mouse Gamer');
});
it('disables the paginator while a new page request is in flight', async () => {
const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>();
const catalogServiceStub = {
getProductos: vi
.fn()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(nextPageSubject.asObservable())
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges();
expect(
Array.from(element.querySelectorAll('app-paginator button')).every(
(button) => (button as HTMLButtonElement).disabled
)
).toBe(true);
expect(element.textContent).toContain('Actualizando productos...');
nextPageSubject.next(createPaginatedResponse(pageOneProducts, 2, 3));
nextPageSubject.complete();
fixture.detectChanges();
expect(
Array.from(element.querySelectorAll('app-paginator button')).some(
(button) => !(button as HTMLButtonElement).disabled
)
).toBe(true);
});
it('shows an empty-state message and hides the paginator when there are no products', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse([], 1, 1)))
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('No hay productos disponibles en este momento.');
expect(element.querySelector('app-paginator')).toBeNull();
});
it('shows an error message when the catalog request fails', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(throwError(() => new Error('boom')))
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('No pudimos cargar los productos en este momento.');
expect(element.querySelectorAll('app-product-card')).toHaveLength(0);
});
});

View File

@@ -1,14 +1,110 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { Subscription } from 'rxjs';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
interface StoreHomeProductCardViewModel {
id: number;
title: string;
originalPrice: number;
discount: null;
transferPrice: null;
imageUrl: null;
}
@Component({
selector: 'app-store-home-page',
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent],
templateUrl: './store-home-page.component.html',
styleUrl: './store-home-page.component.scss'
styleUrl: './store-home-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
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.'
];
export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly catalogService = inject(CatalogService);
private activeRequestId = 0;
private productsRequestSubscription: Subscription | null = null;
protected readonly currentPage = signal(1);
protected readonly products = signal<Product[]>([]);
protected readonly totalPages = signal(0);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly productCards = computed<StoreHomeProductCardViewModel[]>(() =>
this.products().map((product) => ({
id: product.id,
title: product.nombre,
originalPrice: this.parseProductPrice(product.precio),
discount: null,
transferPrice: null,
imageUrl: null
}))
);
ngOnInit(): void {
this.loadProducts(1);
}
ngOnDestroy(): void {
this.productsRequestSubscription?.unsubscribe();
}
protected onPageChange(page: number): void {
if (page === this.currentPage() || page < 1) {
return;
}
this.loadProducts(page);
}
protected onBuyProduct(_productId: number): void {}
private loadProducts(page: number): void {
this.activeRequestId += 1;
const requestId = this.activeRequestId;
this.productsRequestSubscription?.unsubscribe();
this.loading.set(true);
this.error.set(null);
this.productsRequestSubscription = this.catalogService.getProductos({ page }).subscribe({
next: (response) => {
if (requestId !== this.activeRequestId) {
return;
}
this.products.set(response.data ?? []);
this.currentPage.set(response.meta.current_page);
this.totalPages.set(response.meta.last_page);
},
error: () => {
if (requestId !== this.activeRequestId) {
return;
}
this.products.set([]);
this.totalPages.set(0);
this.loading.set(false);
this.error.set('No pudimos cargar los productos en este momento.');
},
complete: () => {
if (requestId !== this.activeRequestId) {
return;
}
this.loading.set(false);
}
});
}
private parseProductPrice(price: string): number {
const parsedPrice = Number(price);
return Number.isFinite(parsedPrice) ? parsedPrice : 0;
}
}

View File

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

View File

@@ -0,0 +1,56 @@
<nav
class="d-inline-flex align-items-center justify-content-center gap-3 text-decoration-none"
aria-label="Paginacion"
>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-first"
aria-label="Ir a la primera pagina"
[disabled]="isFirstStepDisabled()"
(click)="changePage('first')"
>
<i class="fa-solid fa-angles-left" aria-hidden="true"></i>
</button>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-previous"
aria-label="Ir a la pagina anterior"
[disabled]="isFirstStepDisabled()"
(click)="changePage('previous')"
>
<i class="fa-solid fa-angle-left" aria-hidden="true"></i>
</button>
<span
class="paginator__status d-inline-block text-center fw-semibold lh-1"
data-testid="paginator-status"
aria-live="polite"
>
{{ displayPage() }}/{{ displayTotalPages() }}
</span>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-next"
aria-label="Ir a la pagina siguiente"
[disabled]="isLastStepDisabled()"
(click)="changePage('next')"
>
<i class="fa-solid fa-angle-right" aria-hidden="true"></i>
</button>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-last"
aria-label="Ir a la ultima pagina"
[disabled]="isLastStepDisabled()"
(click)="changePage('last')"
>
<i class="fa-solid fa-angles-right" aria-hidden="true"></i>
</button>
</nav>

View File

@@ -0,0 +1,37 @@
:host {
display: block;
width: fit-content;
color: var(--tenant-primary);
}
.paginator__button {
width: 2rem;
height: 2rem;
color: inherit;
font-size: 1rem;
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover:not(:disabled) {
background-color: color-mix(in srgb, white 84%, var(--tenant-primary));
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, white 55%, var(--tenant-primary));
outline-offset: 2px;
}
&:disabled {
color: color-mix(in srgb, white 45%, var(--tenant-primary));
cursor: default;
}
}
.paginator__status {
min-width: 3rem;
font-size: 1rem;
letter-spacing: 0.04em;
}

View File

@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PaginatorComponent } from './paginator.component';
describe('PaginatorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PaginatorComponent]
}).compileComponents();
});
function setup(page: number, totalPages: number, disabled = false) {
const fixture = TestBed.createComponent(PaginatorComponent);
fixture.componentRef.setInput('page', page);
fixture.componentRef.setInput('totalPages', totalPages);
fixture.componentRef.setInput('disabled', disabled);
fixture.detectChanges();
return {
fixture,
element: fixture.nativeElement as HTMLElement
};
}
it('renders the current page status', () => {
const { element } = setup(1, 8);
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
});
it('emits the next page when clicking next', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(2);
});
it('emits the last page when clicking last', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-last"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(8);
});
it('emits the first page when clicking first from a middle page', () => {
const { fixture, element } = setup(4, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-first"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(1);
});
it('does not emit when trying to go before the first page', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-previous"]') as HTMLButtonElement).click();
expect(pageChangeSpy).not.toHaveBeenCalled();
});
it('does not emit when trying to go after the last page', () => {
const { fixture, element } = setup(8, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
expect(pageChangeSpy).not.toHaveBeenCalled();
});
it('disables all buttons when disabled is true', () => {
const { element } = setup(2, 8, true);
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
});
it('disables all buttons when there is only one page', () => {
const { element } = setup(1, 1);
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
});
});

View File

@@ -0,0 +1,76 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
type PaginatorAction = 'first' | 'previous' | 'next' | 'last';
@Component({
selector: 'app-paginator',
standalone: true,
templateUrl: './paginator.component.html',
styleUrl: './paginator.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PaginatorComponent {
readonly page = input.required<number>();
readonly totalPages = input.required<number>();
readonly disabled = input(false);
readonly pageChange = output<number>();
protected readonly displayPage = computed(() => {
const totalPages = this.displayTotalPages();
if (totalPages < 1) {
return 0;
}
return Math.min(Math.max(this.page(), 1), totalPages);
});
protected readonly displayTotalPages = computed(() => Math.max(this.totalPages(), 0));
protected readonly isNavigationDisabled = computed(() => {
const page = this.page();
const totalPages = this.totalPages();
return (
this.disabled() ||
!Number.isInteger(page) ||
!Number.isInteger(totalPages) ||
totalPages <= 1 ||
page < 1 ||
page > totalPages
);
});
protected readonly isFirstStepDisabled = computed(
() => this.isNavigationDisabled() || this.displayPage() <= 1
);
protected readonly isLastStepDisabled = computed(
() => this.isNavigationDisabled() || this.displayPage() >= this.displayTotalPages()
);
protected changePage(action: PaginatorAction): void {
if (this.isNavigationDisabled()) {
return;
}
const currentPage = this.displayPage();
const totalPages = this.displayTotalPages();
const nextPageByAction: Record<PaginatorAction, number> = {
first: 1,
previous: currentPage - 1,
next: currentPage + 1,
last: totalPages
};
const nextPage = nextPageByAction[action];
if (nextPage === currentPage || nextPage < 1 || nextPage > totalPages) {
return;
}
this.pageChange.emit(nextPage);
}
}

View File

@@ -0,0 +1,50 @@
<div class="product-card border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100">
<!-- Image section -->
<div class="product-card__image-container position-relative bg-light">
@if (imageUrl()) {
<img [src]="imageUrl()" [alt]="title()" class="product-card__image w-100 h-100 object-fit-cover" />
} @else {
<div
class="product-card__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted">
<i class="fa-solid fa-image fa-2x"></i>
</div>
}
<!-- Discount badge in top right -->
@if (discount() && discount()! > 0) {
<span class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1">
-{{ discount() }}%
</span>
}
</div>
<!-- Content section -->
<div class="product-card__body p-3 d-flex flex-column align-items-center text-center flex-grow-1">
<!-- Product Title -->
<h3 class="product-card__title text-uppercase text-black mb-4">{{ title() }}</h3>
<!-- Prices Area -->
<div class="product-card__prices d-flex align-items-center justify-content-center gap-2">
<!-- Discounted (computado) Price -->
<span class="product-card__price-discounted text-primary">{{ formattedDiscountedPrice() }}</span>
<!-- Original strikethrough Price (only if discount exists) -->
@if (discount() && discount()! > 0) {
<span class="product-card__price-original text-decoration-line-through">{{ formattedOriginalPrice() }}</span>
}
</div>
<!-- Transfer Price Info -->
<div class="product-card__price-transfer mb-4">
<span class="product-card__price-transfer-value">{{ formattedTransferPrice() }}</span>
<span class="product-card__price-transfer-label"> con transferencia</span>
</div>
<!-- Purchase Action Button -->
<div class="product-card__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()">
{{ buttonText() }}
</app-button>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.product-card {
width: 100%;
max-height: 411px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
&__image-container {
width: 100%;
max-height: 50%;
aspect-ratio: 1 / 1;
overflow: hidden;
}
&__discount-badge {
background-color: var(--tenant-primary) !important;
font-size: 13px;
font-weight: 325;
z-index: 10;
}
&__title {
font-size: 12px;
font-weight: 325;
letter-spacing: 0.5px;
line-height: 1.4;
}
&__price-discounted {
color: var(--tenant-primary) !important;
font-size: 24px;
font-weight: 700;
}
&__price-original {
font-size: 11px;
font-weight: 325;
color: #DDDDDD;
}
&__price-transfer {
font-size: 12px;
color: #6c757d;
}
&__price-transfer-value {
font-weight: 700;
}
&__price-transfer-label {
font-weight: 300;
}
&__action {
app-button {
display: block;
width: 100%;
::ng-deep .btn {
width: 100%;
min-width: 100%;
border-radius: 8px;
}
}
}
}

View File

@@ -0,0 +1,69 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-product-card',
standalone: true,
imports: [ButtonComponent],
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
// Configurable inputs
readonly imageUrl = input<string | null>(null);
readonly title = input<string>('');
readonly originalPrice = input<number>(0);
readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar');
// Interactive events
readonly buy = output<void>();
// Computed discounted price
readonly discountedPrice = computed(() => {
const original = this.originalPrice();
const disc = this.discount();
if (!disc || disc <= 0) {
return original;
}
return original * (1 - disc / 100);
});
// Computed transfer price
readonly computedTransferPrice = computed(() => {
const givenTransfer = this.transferPrice();
if (givenTransfer !== null) {
return givenTransfer;
}
// Fallback if not specified: use the discounted price
return this.discountedPrice();
});
// Formatted string for discounted price: "$ 76.000"
readonly formattedDiscountedPrice = computed(() => {
return this.formatCurrency(this.discountedPrice());
});
// Formatted string for original price (if has discount): "$ 95.000"
readonly formattedOriginalPrice = computed(() => {
return this.formatCurrency(this.originalPrice());
});
// Formatted string for transfer price: "$ 74.800"
readonly formattedTransferPrice = computed(() => {
return this.formatCurrency(this.computedTransferPrice());
});
/**
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
* This manual implementation is safe from runtime/SSR locale variations.
*/
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,9 @@
<section class="d-grid w-100 gap-3">
<header class="d-flex justify-content-center">
<h2 class="m-0 text-center text-uppercase store-section__title">{{ title() }}</h2>
</header>
<div class="w-100">
<ng-content></ng-content>
</div>
</section>

View File

@@ -0,0 +1,12 @@
:host {
display: block;
width: 100%;
}
.store-section__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
color: #000000;
}

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-store-section',
standalone: true,
templateUrl: './store-section.component.html',
styleUrl: './store-section.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StoreSectionComponent {
readonly title = input.required<string>();
}

View File

@@ -9,6 +9,12 @@
:root {
--bs-body-font-family: "Gotham", sans-serif;
--bs-primary: var(--tenant-primary);
--bs-primary-rgb: var(--tenant-primary-rgb);
--bs-secondary: var(--tenant-secondary);
--bs-secondary-rgb: var(--tenant-secondary-rgb);
--bs-danger: var(--tenant-danger);
--bs-danger-rgb: var(--tenant-danger-rgb);
}
label {