- Add new reward-order-confirmation feature library with components and store - Implement checkout completion orchestrator service for order finalization - Migrate checkout/oms/crm models to Zod schemas for better type safety - Add order creation facade and display order schemas - Update shopping cart facade with order completion flow - Add comprehensive tests for shopping cart facade - Update routing to include order confirmation page
25 KiB
@isa/shared/product-router-link
An Angular library providing a customizable directive for creating product navigation links based on EAN codes with flexible URL generation strategies.
Overview
The Product Router Link library extends Angular's RouterLink to provide a declarative way of navigating to product detail pages using EAN codes. It features a pluggable URL builder system that allows applications to customize how product URLs are generated, making it adaptable to different routing strategies and application requirements.
Table of Contents
- Features
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Configuration
- Testing
- Architecture Notes
Features
- Declarative navigation - Simple directive extending Angular's RouterLink
- EAN-based routing - Automatic URL generation from product EAN codes
- Pluggable URL builder - Customizable URL generation strategy via DI
- Async URL support - Builder can return Promise for dynamic URL resolution
- Effect-based updates - Reactive URL updates when EAN changes
- Type-safe configuration - TypeScript interfaces for custom builders
- Cursor styling - Automatic pointer cursor for better UX
- Standalone architecture - Modern Angular standalone directive
Quick Start
1. Basic Usage with Default Builder
The library includes a default URL builder that generates product detail URLs:
import { Component } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<div class="product-grid">
@for (product of products; track product.ean) {
<div
sharedProductRouterLink
[ean]="product.ean"
class="product-card"
>
<h3>{{ product.name }}</h3>
<p>Click to view details</p>
</div>
}
</div>
`
})
export class ProductListComponent {
products = [
{ ean: '1234567890123', name: 'Product 1' },
{ ean: '9876543210987', name: 'Product 2' }
];
}
2. Custom URL Builder
Override the default URL builder to match your routing structure:
import { ApplicationConfig } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder((ean: string) => {
return `/products/${ean}`;
})
]
};
3. Async URL Builder
Use async builders for dynamic URL resolution:
import { inject } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { ProductService } from './product.service';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder(async (ean: string) => {
const productService = inject(ProductService);
const product = await productService.getProductByEan(ean);
return `/category/${product.categoryId}/product/${ean}`;
})
]
};
Core Concepts
EAN-Based Navigation
The directive uses EAN (European Article Number) as the primary identifier for product navigation. This ensures:
- Consistent routing: Same EAN always links to same product
- SEO-friendly: Product URLs can include EAN for search optimization
- API integration: EAN directly maps to backend product identifiers
URL Builder Pattern
The library uses the Strategy pattern for URL generation:
type ProductRouterLinkBuilder = (ean: string) => PromiseLike<string> | string;
This allows:
- Customization: Each application defines its own routing structure
- Flexibility: Synchronous or asynchronous URL generation
- Testability: Easy to mock and test different URL strategies
Default URL Format
Without custom configuration, the default builder generates URLs in this format:
/kunde/{timestamp}/product/details/{ean}/ean
Example:
/kunde/1698765432000/product/details/1234567890123/ean
The timestamp ensures each navigation creates a new route entry, useful for certain navigation stack requirements.
Effect-Based Reactivity
The directive uses Angular effects to automatically update the router link when the EAN input changes:
urlEffect = effect(async () => {
const ean = this.ean();
if (!ean) return;
const url = await this.#builder(ean);
this.routerLink = url;
});
This ensures the link is always synchronized with the current EAN value.
API Reference
ProductRouterLinkDirective
A standalone directive extending Angular's RouterLink for EAN-based product navigation.
Selector: [sharedProductRouterLink]
Extends: RouterLink
Inputs
ean (required)
- Type:
InputSignal<string> - Description: The product's EAN code to navigate to
Host Bindings
class: Automatically adds'cursor-pointer'for better UX
Inherited Properties
Inherits all properties from Angular's RouterLink:
routerLink- The dynamically generated product URLqueryParams- Optional query parametersfragment- Optional URL fragmentpreserveFragment- Whether to preserve URL fragmentskipLocationChange- Whether to skip location changereplaceUrl- Whether to replace current history entrystate- Navigation state
Example
<div
sharedProductRouterLink
[ean]="productEan"
[queryParams]="{ source: 'search' }"
>
View Product Details
</div>
ProductRouterLinkBuilder
Type definition for custom URL builder functions.
Type:
type ProductRouterLinkBuilder = (ean: string) => PromiseLike<string> | string;
Parameters:
ean: string- The product EAN to build a URL for
Returns: PromiseLike<string> | string - The generated product URL (can be async)
Example Implementations:
// Synchronous builder
const syncBuilder: ProductRouterLinkBuilder = (ean) => {
return `/products/${ean}`;
};
// Asynchronous builder
const asyncBuilder: ProductRouterLinkBuilder = async (ean) => {
const category = await fetchCategoryForEan(ean);
return `/categories/${category}/products/${ean}`;
};
PRODUCT_ROUTER_LINK_BUILDER
Injection token for the product router link builder function.
Type: InjectionToken<ProductRouterLinkBuilder>
Default Factory:
(ean: string) => `/kunde/${Date.now()}/product/details/${ean}/ean`
The default implementation creates URLs with timestamps for unique navigation entries.
provideProductRouterLinkBuilder(builder)
Provider function for configuring a custom product URL builder.
Parameters:
builder: ProductRouterLinkBuilder- Custom URL builder function
Returns: Provider[] - Array of providers to include in application config
Example:
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder((ean) => `/shop/product/${ean}`)
]
};
Usage Examples
Product Card Navigation
import { Component } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { ProductImageDirective } from '@isa/shared/product-image';
@Component({
selector: 'app-product-card',
standalone: true,
imports: [ProductRouterLinkDirective, ProductImageDirective],
template: `
<div
sharedProductRouterLink
[ean]="product.ean"
class="product-card hover:shadow-lg transition-shadow"
>
<img
sharedProductImage
[ean]="product.ean"
[imageWidth]="300"
[imageHeight]="300"
alt="{{ product.name }}"
/>
<h3 class="font-bold">{{ product.name }}</h3>
<p class="text-gray-600">{{ product.price | currency }}</p>
</div>
`,
styles: [`
.product-card {
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
transition: box-shadow 0.2s;
}
`]
})
export class ProductCardComponent {
@Input() product!: { ean: string; name: string; price: number };
}
Search Results with Query Parameters
import { Component, inject } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { Router } from '@angular/router';
@Component({
selector: 'app-search-results',
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<div class="search-results">
@for (result of searchResults; track result.ean) {
<div
sharedProductRouterLink
[ean]="result.ean"
[queryParams]="{ source: 'search', q: searchQuery }"
class="result-item"
>
<h4>{{ result.name }}</h4>
<p>{{ result.description }}</p>
</div>
}
</div>
`
})
export class SearchResultsComponent {
searchResults = [
{ ean: '1234567890123', name: 'Product 1', description: 'Description 1' },
{ ean: '2345678901234', name: 'Product 2', description: 'Description 2' }
];
searchQuery = 'laptop';
}
Table Row Navigation
import { Component } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@Component({
selector: 'app-product-table',
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<table class="w-full">
<thead>
<tr>
<th>EAN</th>
<th>Name</th>
<th>Stock</th>
<th>Price</th>
</tr>
</thead>
<tbody>
@for (product of products; track product.ean) {
<tr
sharedProductRouterLink
[ean]="product.ean"
class="hover:bg-gray-100"
>
<td>{{ product.ean }}</td>
<td>{{ product.name }}</td>
<td>{{ product.stock }}</td>
<td>{{ product.price | currency }}</td>
</tr>
}
</tbody>
</table>
`
})
export class ProductTableComponent {
products = [
{ ean: '1234567890123', name: 'Laptop', stock: 5, price: 999.99 },
{ ean: '2345678901234', name: 'Mouse', stock: 50, price: 29.99 },
{ ean: '3456789012345', name: 'Keyboard', stock: 30, price: 79.99 }
];
}
Navigation with State
import { Component } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@Component({
selector: 'app-cart-items',
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<div class="cart-items">
@for (item of cartItems; track item.ean) {
<div
sharedProductRouterLink
[ean]="item.ean"
[state]="{ returnUrl: '/cart', quantity: item.quantity }"
class="cart-item"
>
<h4>{{ item.name }}</h4>
<p>Quantity: {{ item.quantity }}</p>
<p>Click to view details</p>
</div>
}
</div>
`
})
export class CartItemsComponent {
cartItems = [
{ ean: '1234567890123', name: 'Product 1', quantity: 2 },
{ ean: '2345678901234', name: 'Product 2', quantity: 1 }
];
}
Dynamic EAN with Signal
import { Component, signal } from '@angular/core';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@Component({
selector: 'app-related-products',
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<div class="related-products">
<h3>Related Products</h3>
@for (relatedEan of relatedEans; track relatedEan) {
<div
sharedProductRouterLink
[ean]="relatedEan"
class="related-item"
>
View Product {{ relatedEan }}
</div>
}
</div>
<button (click)="loadMoreRelated()">
Load More
</button>
`
})
export class RelatedProductsComponent {
relatedEans = ['1234567890123', '2345678901234'];
loadMoreRelated(): void {
this.relatedEans = [
...this.relatedEans,
'3456789012345',
'4567890123456'
];
}
}
Configuration
Application-Level Configuration
Configure a custom URL builder at the application level:
// In app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder((ean: string) => {
// Simple product detail route
return `/products/${ean}`;
})
]
};
Feature-Specific Configuration
Provide different builders for specific features or modules:
import { Component } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
@Component({
selector: 'app-admin-products',
standalone: true,
providers: [
// Admin-specific URL builder
provideProductRouterLinkBuilder((ean) => {
return `/admin/catalog/products/${ean}/edit`;
})
],
template: `...`
})
export class AdminProductsComponent {}
Category-Based URL Builder
Build URLs that include category information:
import { inject } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { CategoryService } from './category.service';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder(async (ean: string) => {
const categoryService = inject(CategoryService);
const category = await categoryService.getCategoryByEan(ean);
return `/shop/${category.slug}/products/${ean}`;
})
]
};
Multi-Tenant URL Builder
Generate tenant-specific product URLs:
import { inject } from '@angular/core';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { TenantService } from './tenant.service';
export const appConfig: ApplicationConfig = {
providers: [
provideProductRouterLinkBuilder((ean: string) => {
const tenantService = inject(TenantService);
const tenantId = tenantService.getCurrentTenantId();
return `/tenant/${tenantId}/products/${ean}`;
})
]
};
Testing Configuration
Override builder for testing:
import { TestBed } from '@angular/core/testing';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
describe('ProductListComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
// Use simple URLs for testing
provideProductRouterLinkBuilder((ean) => `/test-products/${ean}`)
]
}).compileComponents();
});
it('should navigate to correct URL', () => {
// Test navigation with predictable URLs
});
});
Testing
The library uses Jest for testing.
Running Tests
# Run tests for this library
npx nx test shared-product-router-link --skip-nx-cache
# Run tests with coverage
npx nx test shared-product-router-link --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test shared-product-router-link --watch
Test Examples
Testing the Directive
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { ProductRouterLinkDirective, provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
@Component({
standalone: true,
imports: [ProductRouterLinkDirective],
template: `
<div sharedProductRouterLink [ean]="ean">Navigate</div>
`
})
class TestComponent {
ean = '1234567890123';
}
describe('ProductRouterLinkDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestComponent],
providers: [
provideProductRouterLinkBuilder((ean) => `/products/${ean}`)
]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
router = TestBed.inject(Router);
fixture.detectChanges();
});
it('should have cursor-pointer class', () => {
const element = fixture.nativeElement.querySelector('[sharedProductRouterLink]');
expect(element.classList.contains('cursor-pointer')).toBe(true);
});
it('should generate correct router link', async () => {
const directive = fixture.debugElement.query(
By.directive(ProductRouterLinkDirective)
).componentInstance;
// Wait for effect to process
await fixture.whenStable();
expect(directive.routerLink).toBe('/products/1234567890123');
});
});
Testing URL Builder
import { ProductRouterLinkBuilder } from '@isa/shared/product-router-link';
describe('Custom URL Builder', () => {
it('should generate category-based URLs', async () => {
const builder: ProductRouterLinkBuilder = (ean) => {
// Mock category lookup
const categoryMap: Record<string, string> = {
'1234567890123': 'electronics',
'2345678901234': 'books'
};
const category = categoryMap[ean] || 'general';
return `/category/${category}/product/${ean}`;
};
const url1 = await builder('1234567890123');
const url2 = await builder('2345678901234');
expect(url1).toBe('/category/electronics/product/1234567890123');
expect(url2).toBe('/category/books/product/2345678901234');
});
it('should handle async builders', async () => {
const builder: ProductRouterLinkBuilder = async (ean) => {
await new Promise(resolve => setTimeout(resolve, 10));
return `/products/${ean}`;
};
const url = await builder('1234567890123');
expect(url).toBe('/products/1234567890123');
});
});
Integration Testing
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { ProductListComponent } from './product-list.component';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
describe('ProductListComponent Navigation', () => {
let fixture: ComponentFixture<ProductListComponent>;
let router: Router;
let location: Location;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
provideRouter([
{ path: 'products/:ean', component: ProductDetailComponent }
]),
provideProductRouterLinkBuilder((ean) => `/products/${ean}`)
]
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture.detectChanges();
});
it('should navigate to product detail when clicked', async () => {
const productLink = fixture.nativeElement.querySelector('[sharedProductRouterLink]');
productLink.click();
await fixture.whenStable();
expect(location.path()).toBe('/products/1234567890123');
});
});
Architecture Notes
Design Patterns
Strategy Pattern
The library implements the Strategy pattern for URL generation:
// Strategy interface
type ProductRouterLinkBuilder = (ean: string) => PromiseLike<string> | string;
// Concrete strategies
const defaultStrategy: ProductRouterLinkBuilder = (ean) =>
`/kunde/${Date.now()}/product/details/${ean}/ean`;
const simpleStrategy: ProductRouterLinkBuilder = (ean) =>
`/products/${ean}`;
const categoryStrategy: ProductRouterLinkBuilder = async (ean) => {
const category = await fetchCategory(ean);
return `/category/${category}/product/${ean}`;
};
This enables:
- Flexibility: Easy to change URL generation logic
- Testability: Each strategy can be tested in isolation
- Reusability: Same directive works with different strategies
Directive Extension Pattern
Extends Angular's RouterLink instead of reimplementing:
export class ProductRouterLinkDirective extends RouterLink {
// Inherits all RouterLink functionality
// Only adds EAN-specific behavior
}
Benefits:
- Reuses battle-tested Angular router logic
- Inherits all RouterLink features (query params, state, etc.)
- Minimal code surface for bugs
Effect-Based URL Resolution
Uses Angular effects for reactive URL updates:
urlEffect = effect(async () => {
const ean = this.ean();
if (!ean) return;
const url = await this.#builder(ean);
this.routerLink = url;
});
Why Effects?
- Automatically runs when EAN signal changes
- Handles both sync and async builders
- Cleans up automatically with directive lifecycle
Dependency Injection Strategy
Uses injection token with default factory:
export const PRODUCT_ROUTER_LINK_BUILDER =
new InjectionToken<ProductRouterLinkBuilder>('PRODUCT_ROUTER_LINK_BUILDER', {
factory: () => (ean: string) =>
`/kunde/${Date.now()}/product/details/${ean}/ean`,
});
This provides:
- Default behavior: Works out of the box
- Override capability: Easy to customize per application
- Tree-shakeable: Unused code can be removed
Host Class Binding
Automatically adds cursor styling:
@Directive({
selector: '[sharedProductRouterLink]',
host: {
class: 'cursor-pointer',
},
})
This improves UX without requiring consumers to remember CSS classes.
Type Safety
TypeScript interface ensures builder correctness:
export type ProductRouterLinkBuilder = (
ean: string,
) => PromiseLike<string> | string;
Prevents runtime errors from invalid builder implementations.
Dependencies
Required Libraries
@angular/core- Angular framework@angular/router- Angular router for RouterLink extension
Path Alias
Import from: @isa/shared/product-router-link
Peer Dependencies
- Angular Router must be configured in the application
- Router providers must be included in application config
Best Practices
1. Consistent URL Structure
Maintain consistent URL patterns across your application:
// Good: Consistent structure
provideProductRouterLinkBuilder((ean) => `/products/${ean}`)
// Avoid: Inconsistent patterns
// Some links go to /product/:ean, others to /item/:ean
2. SEO-Friendly URLs
Include product slugs for better SEO:
provideProductRouterLinkBuilder(async (ean) => {
const product = await fetchProduct(ean);
const slug = product.name.toLowerCase().replace(/\s+/g, '-');
return `/products/${slug}/${ean}`;
});
// Result: /products/wireless-mouse/1234567890123
3. Error Handling
Handle missing products gracefully:
provideProductRouterLinkBuilder(async (ean) => {
try {
const product = await fetchProduct(ean);
return `/products/${product.id}`;
} catch (error) {
console.error(`Product not found: ${ean}`);
return `/products/not-found?ean=${ean}`;
}
});
4. Caching
Cache URL generation for frequently accessed products:
const urlCache = new Map<string, string>();
provideProductRouterLinkBuilder(async (ean) => {
if (urlCache.has(ean)) {
return urlCache.get(ean)!;
}
const url = await generateUrl(ean);
urlCache.set(ean, url);
return url;
});
5. Analytics Tracking
Include tracking parameters in URLs:
provideProductRouterLinkBuilder((ean) => {
const source = getTrackingSource(); // e.g., 'search', 'recommendation'
return `/products/${ean}?utm_source=${source}`;
});
Performance Considerations
Async Builder Performance
- Async builders run on every EAN change
- Consider caching for frequently viewed products
- Avoid expensive operations in builder functions
Effect Cleanup
- Effects automatically clean up when directive is destroyed
- No manual subscription management needed
- Guards against setting
routerLinkafter destruction
Bundle Size
- Library is small (~2KB minified)
- Extends RouterLink, no duplicate router logic
- Tree-shakeable with proper build configuration
Common Use Cases
E-commerce Product Navigation
provideProductRouterLinkBuilder((ean) => `/shop/products/${ean}`)
Admin Panel
provideProductRouterLinkBuilder((ean) => `/admin/inventory/${ean}`)
Multi-Language Sites
provideProductRouterLinkBuilder((ean) => {
const lang = getCurrentLanguage();
return `/${lang}/products/${ean}`;
})
Marketplace with Vendors
provideProductRouterLinkBuilder(async (ean) => {
const product = await fetchProduct(ean);
return `/vendors/${product.vendorId}/products/${ean}`;
})
License
Internal ISA Frontend library - not for external distribution.