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

27 KiB

@isa/shared/product-format

Angular components for displaying product format information with icons and formatted text, supporting various media types like hardcover, paperback, audio, and digital formats.

Overview

The Product Format library provides two complementary standalone components for displaying product format information. The ProductFormatComponent combines an icon with formatted text, while the ProductFormatIconComponent displays just the icon. Both components integrate with the @isa/icons library to render format-specific icons and support customizable styling through Tailwind CSS.

Table of Contents

Features

  • Combined format display - Icon + text in a single component
  • Icon-only display - Standalone icon component for compact layouts
  • Format-specific icons - Automatic icon selection from ProductFormatIconGroup
  • Flexible text styling - Regular or bold format detail text
  • Signal-based inputs - Modern Angular signal inputs for reactivity
  • Case normalization - Automatic lowercase conversion for icon names
  • Consistent sizing - Standardized 1.5rem (24px) icon size
  • ISA design system - Integration with ISA Tailwind color palette
  • E2E testing attributes - Built-in data-what and data-which attributes
  • OnPush change detection - Optimized performance
  • Standalone architecture - Modern Angular components with explicit imports

Quick Start

1. Display Format with Icon and Text

import { Component } from '@angular/core';
import { ProductFormatComponent } from '@isa/shared/product-format';

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <div class="product-info">
      <h3>{{ product.title }}</h3>

      <!-- Format with icon and text -->
      <shared-product-format
        [format]="product.format"
        [formatDetail]="product.formatDetail"
      />
    </div>
  `
})
export class ProductCardComponent {
  product = {
    title: 'The Great Gatsby',
    format: 'HC',  // Hardcover icon code
    formatDetail: 'Hardcover'
  };
}

2. Display Icon Only

import { Component } from '@angular/core';
import { ProductFormatIconComponent } from '@isa/shared/product-format';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductFormatIconComponent],
  template: `
    <div class="product-grid">
      @for (product of products; track product.id) {
        <div class="product-item">
          <!-- Icon only for compact display -->
          <shared-product-format-icon [format]="product.format" />
          <span>{{ product.title }}</span>
        </div>
      }
    </div>
  `
})
export class ProductListComponent {
  products = [
    { id: 1, format: 'HC', title: 'Book 1' },
    { id: 2, format: 'PB', title: 'Book 2' },
    { id: 3, format: 'EB', title: 'Book 3' }
  ];
}

3. Bold Format Detail

import { Component } from '@angular/core';
import { ProductFormatComponent } from '@isa/shared/product-format';

@Component({
  selector: 'app-product-highlight',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <shared-product-format
      [format]="'HC'"
      [formatDetail]="'Limited Edition Hardcover'"
      [formatDetailsBold]="true"
    />
  `
})
export class ProductHighlightComponent {}

Core Concepts

Format Codes

Product formats are identified by short codes (typically 2 characters) that map to specific icons:

// Common format codes
'HC'   Hardcover
'PB'   Paperback
'EB'   E-Book
'AB'   Audiobook
'CD'   CD/Audio
'DL'   Download

The format code is automatically converted to lowercase for icon lookup.

Icon Integration

The components integrate with @isa/icons ProductFormatIconGroup:

import { ProductFormatIconGroup } from '@isa/icons';
import { NgIcon, provideIcons } from '@ng-icons/core';

// Icons are automatically loaded from ProductFormatIconGroup
providers: [provideIcons(ProductFormatIconGroup)]

Component Structure

Two components serve different display needs:

  • ProductFormatComponent: Icon + text combination for detailed display
  • ProductFormatIconComponent: Icon only for compact layouts
// Full display
<shared-product-format format="HC" formatDetail="Hardcover" />
// Renders: [HC Icon] Hardcover

// Icon only
<shared-product-format-icon format="HC" />
// Renders: [HC Icon]

Styling

