# @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](#features)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
- [API Reference](#api-reference)
- [Usage Examples](#usage-examples)
- [Configuration](#configuration)
- [Testing](#testing)
- [Architecture Notes](#architecture-notes)

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

```typescript
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:

```typescript
@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:

```typescript
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

```typescript
<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:**
```typescript
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:**
```typescript
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:**
```typescript
// 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```typescript
// 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:

```typescript
// 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

```bash
# 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

```typescript
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

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
@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.
