Files
Lorenz Hilpert 2b5da00249 feat(checkout): add reward order confirmation feature with schema migrations
- 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
2025-10-21 14:28:52 +02:00

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

  • 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 URL
  • queryParams - Optional query parameters
  • fragment - Optional URL fragment
  • preserveFragment - Whether to preserve URL fragment
  • skipLocationChange - Whether to skip location change
  • replaceUrl - Whether to replace current history entry
  • state - 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}`;
};

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 routerLink after 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.