Both components use ISA design system colors and consistent sizing:

  • Icon size: 1.5rem (24px) - size="1.5rem"
  • Icon color: text-isa-neutral-900 (dark neutral)
  • Text color: text-isa-secondary-900 (dark secondary)
  • Layout: Flexbox with gap-2 (0.5rem spacing)

API Reference

ProductFormatComponent

Combined icon and text display component for product formats.

Selector: shared-product-format

Change Detection: OnPush

Standalone: true

Inputs

format (required)
  • Type: InputSignal<string>
  • Description: Format code (e.g., 'HC', 'PB', 'EB')
  • Usage: Determines which icon to display
formatDetail (required)
  • Type: InputSignal<string>
  • Description: Human-readable format description (e.g., 'Hardcover', 'Paperback')
  • Usage: Displayed as text next to the icon
formatDetailsBold (optional)
  • Type: InputSignal<boolean>
  • Default: false
  • Description: Whether to display format detail text in bold
  • Styling:
    • false: .isa-text-body-2-regular
    • true: .isa-text-body-2-bold

Host Bindings

  • class: ["flex", "items-center", "gap-2"] - Flexbox layout with centered items
  • data-what: 'product-format' - E2E testing identifier
  • data-which: 'shared-product-format' - Component type identifier

Template Structure

<shared-product-format-icon [format]="format()"></shared-product-format-icon>
<span
  [class.isa-text-body-2-regular]="!formatDetailsBold()"
  [class.isa-text-body-2-bold]="formatDetailsBold()"
  class="text-isa-secondary-900"
>
  {{ formatDetail() }}
</span>

Example

<shared-product-format
  [format]="'HC'"
  [formatDetail]="'Hardcover'"
  [formatDetailsBold]="false"
/>
// Renders: [HC Icon] Hardcover (regular weight)

<shared-product-format
  [format]="'PB'"
  [formatDetail]="'Paperback'"
  [formatDetailsBold]="true"
/>
// Renders: [PB Icon] Paperback (bold weight)

ProductFormatIconComponent

Icon-only display component for product formats.

Selector: shared-product-format-icon

Standalone: true

Inputs

format (required)
  • Type: InputSignal<string>
  • Description: Format code (e.g., 'HC', 'PB', 'EB')
  • Processing: Automatically converted to lowercase via formatLowerCase() computed signal

Computed Properties

formatLowerCase()
  • Type: Signal<string>
  • Returns: Lowercase version of the format input
  • Example: 'HC''hc', 'PB''pb'
  • Purpose: Icon names in ProductFormatIconGroup are lowercase

Host Bindings

  • class: ["inline-block","text-isa-neutral-900", "size-6"] - Inline block with neutral color and 24px size
  • data-what: 'product-format-icon' - E2E testing identifier
  • data-which: 'shared-product-format-icon' - Component type identifier

Template Structure

<ng-icon [name]="formatLowerCase()" size="1.5rem"></ng-icon>

Example

<shared-product-format-icon [format]="'HC'" />
// Renders: Hardcover icon (24x24px)

<shared-product-format-icon [format]="'EB'" />
// Renders: E-book icon (24x24px)

Usage Examples

Product Card with Format

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

interface Product {
  ean: string;
  title: string;
  author: string;
  format: string;
  formatDetail: string;
  price: number;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [ProductFormatComponent, ProductImageDirective],
  template: `
    <div class="product-card">
      <img
        sharedProductImage
        [ean]="product().ean"
        [imageWidth]="200"
        [imageHeight]="300"
        alt="{{ product().title }}"
      />

      <div class="product-info">
        <h3 class="isa-text-heading-4-bold">{{ product().title }}</h3>
        <p class="isa-text-body-2-regular">{{ product().author }}</p>

        <shared-product-format
          [format]="product().format"
          [formatDetail]="product().formatDetail"
        />

        <p class="price">{{ product().price | currency }}</p>
      </div>
    </div>
  `,
  styles: [`
    .product-card {
      display: flex;
      flex-direction: column;
      gap: 1rem;
      padding: 1rem;
      border: 1px solid var(--isa-neutral-300);
      border-radius: 0.5rem;
    }
  `]
})
export class ProductCardComponent {
  product = input.required<Product>();
}

