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

@isa/shared/product-image

A lightweight Angular library providing a directive and service for displaying product images from a CDN with dynamic sizing and fallback support.

Overview

The Product Image library simplifies the integration of product images throughout the ISA application by providing a declarative directive and a service for generating image URLs. It integrates with the application's configuration system to automatically fetch the CDN base URL and supports dynamic image dimensions, fallback images, and optional dummy image display.

Table of Contents

Features

  • Declarative directive - Simple sharedProductImage directive for img elements
  • CDN integration - Automatic CDN URL configuration from app config
  • Dynamic sizing - Configurable width and height with intelligent defaults
  • Dummy fallback - Optional dummy image display when product image unavailable
  • Signal-based reactivity - Uses Angular signals for efficient change detection
  • Type-safe configuration - Zod validation for CDN URL configuration
  • Custom provider - Override CDN URL for testing or alternative environments
  • Standalone architecture - Modern Angular standalone directive

Quick Start

1. Import the Directive

import { Component } from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductImageDirective],
  template: `
    <div class="product-grid">
      @for (product of products; track product.ean) {
        <div class="product-card">
          <img
            sharedProductImage
            [ean]="product.ean"
            [imageWidth]="300"
            [imageHeight]="300"
            alt="Product image"
          />
        </div>
      }
    </div>
  `
})
export class ProductListComponent {
  products = [
    { ean: '1234567890123', name: 'Product 1' },
    { ean: '9876543210987', name: 'Product 2' }
  ];
}

2. Use with Default Dimensions

The directive uses sensible defaults (150x150) if dimensions are not specified:

@Component({
  template: `
    <!-- Uses default 150x150 dimensions -->
    <img sharedProductImage [ean]="productEan" alt="Product" />
  `
})
export class ProductDetailComponent {
  productEan = '1234567890123';
}

3. Use the Service Directly

For programmatic URL generation:

import { Component, inject } from '@angular/core';
import { ProductImageService } from '@isa/shared/product-image';

@Component({
  selector: 'app-custom-image',
  template: `
    <div [style.background-image]="'url(' + imageUrl + ')'"></div>
  `
})
export class CustomImageComponent {
  #productImageService = inject(ProductImageService);

  imageUrl = this.#productImageService.getImageUrl({
    imageId: '1234567890123',
    width: 400,
    height: 300,
    showDummy: true
  });
}

Core Concepts

Image URL Structure

The library generates CDN image URLs following this format:

{CDN_BASE_URL}/{imageId}_{width}x{height}.jpg?showDummy={showDummy}

Example:

https://cdn.example.com/1234567890123_300x300.jpg?showDummy=true

Image ID (EAN)

Product images are identified by their EAN (European Article Number), which serves as the imageId in the CDN URL structure. This ensures a consistent 1:1 mapping between products and their images.

Dynamic Sizing

The library supports dynamic image dimensions to optimize for different use cases:

  • Thumbnail: 150x150 (default)
  • Product Card: 300x300
  • Detail View: 600x600
  • Custom: Any width/height combination

The CDN automatically scales and serves the requested dimensions, improving page load performance.

Dummy Image Fallback

The showDummy parameter controls fallback behavior:

  • true (default): Display a generic placeholder when the actual product image is unavailable
  • false: Return no image (useful for progressive loading scenarios)

API Reference

ProductImageDirective

A standalone directive that sets the src attribute of img elements to product image URLs.

Selector: img[sharedProductImage]

Inputs

ean (required)
  • Type: InputSignal<string>
  • Description: The product's EAN number, used as the image identifier
imageWidth (optional)
  • Type: InputSignal<number>
  • Default: 150
  • Description: Desired image width in pixels
imageHeight (optional)
  • Type: InputSignal<number>
  • Default: 150
  • Description: Desired image height in pixels

Host Bindings

  • [src]: Automatically binds to the computed image URL

Example

<img
  sharedProductImage
  [ean]="productEan"
  [imageWidth]="300"
  [imageHeight]="300"
  alt="Product image"
  class="rounded-lg shadow-md"
/>

ProductImageService

Injectable service for generating product image URLs.

Provided in: 'root'

Methods

getImageUrl(options): string

Generates a CDN image URL for the specified product.

Parameters:

  • options.imageId: string - Product EAN (image identifier)
  • options.width?: number - Image width in pixels (default: 150)
  • options.height?: number - Image height in pixels (default: 150)
  • options.showDummy?: boolean - Show dummy/placeholder image if unavailable (default: true)

Returns: string - Complete CDN image URL

Example:

const url = productImageService.getImageUrl({
  imageId: '1234567890123',
  width: 400,
  height: 400,
  showDummy: false
});
// Result: "https://cdn.example.com/1234567890123_400x400.jpg?showDummy=false"

