- 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
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
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Supported Formats
- Architecture Notes
- Testing
- Dependencies
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-whatanddata-whichattributes - 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 displayProductFormatIconComponent: 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-regulartrue:.isa-text-body-2-bold
Host Bindings
class:["flex", "items-center", "gap-2"]- Flexbox layout with centered itemsdata-what:'product-format'- E2E testing identifierdata-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 sizedata-what:'product-format-icon'- E2E testing identifierdata-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:
- Dynamic Icon Size - Input for configurable icon sizes
- Custom Icon Groups - Support for additional icon sets
- Tooltip Integration - Optional tooltips with format descriptions
- Format Validation - Warning for unsupported format codes
- Accessibility - ARIA labels for screen readers
- RTL Support - Right-to-left language support
- 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 displayProductFormatIconComponent- 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.