Product List with Icon Only

import { Component } from '@angular/core';
import { ProductFormatIconComponent } from '@isa/shared/product-format';

@Component({
  selector: 'app-compact-product-list',
  standalone: true,
  imports: [ProductFormatIconComponent],
  template: `
    <table class="product-table">
      <thead>
        <tr>
          <th>Format</th>
          <th>Title</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>
        @for (product of products; track product.ean) {
          <tr>
            <td>
              <shared-product-format-icon [format]="product.format" />
            </td>
            <td>{{ product.title }}</td>
            <td>{{ product.price | currency }}</td>
          </tr>
        }
      </tbody>
    </table>
  `
})
export class CompactProductListComponent {
  products = [
    { ean: '1234567890123', format: 'HC', title: 'Book Title 1', price: 29.99 },
    { ean: '2345678901234', format: 'PB', title: 'Book Title 2', price: 14.99 },
    { ean: '3456789012345', format: 'EB', title: 'Book Title 3', price: 9.99 },
    { ean: '4567890123456', format: 'AB', title: 'Book Title 4', price: 19.99 }
  ];
}

Format Filter/Facets

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

interface FormatFilter {
  code: string;
  label: string;
  count: number;
}

@Component({
  selector: 'app-format-filter',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <div class="filter-section">
      <h3 class="isa-text-heading-4-bold">Filter by Format</h3>

      <div class="format-options">
        @for (format of availableFormats; track format.code) {
          <label class="format-option">
            <input
              type="checkbox"
              [value]="format.code"
              (change)="toggleFormat(format.code)"
            />

            <shared-product-format
              [format]="format.code"
              [formatDetail]="format.label + ' (' + format.count + ')'"
              [formatDetailsBold]="isSelected(format.code)"
            />
          </label>
        }
      </div>
    </div>
  `,
  styles: [`
    .format-option {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      padding: 0.5rem;
      cursor: pointer;
    }

    .format-option:hover {
      background-color: var(--isa-neutral-100);
    }
  `]
})
export class FormatFilterComponent {
  availableFormats: FormatFilter[] = [
    { code: 'HC', label: 'Hardcover', count: 125 },
    { code: 'PB', label: 'Paperback', count: 342 },
    { code: 'EB', label: 'E-Book', count: 218 },
    { code: 'AB', label: 'Audiobook', count: 89 }
  ];

  selectedFormats = signal<Set<string>>(new Set());

  toggleFormat(code: string): void {
    this.selectedFormats.update(formats => {
      const newFormats = new Set(formats);
      if (newFormats.has(code)) {
        newFormats.delete(code);
      } else {
        newFormats.add(code);
      }
      return newFormats;
    });
  }

  isSelected(code: string): boolean {
    return this.selectedFormats().has(code);
  }
}

Product Detail Page

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

interface ProductVariant {
  format: string;
  formatDetail: string;
  price: number;
  available: boolean;
}

@Component({
  selector: 'app-product-detail',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <div class="product-detail">
      <h2 class="isa-text-heading-2-bold">{{ productTitle }}</h2>

      <div class="format-selection">
        <h3 class="isa-text-heading-4-bold">Choose Format</h3>

        <div class="format-variants">
          @for (variant of variants; track variant.format) {
            <button
              class="variant-button"
              [class.selected]="selectedFormat() === variant.format"
              [disabled]="!variant.available"
              (click)="selectFormat(variant.format)"
            >
              <shared-product-format
                [format]="variant.format"
                [formatDetail]="variant.formatDetail"
                [formatDetailsBold]="selectedFormat() === variant.format"
              />

              <span class="variant-price">
                {{ variant.price | currency }}
              </span>

              @if (!variant.available) {
                <span class="unavailable">Out of Stock</span>
              }
            </button>
          }
        </div>
      </div>
    </div>
  `,
  styles: [`
    .variant-button {
      display: flex;
      align-items: center;
      justify-content: space-between;
      width: 100%;
      padding: 1rem;
      border: 2px solid var(--isa-neutral-300);
      border-radius: 0.5rem;
      cursor: pointer;
      transition: all 0.2s;
    }

    .variant-button:hover:not(:disabled) {
      border-color: var(--isa-accent-500);
    }

    .variant-button.selected {
      border-color: var(--isa-accent-500);
      background-color: var(--isa-accent-50);
    }

    .variant-button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
  `]
})
export class ProductDetailComponent {
  productTitle = 'The Great Gatsby';