Properties

imageUrl: string
  • Type: string
  • Description: The base CDN URL injected from configuration
  • Access: readonly

PRODUCT_IMAGE_URL

Injection token providing the CDN base URL for product images.

Type: InjectionToken<string>

Default Factory:

inject(Config).get('@cdn/product-image.url', z.string().url())

The default implementation reads from the application's configuration service and validates the URL using Zod.

provideProductImageUrl(url)

Provider function for overriding the product image CDN URL.

Parameters:

  • url: string - Custom CDN base URL

Returns: Provider[] - Array of providers to include in application/component providers

Use Cases:

  • Testing with mock image server
  • Environment-specific CDN URLs
  • Development vs. production configurations

Example:

// In app.config.ts or testing setup
import { provideProductImageUrl } from '@isa/shared/product-image';

export const appConfig: ApplicationConfig = {
  providers: [
    provideProductImageUrl('https://test-cdn.example.com'),
    // other providers...
  ]
};

Usage Examples

Product Grid with Multiple Sizes

import { Component } from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';

@Component({
  selector: 'app-product-grid',
  standalone: true,
  imports: [ProductImageDirective],
  template: `
    <div class="grid grid-cols-4 gap-4">
      @for (product of products; track product.ean) {
        <div class="product-card">
          <img
            sharedProductImage
            [ean]="product.ean"
            [imageWidth]="250"
            [imageHeight]="250"
            alt="{{ product.name }}"
            class="w-full h-auto rounded"
          />
          <h3>{{ product.name }}</h3>
        </div>
      }
    </div>
  `
})
export class ProductGridComponent {
  products = [
    { ean: '1234567890123', name: 'Laptop Computer' },
    { ean: '2345678901234', name: 'Wireless Mouse' },
    { ean: '3456789012345', name: 'USB Cable' },
    { ean: '4567890123456', name: 'Monitor Stand' }
  ];
}

Thumbnail vs. Detail View

import { Component, signal } from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';

@Component({
  selector: 'app-product-detail',
  standalone: true,
  imports: [ProductImageDirective],
  template: `
    <div class="flex gap-4">
      <!-- Thumbnail gallery -->
      <div class="flex flex-col gap-2">
        @for (ean of productImages; track ean) {
          <img
            sharedProductImage
            [ean]="ean"
            [imageWidth]="100"
            [imageHeight]="100"
            (click)="selectedEan.set(ean)"
            class="cursor-pointer border-2"
            [class.border-isa-accent-500]="selectedEan() === ean"
          />
        }
      </div>

      <!-- Large preview -->
      <div class="flex-1">
        <img
          sharedProductImage
          [ean]="selectedEan()"
          [imageWidth]="600"
          [imageHeight]="600"
          class="w-full h-auto"
        />
      </div>
    </div>
  `
})
export class ProductDetailComponent {
  productImages = [
    '1234567890123',
    '1234567890124',
    '1234567890125'
  ];

  selectedEan = signal(this.productImages[0]);
}

Programmatic URL Generation

import { Component, inject, computed, signal } from '@angular/core';
import { ProductImageService } from '@isa/shared/product-image';

@Component({
  selector: 'app-background-image',
  template: `
    <div
      class="hero-section"
      [style.background-image]="backgroundUrl()"
    >
      <h1>Featured Product</h1>
    </div>
  `,
  styles: [`
    .hero-section {
      width: 100%;
      height: 400px;
      background-size: cover;
      background-position: center;
    }
  `]
})
export class BackgroundImageComponent {
  #productImageService = inject(ProductImageService);

  featuredEan = signal('1234567890123');

  backgroundUrl = computed(() => {
    const url = this.#productImageService.getImageUrl({
      imageId: this.featuredEan(),
      width: 1920,
      height: 600,
      showDummy: false
    });
    return `url(${url})`;
  });
}

Responsive Images with Different Sizes

import { Component } from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';
import { breakpoint, Breakpoint } from '@isa/ui/layout';

@Component({
  selector: 'app-responsive-image',
  standalone: true,
  imports: [ProductImageDirective],
  template: `
    @if (isDesktop()) {
      <img
        sharedProductImage
        [ean]="productEan"
        [imageWidth]="600"
        [imageHeight]="600"
        alt="Product"
      />
    } @else {
      <img
        sharedProductImage
        [ean]="productEan"
        [imageWidth]="300"
        [imageHeight]="300"
        alt="Product"
      />
    }
  `
})
export class ResponsiveImageComponent {
  productEan = '1234567890123';
  isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);
}

Configuration

Application-Level Configuration

The library integrates with @isa/core/config to read the CDN URL from the application configuration:

