Compare commits
3 Commits
236a3772a4
...
da01745732
| Author | SHA1 | Date | |
|---|---|---|---|
| da01745732 | |||
| ac9ba12045 | |||
| 23e8da345a |
@@ -1,12 +1,17 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes), provideClientHydration()
|
||||
provideRouter(routes),
|
||||
provideClientHydration(),
|
||||
provideHttpClient(),
|
||||
provideAppInitializer(tenantBootstrap)
|
||||
]
|
||||
};
|
||||
|
||||
44
src/app/app.routes.spec.ts
Normal file
44
src/app/app.routes.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
|
||||
import { App } from './app';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
async function renderAppAt(url: string) {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter(routes)]
|
||||
}).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 /store', async () => {
|
||||
const { fixture, router } = await renderAppAt('/store');
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(router.url).toBe('/store');
|
||||
expect(compiled.querySelector('.store-layout')).not.toBeNull();
|
||||
expect(compiled.querySelector('.store-home')).not.toBeNull();
|
||||
expect(compiled.textContent).toContain('Tienda en construccion');
|
||||
});
|
||||
|
||||
it('keeps the root redirect under componentes-test', async () => {
|
||||
const { fixture, router } = await renderAppAt('/');
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(router.url).toBe('/componentes-test/reutilizables');
|
||||
expect(compiled.textContent).toContain('Componentes reutilizables');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ export const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/componentes-test/componentes-test.routes').then((m) => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'store',
|
||||
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<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="Espacio reservado para logo de Sonder"
|
||||
>
|
||||
<span class="store-layout__brand-mark store-layout__brand-mark--footer" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span class="store-layout__brand-text store-layout__brand-text--footer">SONDER</span>
|
||||
</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 text-white-50">Copyright © {{ 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="text-light-emphasis">{{ 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>
|
||||
@@ -0,0 +1,44 @@
|
||||
.store-layout__footer {
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.store-layout__brand-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-layout__brand-text {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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[] = [];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<header class="store-layout__header bg-white border-bottom">
|
||||
<div class="border-bottom">
|
||||
<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="Espacio reservado para logo de Sonder"
|
||||
>
|
||||
<span class="store-layout__brand-mark" aria-hidden="true">S</span>
|
||||
<span class="store-layout__brand-text">SONDER</span>
|
||||
</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 border-dark 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">
|
||||
<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 text-dark 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-dark 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>
|
||||
@@ -0,0 +1,46 @@
|
||||
.store-layout__brand-slot {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 1px dashed #bbbbbb;
|
||||
border-radius: 999px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #373737;
|
||||
}
|
||||
|
||||
.store-layout__brand-text {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
width: min(100%, 24rem);
|
||||
min-width: 18rem;
|
||||
}
|
||||
|
||||
.store-layout__category-trigger {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.store-layout__brand-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-layout__brand-text {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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[] = [];
|
||||
}
|
||||
13
src/app/core/layout/store-layout/store-layout.component.html
Normal file
13
src/app/core/layout/store-layout/store-layout.component.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||
<app-store-header [headerActions]="headerActions" />
|
||||
|
||||
<main class="flex-grow-1 d-block">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<app-store-footer
|
||||
[currentYear]="currentYear"
|
||||
[footerSections]="footerSections"
|
||||
[socialLinks]="socialLinks"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
background-color: #f6f6f6;
|
||||
color: #202020;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { StoreLayoutComponent } from './store-layout.component';
|
||||
|
||||
describe('StoreLayoutComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StoreLayoutComponent],
|
||||
providers: [provideRouter([])]
|
||||
}).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 placeholder 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('.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();
|
||||
});
|
||||
});
|
||||
55
src/app/core/layout/store-layout/store-layout.component.ts
Normal file
55
src/app/core/layout/store-layout/store-layout.component.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
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 {
|
||||
protected readonly currentYear = new Date().getFullYear();
|
||||
|
||||
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'
|
||||
}
|
||||
];
|
||||
}
|
||||
55
src/app/core/services/tenant-bootstrap.spec.ts
Normal file
55
src/app/core/services/tenant-bootstrap.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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({
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tennant',
|
||||
dominio: 'localhost',
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
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);
|
||||
});
|
||||
});
|
||||
9
src/app/core/services/tenant-bootstrap.ts
Normal file
9
src/app/core/services/tenant-bootstrap.ts
Normal 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().then(() => undefined);
|
||||
}
|
||||
16
src/app/core/services/tenant.interface.ts
Normal file
16
src/app/core/services/tenant.interface.ts
Normal 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;
|
||||
}
|
||||
88
src/app/core/services/tenant.service.spec.ts
Normal file
88
src/app/core/services/tenant.service.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PLATFORM_ID } 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 { TenantService } from './tenant.service';
|
||||
|
||||
describe('TenantService', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/store');
|
||||
});
|
||||
|
||||
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}tennatns/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(response);
|
||||
|
||||
await expect(bootstrapPromise).resolves.toEqual(response.data);
|
||||
expect(service.tenant()).toEqual(response.data);
|
||||
|
||||
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}tennatns/bootstrap/localhost`);
|
||||
|
||||
request.flush({});
|
||||
|
||||
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
|
||||
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
it('skips the HTTP bootstrap on the server platform', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
TenantService,
|
||||
{ provide: PLATFORM_ID, useValue: 'server' }
|
||||
]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
await expect(service.bootstrap()).resolves.toBeNull();
|
||||
expect(service.tenant()).toBeNull();
|
||||
|
||||
httpController.expectNone(() => true);
|
||||
httpController.verify();
|
||||
});
|
||||
});
|
||||
47
src/app/core/services/tenant.service.ts
Normal file
47
src/app/core/services/tenant.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TenantService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly tenantState = signal<Tenant | null>(null);
|
||||
|
||||
readonly tenant = this.tenantState.asReadonly();
|
||||
|
||||
async bootstrap(): Promise<Tenant | null> {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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}".`);
|
||||
}
|
||||
|
||||
this.tenantState.set(response.data);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private resolveDomain(): string {
|
||||
const domain = window.location.hostname;
|
||||
|
||||
if (!domain) {
|
||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
];
|
||||
}
|
||||
17
src/app/features/store/store.routes.ts
Normal file
17
src/app/features/store/store.routes.ts
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
6
src/environments/environment.local.ts
Normal file
6
src/environments/environment.local.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
nombre:"Desarrollo - activo",
|
||||
url:"http://localhost:8000/api/",
|
||||
urlDescarga:"url/"
|
||||
};
|
||||
6
src/environments/environment.ts
Normal file
6
src/environments/environment.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
nombre:"Desarrollo - activo",
|
||||
url:"http://localhost:8000/api/",
|
||||
urlDescarga:"url/"
|
||||
};
|
||||
21
src/main.ts
21
src/main.ts
@@ -2,5 +2,24 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
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)
|
||||
.catch((err) => console.error(err));
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
renderBootstrapError(err);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user