  variants: ProductVariant[] = [
    { format: 'HC', formatDetail: 'Hardcover', price: 29.99, available: true },
    { format: 'PB', formatDetail: 'Paperback', price: 14.99, available: true },
    { format: 'EB', formatDetail: 'E-Book', price: 9.99, available: true },
    { format: 'AB', formatDetail: 'Audiobook', price: 19.99, available: false }
  ];

  selectedFormat = signal<string>('HC');

  selectFormat(format: string): void {
    this.selectedFormat.set(format);
  }
}

Shopping Cart Item

import { Component, input, output } from '@angular/core';
import { ProductFormatComponent } from '@isa/shared/product-format';

interface CartItem {
  id: number;
  title: string;
  format: string;
  formatDetail: string;
  quantity: number;
  price: number;
}

@Component({
  selector: 'app-cart-item',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <div class="cart-item">
      <div class="item-info">
        <h4 class="isa-text-body-1-bold">{{ item().title }}</h4>

        <shared-product-format
          [format]="item().format"
          [formatDetail]="item().formatDetail"
        />
      </div>

      <div class="item-controls">
        <div class="quantity-controls">
          <button (click)="decreaseQuantity()">-</button>
          <span>{{ item().quantity }}</span>
          <button (click)="increaseQuantity()">+</button>
        </div>

        <div class="item-price">
          {{ item().price * item().quantity | currency }}
        </div>

        <button (click)="remove()" class="remove-button">
          Remove
        </button>
      </div>
    </div>
  `
})
export class CartItemComponent {
  item = input.required<CartItem>();

  quantityChange = output<number>();
  removeItem = output<void>();

  increaseQuantity(): void {
    this.quantityChange.emit(this.item().quantity + 1);
  }

  decreaseQuantity(): void {
    if (this.item().quantity > 1) {
      this.quantityChange.emit(this.item().quantity - 1);
    }
  }

  remove(): void {
    this.removeItem.emit();
  }
}

Format Legend/Guide

import { Component } from '@angular/core';
import { ProductFormatComponent } from '@isa/shared/product-format';

@Component({
  selector: 'app-format-legend',
  standalone: true,
  imports: [ProductFormatComponent],
  template: `
    <div class="format-legend">
      <h3 class="isa-text-heading-4-bold">Format Guide</h3>

      <div class="legend-items">
        @for (format of formatLegend; track format.code) {
          <div class="legend-item">
            <shared-product-format
              [format]="format.code"
              [formatDetail]="format.label"
            />

            <p class="isa-text-body-2-regular text-isa-neutral-700">
              {{ format.description }}
            </p>
          </div>
        }
      </div>
    </div>
  `,
  styles: [`
    .legend-item {
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
      padding: 1rem;
      border-bottom: 1px solid var(--isa-neutral-200);
    }
  `]
})
export class FormatLegendComponent {
  formatLegend = [
    {
      code: 'HC',
      label: 'Hardcover',
      description: 'Durable hardbound book with rigid protective cover'
    },
    {
      code: 'PB',
      label: 'Paperback',
      description: 'Softcover book with flexible paper cover'
    },
    {
      code: 'EB',
      label: 'E-Book',
      description: 'Digital book format for e-readers and tablets'
    },
    {
      code: 'AB',
      label: 'Audiobook',
      description: 'Audio recording of book read aloud'
    }
  ];
}

Supported Formats

Common Media Formats

The library supports various product format codes through the ProductFormatIconGroup:

Code Format Description
HC Hardcover Hardbound books with rigid covers
PB Paperback Softcover books with flexible covers
EB E-Book Digital book formats
AB Audiobook Audio recordings of books
CD CD/Audio Physical audio discs
DL Download Digital download products
BR Blu-ray High-definition video discs
DVD DVD Standard definition video discs

Icon Naming Convention

All format codes are automatically converted to lowercase for icon lookup:

format: 'HC'    icon name: 'hc'
format: 'PB'    icon name: 'pb'
format: 'EB'    icon name: 'eb'

This ensures consistent icon resolution regardless of input casing.

Architecture Notes

Design Patterns

Composition Pattern

ProductFormatComponent composes ProductFormatIconComponent:

@Component({
  template: `
    <shared-product-format-icon [format]="format()"></shared-product-format-icon>
    <span>{{ formatDetail() }}</span>
  `,
  imports: [ProductFormatIconComponent]
})
export class ProductFormatComponent {}

Benefits:

  • Code reuse - icon component used independently or within format component
  • Single responsibility - each component has clear purpose
  • Flexibility - consumers choose icon-only or icon+text display

Computed Signal for Case Normalization

Icon component uses computed signal for case conversion:

formatLowerCase = computed(() => this.format()?.toLowerCase());

Benefits:

  • Automatic reactivity when format input changes
  • Efficient - only recalculates when format changes
  • OnPush compatible
  • No manual string manipulation in template

Host Class Binding

Both components use host bindings for consistent styling:

// ProductFormatComponent
host: {
  '[class]': '["flex", "items-center", "gap-2"]',
  'data-what': 'product-format',
  'data-which': 'shared-product-format'
}

// ProductFormatIconComponent
host: {
  '[class]': '["inline-block","text-isa-neutral-900", "size-6"]',
  'data-what': 'product-format-icon',
  'data-which': 'shared-product-format-icon'
}

Benefits:

  • Consistent styling without consumer configuration
  • E2E testing attributes built-in
  • Encapsulated component behavior

OnPush Change Detection

ProductFormatComponent uses OnPush strategy:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})

Benefits:

  • Improved performance
  • Works seamlessly with signal inputs
  • Reduces unnecessary change detection cycles

Icon Library Integration

The library integrates with @ng-icons/core and @isa/icons:

import { NgIcon, provideIcons } from '@ng-icons/core';
import { ProductFormatIconGroup } from '@isa/icons';

providers: [provideIcons(ProductFormatIconGroup)]

Benefits:

  • Centralized icon management
  • Consistent icon rendering
  • Automatic tree-shaking of unused icons

Future Enhancements

Potential improvements identified:

  1. Dynamic Icon Size - Input for configurable icon sizes
  2. Custom Icon Groups - Support for additional icon sets
  3. Tooltip Integration - Optional tooltips with format descriptions
  4. Format Validation - Warning for unsupported format codes
  5. Accessibility - ARIA labels for screen readers
  6. RTL Support - Right-to-left language support
  7. Icon Color Variants - Theme-based color customization

Testing

The library uses Jest with Spectator for testing.

Running Tests

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

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

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

Test Structure

The library includes unit tests covering:

  • Component creation - Tests component initialization
  • Input binding - Tests format and formatDetail inputs
  • Icon rendering - Tests ProductFormatIconComponent integration
  • Text rendering - Tests formatDetail display
  • E2E attributes - Tests data-what and data-which attributes
  • Case normalization - Tests lowercase conversion

Example Test

import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ProductFormatComponent } from './product-format.component';
import { ProductFormatIconComponent } from './product-format-icon.component';

describe('ProductFormatComponent', () => {
  let spectator: Spectator<ProductFormatComponent>;
  const createComponent = createComponentFactory({
    component: ProductFormatComponent,
    imports: [ProductFormatIconComponent]
  });

  beforeEach(() => {
    spectator = createComponent({
      props: {
        format: 'HC',
        formatDetail: 'Hardcover'
      }
    });
  });

  it('should create', () => {
    expect(spectator.component).toBeTruthy();
  });

  it('should render ProductFormatIconComponent with correct format', () => {
    const icon = spectator.query(ProductFormatIconComponent);
    expect(icon).toBeTruthy();
    expect(icon?.format()).toBe('HC');
  });

  it('should render the formatDetail text', () => {
    expect(spectator.query('span')).toHaveText('Hardcover');
  });

  it('should set correct data attributes on host', () => {
    const host = spectator.element;
    expect(host).toHaveAttribute('data-what', 'product-format');
    expect(host).toHaveAttribute('data-which', 'shared-product-format');
  });
});

Dependencies

Required Libraries

  • @angular/core - Angular framework for components and signals
  • @ng-icons/core - Icon rendering engine
  • @isa/icons - ISA icon library with ProductFormatIconGroup

Path Alias

Import from: @isa/shared/product-format

Exports

  • ProductFormatComponent - Combined icon and text display
  • ProductFormatIconComponent - Icon-only display

Best Practices

1. Choose Appropriate Component

Use the right component for your layout:

// Good - Combined component for detailed displays
<shared-product-format format="HC" formatDetail="Hardcover" />

// Good - Icon component for compact layouts
<shared-product-format-icon format="HC" />

// Avoid - Using combined component when space is limited
<div class="compact-list">
  <shared-product-format format="HC" formatDetail="Hardcover" /> <!-- Too verbose -->
</div>

2. Consistent Format Codes

Use consistent format codes across your application:

// Good - Consistent codes
const FORMATS = {
  HARDCOVER: 'HC',
  PAPERBACK: 'PB',
  EBOOK: 'EB'
} as const;

// Avoid - Inconsistent codes
format: 'hardcover'  // Wrong - should be 'HC'
format: 'hc'         // Works but inconsistent with uppercase convention

3. Provide Meaningful Format Details

Use clear, user-friendly format descriptions:

// Good - Clear descriptions
<shared-product-format format="HC" formatDetail="Hardcover" />
<shared-product-format format="AB" formatDetail="Audiobook (MP3)" />

// Avoid - Technical or unclear descriptions
<shared-product-format format="HC" formatDetail="HC Format" /> <!-- Redundant -->
<shared-product-format format="AB" formatDetail="Audio" /> <!-- Too vague -->

4. Include E2E Attributes

Add test attributes to parent containers for context:

<div class="product-info" data-what="product-format-section">
  <shared-product-format
    [format]="product.format"
    [formatDetail]="product.formatDetail"
  />
</div>

5. Handle Missing Format Gracefully

Provide fallbacks for unknown formats:

@Component({
  template: `
    @if (hasValidFormat()) {
      <shared-product-format
        [format]="product().format"
        [formatDetail]="product().formatDetail"
      />
    } @else {
      <span class="unknown-format">Format not available</span>
    }
  `
})
export class ProductComponent {
  product = input.required<Product>();

  hasValidFormat(): boolean {
    const format = this.product().format;
    return !!format && this.product().formatDetail !== undefined;
  }
}

Performance Considerations

OnPush Change Detection

The component uses OnPush strategy for optimal performance:

  • Only re-renders when inputs change
  • Minimal change detection overhead
  • Works seamlessly with signals

Icon Loading

Icons are loaded from ProductFormatIconGroup:

  • Tree-shakeable - unused icons not included in bundle
  • Lazy-loaded via ng-icons
  • Cached after first render

Signal-Based Reactivity

Uses computed signals for efficient updates:

  • Only recalculates when format input changes
  • No manual subscription management
  • Automatic cleanup

License

Internal ISA Frontend library - not for external distribution.