// In your configuration file (e.g., environment.ts)
export const environment = {
  '@cdn/product-image.url': 'https://cdn.example.com/products'
};

// The service automatically reads this configuration
// No additional setup required

Override for Testing

Use the provideProductImageUrl function to override the CDN URL for testing:

// In test setup or app.config.ts
import { provideProductImageUrl } from '@isa/shared/product-image';

export const appConfig: ApplicationConfig = {
  providers: [
    provideProductImageUrl('http://localhost:3000/mock-images'),
    // other providers...
  ]
};

Testing

The library uses Jest for testing.

Running Tests

# Run tests for this library
npx nx test shared-product-image --skip-nx-cache

# Run tests with coverage
npx nx test shared-product-image --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test shared-product-image --watch

Test Examples

Testing the Directive

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { ProductImageDirective, provideProductImageUrl } from '@isa/shared/product-image';

@Component({
  standalone: true,
  imports: [ProductImageDirective],
  template: `<img sharedProductImage [ean]="ean" [imageWidth]="300" [imageHeight]="300" />`
})
class TestComponent {
  ean = '1234567890123';
}

describe('ProductImageDirective', () => {
  let fixture: ComponentFixture<TestComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TestComponent],
      providers: [
        provideProductImageUrl('https://test-cdn.example.com')
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
  });

  it('should set src attribute with correct URL', () => {
    const img = fixture.nativeElement.querySelector('img');
    expect(img.src).toBe('https://test-cdn.example.com/1234567890123_300x300.jpg?showDummy=true');
  });
});

Testing the Service

import { TestBed } from '@angular/core/testing';
import { ProductImageService, provideProductImageUrl } from '@isa/shared/product-image';

describe('ProductImageService', () => {
  let service: ProductImageService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideProductImageUrl('https://cdn.test.com')
      ]
    });
    service = TestBed.inject(ProductImageService);
  });

  it('should generate URL with default dimensions', () => {
    const url = service.getImageUrl({ imageId: '1234567890123' });
    expect(url).toBe('https://cdn.test.com/1234567890123_150x150.jpg?showDummy=true');
  });

  it('should generate URL with custom dimensions', () => {
    const url = service.getImageUrl({
      imageId: '1234567890123',
      width: 500,
      height: 400,
      showDummy: false
    });
    expect(url).toBe('https://cdn.test.com/1234567890123_500x400.jpg?showDummy=false');
  });
});

Architecture Notes

Design Patterns

Service + Directive Pattern

The library uses a separation of concerns pattern:

  • Service: Pure URL generation logic, easily testable
  • Directive: Declarative UI integration, leverages Angular's host binding

This separation allows the service to be used independently for programmatic scenarios while the directive provides a simple template-based API.

Dependency Injection Strategy

Uses Angular's injection token pattern with a factory default:

export const PRODUCT_IMAGE_URL = new InjectionToken<string>('PRODUCT_IMAGE_URL', {
  factory: () => inject(Config).get('@cdn/product-image.url', z.string().url()),
});

This approach provides:

  • Default behavior: Automatic config integration
  • Override capability: Custom providers for testing
  • Type safety: Zod validation ensures valid URLs

Signal-Based Reactivity

The directive uses Angular signals for optimal change detection:

src = computed(() => {
  const ean = this.ean();
  const width = this.imageWidth();
  const height = this.imageHeight();
  return this.productImageService.getImageUrl({ imageId: ean, height, width });
});

Benefits:

  • Automatic reactivity when inputs change
  • Efficient change detection (OnPush-compatible)
  • No manual subscription management needed

Host Binding Pattern

The directive uses host property binding to set the src attribute:

@Directive({
  selector: 'img[sharedProductImage]',
  host: { '[src]': 'src()' }
})

This ensures the directive can only be applied to img elements and automatically manages the src attribute lifecycle.

Dependencies

Required Libraries

  • @angular/core - Angular framework
  • @isa/core/config - Application configuration service
  • zod - Schema validation for configuration

Path Alias

Import from: @isa/shared/product-image

Performance Considerations

CDN Optimization

  • Images are served from CDN with automatic caching
  • Dynamic sizing reduces bandwidth for thumbnails and previews
  • Query parameter allows CDN to cache different image variants

Change Detection

  • Uses OnPush-compatible signal-based reactivity
  • Computed values only recalculate when inputs change
  • No unnecessary re-renders

Best Practices

  1. Use appropriate dimensions: Don't request 600x600 images for 100px thumbnails
  2. Lazy loading: Combine with native loading="lazy" or custom intersection observer
  3. Alt text: Always provide meaningful alt text for accessibility
  4. Fallback handling: Set showDummy="false" when implementing custom error handling

License

Internal ISA Frontend library - not for external distribution.