diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 1404718..49ee050 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,7 +4,7 @@ import { provideAppInitializer, provideBrowserGlobalErrorListeners, } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, UrlSerializer } from '@angular/router'; import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser'; import { routes } from './app.routes'; @@ -12,6 +12,7 @@ import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authInterceptor } from './core/services/auth/auth.interceptor'; import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor'; import { tenantBootstrap } from './core/services/tenant-bootstrap'; +import { TenantUrlSerializer } from './core/services/tenant-url.serializer'; export function isStoreCatalogRequest(request: HttpRequest): boolean { return ( @@ -26,6 +27,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + { provide: UrlSerializer, useClass: TenantUrlSerializer }, provideClientHydration( withHttpTransferCacheOptions({ includeRequestsWithAuthHeaders: true, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1bf87ad..d4afdd4 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -14,5 +14,12 @@ export const routes: Routes = [ { path: '', loadChildren: () => import('./features/store/store.routes').then((m) => m.routes) + }, + { + path: '**', + loadComponent: () => + import('./shared/pages/route-not-found-page.component').then( + (m) => m.RouteNotFoundPageComponent, + ) } ]; diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 73f921f..ae1d310 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -119,8 +119,12 @@ export class AuthService extends BaseApiService { const apiUrl = new URL(environment.url); const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin); + const basePath = tenant.base_path && tenant.base_path !== '/' ? tenant.base_path : ''; authorizationUrl.searchParams.set('tenant', tenant.codigo); - authorizationUrl.searchParams.set('return_url', this.document.location.origin); + authorizationUrl.searchParams.set( + 'return_url', + `${this.document.location.origin}${basePath}`, + ); this.document.location.assign(authorizationUrl.toString()); } diff --git a/src/app/core/services/tenant-url.serializer.spec.ts b/src/app/core/services/tenant-url.serializer.spec.ts new file mode 100644 index 0000000..ebcd141 --- /dev/null +++ b/src/app/core/services/tenant-url.serializer.spec.ts @@ -0,0 +1,50 @@ +import '@angular/compiler'; +import { DefaultUrlSerializer } from '@angular/router'; +import { describe, expect, it } from 'vitest'; + +import { Tenant } from './tenant.interface'; +import { TenantService } from './tenant.service'; +import { TenantUrlSerializer } from './tenant-url.serializer'; + +describe('TenantUrlSerializer', () => { + const defaultSerializer = new DefaultUrlSerializer(); + + function createSerializer(basePath: string): TenantUrlSerializer { + const tenantService = { + getTenant: () => ({ base_path: basePath }) as Tenant, + } as TenantService; + + return new TenantUrlSerializer(tenantService); + } + + it('keeps root tenants unchanged', () => { + const serializer = createSerializer('/'); + const tree = serializer.parse('/producto/123?ref=home'); + + expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home'); + expect(serializer.serialize(tree)).toBe('/producto/123?ref=home'); + }); + + it('removes the tenant base path when parsing and restores it when serializing', () => { + const serializer = createSerializer('/desfile'); + const tree = serializer.parse('/desfile/producto/123?ref=home#detalle'); + + expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home#detalle'); + expect(serializer.serialize(tree)).toBe('/desfile/producto/123?ref=home#detalle'); + }); + + it('maps the tenant base path to the application root', () => { + const serializer = createSerializer('/desfile/'); + const tree = serializer.parse('/desfile'); + + expect(defaultSerializer.serialize(tree)).toBe('/'); + expect(serializer.serialize(tree)).toBe('/desfile'); + }); + + it('does not strip partial path segment matches', () => { + const serializer = createSerializer('/desfile'); + const tree = serializer.parse('/desfile-shop/producto/123'); + + expect(defaultSerializer.serialize(tree)).toBe('/desfile-shop/producto/123'); + }); +}); diff --git a/src/app/core/services/tenant-url.serializer.ts b/src/app/core/services/tenant-url.serializer.ts new file mode 100644 index 0000000..97eb37a --- /dev/null +++ b/src/app/core/services/tenant-url.serializer.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { DefaultUrlSerializer, UrlSerializer, UrlTree } from '@angular/router'; + +import { TenantService } from './tenant.service'; + +@Injectable() +export class TenantUrlSerializer extends UrlSerializer { + private readonly defaultSerializer = new DefaultUrlSerializer(); + + constructor(private readonly tenantService: TenantService) { + super(); + } + + override parse(url: string): UrlTree { + return this.defaultSerializer.parse(this.removeBasePath(url)); + } + + override serialize(tree: UrlTree): string { + const url = this.defaultSerializer.serialize(tree); + const basePath = this.basePath(); + + if (basePath === '/') { + return url; + } + + return url === '/' ? basePath : `${basePath}${url}`; + } + + private removeBasePath(url: string): string { + const basePath = this.basePath(); + + if (basePath === '/' || !this.startsWithCompletePathSegment(url, basePath)) { + return url; + } + + const remainder = url.slice(basePath.length); + + if (remainder === '') { + return '/'; + } + + return remainder.startsWith('?') || remainder.startsWith('#') + ? `/${remainder}` + : remainder; + } + + private basePath(): string { + const configuredPath = this.tenantService.getTenant()?.base_path?.trim() ?? '/'; + + if (configuredPath === '' || configuredPath === '/') { + return '/'; + } + + return `/${configuredPath.replace(/^\/+|\/+$/g, '')}`; + } + + private startsWithCompletePathSegment(url: string, basePath: string): boolean { + if (!url.startsWith(basePath)) { + return false; + } + + const boundary = url.charAt(basePath.length); + + return boundary === '' || boundary === '/' || boundary === '?' || boundary === '#'; + } +} diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index c8c6781..1ee741c 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -101,6 +101,7 @@ export interface Tenant { codigo: string; nombre: string; dominio: string; + base_path?: string; site_title?: string | null; favicon?: string | null; primary_color: string; diff --git a/src/app/shared/pages/route-not-found-page.component.ts b/src/app/shared/pages/route-not-found-page.component.ts new file mode 100644 index 0000000..bcbafb0 --- /dev/null +++ b/src/app/shared/pages/route-not-found-page.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-route-not-found-page', + template: ` +
+

404

+

No encontramos la página solicitada.

+
+ `, + styles: ` + .route-not-found { + min-height: 100vh; + display: grid; + place-content: center; + gap: 0.5rem; + padding: 2rem; + text-align: center; + } + + h1, + p { + margin: 0; + } + `, +}) +export class RouteNotFoundPageComponent {}