Files
Nino Righi 6e55b7b0da Merged PR 2070: fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC...
fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC View, Adjusted Controls Panel Filter Styling and Layout to fix mobile issues and added spacing to order-by-toolbar

Refs: #5514, #5475
2025-12-05 20:04:07 +00:00
..

@isa/checkout/feature/reward-catalog

A comprehensive loyalty rewards catalog feature for Angular applications supporting reward item browsing, selection, and checkout for customers with bonus cards.

Overview

The Reward Catalog feature library provides a complete user interface for browsing and selecting loyalty reward items. It integrates customer bonus card information, manages reward item selection state, handles filtering and pagination, and orchestrates the checkout flow for reward redemption. The library supports both customer-selected and guest workflows, with automatic navigation to customer selection when needed.

Table of Contents

Features

  • Customer Context Management - Displays customer bonus card information and available points
  • Reward Item Browsing - Searchable, filterable catalog of loyalty reward items
  • Multi-Select Interface - Checkbox-based selection of multiple reward items
  • Infinite Scroll Pagination - Automatic loading of additional items on scroll
  • Customer Selection Flow - Automatic navigation to customer search for guest users
  • Shopping Cart Integration - Creates and manages reward-specific shopping carts
  • Purchase Options Modal - Launches purchase options dialog with redemption point calculation
  • Responsive Design - Tablet and desktop layouts with breakpoint-aware rendering
  • Filter Persistence - Query parameter synchronization for filter state
  • Empty State Handling - User-friendly messages and reset actions when no results found
  • Loading States - Skeleton loaders and spinners for async operations
  • Tab Context Integration - Reward context tracking for multi-tab workflows

Quick Start

1. Route Configuration

import { Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'reward',
    loadChildren: () =>
      import('@isa/checkout/feature/reward-catalog').then(m => m.routes)
  }
];

2. Basic Usage

The library is designed to be lazy-loaded as a route module:

// In your app routing configuration
{
  path: 'kunde/:tabId/reward',
  loadChildren: () =>
    import('@isa/checkout/feature/reward-catalog').then(m => m.routes),
  data: {
    scrollPositionRestoration: true  // Automatically handled
  }
}

3. Navigation to Reward Catalog

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-feature',
  template: '...'
})
export class FeatureComponent {
  #router = inject(Router);
  tabId = 123;

  navigateToRewardCatalog(): void {
    this.#router.navigate(['/kunde', this.tabId, 'reward']);
  }
}

Core Concepts

Customer Workflows

The library supports two primary user workflows:

1. Guest Workflow (No Customer Selected)

When no customer is selected, the header displays a RewardStartCard:

// User sees welcome message
// CTA button: "Kund*in auswählen" (Select Customer)
// Clicking navigates to customer search filtered for loyalty customers

2. Customer Selected Workflow

When a customer with a bonus card is selected, the header displays a RewardCustomerCard:

// Displays: Customer name, available Lesepunkte (reading points)
// Shows: Number of rewards selected in shopping cart
// Actions:
//   - "Zurücksetzen" (Reset) - Clears customer and cart
//   - "Prämienausgabe" (Reward Checkout) - Navigates to checkout (when items selected)

Reward Item Selection

Items are selected using checkboxes with state managed by RewardCatalogStore:

// Selection flow:
1. User clicks checkbox on reward item
2. Item added to RewardCatalogStore.selectedItems
3. Selection state persists in session storage
4. "Prämie auswählen" button becomes enabled
5. User clicks button to open purchase options modal
6. Items added to reward shopping cart
7. Selection state cleared on successful addition

Search and Filtering

The library integrates with @isa/shared/filter for comprehensive filtering:

// Filter capabilities:
- Text search across item names and descriptions
- Category filtering
- Point value filtering
- Sorting options (by points, name, etc.)
- Filter state synced to URL query parameters
- Filter persistence across page reloads

Pagination Strategy

Infinite scroll pagination using intersection observer:

// Pagination flow:
1. Initial load fetches first page (e.g., 20 items)
2. User scrolls to bottom
3. InViewportDirective detects trigger element
4. searchTrigger set to 'reload'
5. Next page fetched and appended to existing items
6. Process repeats until all items loaded (itemsLength === hits)

Component API Reference

RewardCatalogComponent

Main container component that orchestrates the entire reward catalog feature.

Selector: reward-catalog

Providers:

  • SelectedRewardShoppingCartResource - Resource for fetching reward shopping cart
  • SelectedCustomerBonusCardsResource - Resource for fetching customer bonus cards
  • provideFilter() - Filter service configuration with query settings and URL sync

Methods:

search(trigger: SearchTrigger): void

Triggers a search operation with the specified trigger type.

Parameters:

  • trigger: 'scan' | 'input' | 'filter' | 'orderBy' - The search trigger type

Example:

// Called by filter-controls-panel
search('filter');  // User changed a filter
search('input');   // User typed in search box

initRewardContext(): void

Initializes the reward context in tab metadata. Called automatically in constructor.

Purpose: Marks the current tab as being in "reward" context, which is used by customer search to know the user's intent.

Example:

// Automatically called on component initialization
// Sets: TabMetadata.context = 'reward'
// Used by customer search to customize behavior

Host Classes:

/* Applied classes */
w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden

RewardHeaderComponent

Displays customer information or guest welcome message based on customer selection state.

Selector: reward-header

Computed Signals:

bonusCards: Signal<BonusCard[]>

All bonus cards for the selected customer.

primaryBonusCard: Signal<BonusCard | undefined>

The primary bonus card (used for point balance and customer name).

Template Logic:

// Conditional rendering based on customer state
@if (!cardFetching) {
  @if (card) {
    <reward-customer-card></reward-customer-card>  // Customer selected
  } @else {
    <reward-start-card></reward-start-card>        // No customer
  }
} @else {
  // Loading skeleton
}

RewardCustomerCardComponent

Displays selected customer information, available points, and checkout options.

Selector: reward-customer-card

Computed Signals:

bonusCards: Signal<BonusCard[]>

Array of bonus cards for the customer.

primaryBonusCard: Signal<BonusCard | undefined>

The primary bonus card with customer details.

primaryBonusCardPoints: Signal<number>

Available reading points (Lesepunkte) for redemption.

Default: 0

cartItemsLength: Signal<number>

Number of items currently in the reward shopping cart.

Methods:

resetCustomerAndCart(): void

Clears the selected customer and reward shopping cart from tab metadata.

Effects:

  • Removes customer selection
  • Clears reward shopping cart ID
  • Header switches to RewardStartCard
  • User must re-select customer

Example:

// Called when user clicks "Zurücksetzen" button
resetCustomerAndCart();
// Customer: undefined
// Shopping cart: undefined
// Header: Shows "Kund*in auswählen" again

Host Classes:

h-[9.5rem] desktop:h-32 w-full flex flex-row justify-between rounded-2xl bg-isa-neutral-400 p-6

Template Features:

  • Displays customer name from primaryBonusCard.firstName and lastName
  • Shows available points with bold styling
  • Reset button (text button, subtle color, small size)
  • Selected rewards count with skeleton loader during fetch
  • "Prämienausgabe" CTA button (only shown when items selected and points > 0)

RewardStartCardComponent

Welcome card shown when no customer is selected, prompting customer selection.

Selector: reward-start-card

Computed Signals:

route: Signal<{ path: any[]; queryParams: any }>

Navigation route configuration for customer search.

Example value:

{
  path: ['/kunde', 123, 'customer', {
    outlets: { primary: 'search', side: 'search-customer-main' }
  }],
  queryParams: {
    filter_customertype: 'webshop&loyalty;loyalty&!webshop'
  }
}

Host Classes:

h-[9.5rem] desktop:h-32 w-full grid grid-cols-[1fr,auto] gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between

Template Features:

  • Title: "Prämienshop" (subtitle-1-regular)
  • Description: Instructions for using the catalog
  • CTA Button: "Kund*in auswählen" (Select Customer)
    • Color: tertiary
    • Size: large
    • Links to customer search with loyalty filter

RewardListComponent

Displays the paginated list of reward items with infinite scroll and loading states.

Selector: reward-list

Inputs:

searchTrigger: ModelSignal<SearchTrigger | 'reload' | 'initial'>

Two-way binding signal that triggers searches and pagination.

Type: 'scan' | 'input' | 'filter' | 'orderBy' | 'reload' | 'initial'

Default: 'initial'

Example:

<reward-list
  [(searchTrigger)]="searchTrigger"
></reward-list>

Computed Signals:

listFetching: Signal<boolean>

Whether the catalog resource is currently loading data.

items: Signal<Item[]>

Array of reward items to display.

itemsLength: Signal<number>

Number of items currently loaded.

hits: Signal<number>

Total number of items matching current filters.

renderSearchLoader: Signal<boolean>

Whether to show the initial search loader (when fetching with no items).

renderPageTrigger: Signal<boolean>

Whether to render the pagination trigger element (when more items exist and not currently loading).

Methods:

paging(inViewport: boolean): void

Handles pagination when the trigger element comes into viewport.

Parameters:

  • inViewport: boolean - Whether the trigger element is visible

Behavior:

// Called by (uiInViewport) directive
if (inViewport && itemsLength < hits) {
  searchTrigger.set('reload');  // Triggers next page load
}

resetFilter(): void

Resets all filters to default values and triggers a new search.

Example:

// Called when user clicks "Filter zurücksetzen" in empty state
resetFilter();
// All filters cleared
// Query params updated
// New search triggered

Host Classes:

w-full flex flex-col gap-4

Template Features:

  • Result count display: {{ hits() }} Einträge
  • Item list with deferred rendering (viewport-based lazy loading)
  • Horizontal dividers between items on desktop
  • Loading spinner during pagination
  • Viewport trigger for infinite scroll
  • Empty state with reset button when no results

RewardListItemComponent

Individual reward item row with product information and selection checkbox.

Selector: reward-list-item

Inputs:

item: InputSignal<Item>

The reward item to display.

Required: Yes

Type: Item from @isa/catalogue/data-access

Example:

<reward-list-item
  [item]="rewardItem"
></reward-list-item>

Computed Signals:

desktopBreakpoint: Signal<boolean>

Whether the current viewport matches desktop breakpoints (Desktop, DesktopL, or DesktopXL).

productInfoOrientation: Signal<'horizontal' | 'vertical'>

Product info layout orientation based on viewport size.

Returns: 'horizontal' on desktop, 'vertical' on tablet/mobile

Template Structure:

<ui-client-row>
  <ui-client-row-content>
    <checkout-product-info-redemption
      [item]="item"
      [orientation]="horizontal|vertical"
    />
  </ui-client-row-content>

  <ui-item-row-data>
    <checkout-stock-info [item]="item" />
  </ui-item-row-data>

  <ui-item-row-data>
    <reward-list-item-select [item]="item" />
  </ui-item-row-data>
</ui-client-row>

E2E Attributes:

  • data-what="reward-list-item"
  • [attr.data-which]="item.id"

RewardListItemSelectComponent

Checkbox component for selecting/deselecting a reward item.

Selector: reward-list-item-select

Inputs:

item: InputSignal<Item>

The reward item associated with this selection control.

Required: Yes

Computed Signals:

itemSelected: Signal<boolean>

Whether the current item is selected in the store.

Methods:

setSelected(selected: boolean): void

Updates the selection state in the RewardCatalogStore.

Parameters:

  • selected: boolean - New selection state

Behavior:

if (selected) {
  store.selectItem(itemId, item);  // Add to selection
} else {
  store.removeItem(itemId);        // Remove from selection
}

Example:

<ui-checkbox appearance="bullet">
  <input
    type="checkbox"
    [ngModel]="itemSelected()"
    (ngModelChange)="setSelected($event)"
    data-what="reward-item-selection-checkbox"
    [attr.data-which]="item.product.ean"
  />
</ui-checkbox>

RewardActionComponent

Fixed bottom action bar with the "Prämie auswählen" (Select Reward) button.

Selector: reward-action

Computed Signals:

bonusCards: Signal<BonusCard[]>

Customer's bonus cards.

primaryBonusCard: Signal<BonusCard | undefined>

Primary bonus card with customer details.

primaryBonusCardPoints: Signal<number>

Available reading points for redemption.

selectedItems: Signal<Record<number, Item>>

Dictionary of selected items keyed by item ID.

hasSelectedItems: Signal<boolean>

Whether any items are currently selected.

Methods:

async continueToPurchasingOptions(): Promise<void>

Opens the purchase options modal with selected items.

Workflow:

  1. Retrieves selected items from store
  2. Gets or creates reward shopping cart ID
  3. Opens purchase options modal with redemption points enabled
  4. Waits for modal result
  5. Clears selected items on success
  6. Navigates to customer search if no customer and user didn't choose "continue-shopping"

Example:

await continueToPurchasingOptions();
// Modal opens with selected items
// User configures purchase options
// On confirm: items added to cart, selections cleared
// On cancel: nothing happens, selections preserved
// If no customer: navigates to customer search

Button Disabled Conditions:

!hasSelectedItems() ||                          // No items selected
(!!primaryBonusCard() && primaryBonusCardPoints() <= 0)  // Customer has 0 points

Usage Examples

Navigating to Reward Catalog

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';

@Component({
  selector: 'app-navigation-example',
  template: `
    <button (click)="openRewardCatalog()">
      Browse Rewards
    </button>
  `
})
export class NavigationExampleComponent {
  #router = inject(Router);
  tabId = injectTabId();

  async openRewardCatalog(): Promise<void> {
    await this.#router.navigate(['/kunde', this.tabId(), 'reward']);
  }
}
// Direct navigation with filter and search parameters
async navigateWithFilters(): Promise<void> {
  await this.#router.navigate(
    ['/kunde', this.tabId(), 'reward'],
    {
      queryParams: {
        search: 'book',
        filter_category: 'literature',
        orderBy: 'points_asc'
      }
    }
  );
}

Programmatic Customer Selection

import { Component, inject } from '@angular/core';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';

@Component({
  selector: 'app-customer-selection-example',
  template: '...'
})
export class CustomerSelectionExampleComponent {
  #crmMetadata = inject(CrmTabMetadataService);
  tabId = injectTabId();

  selectCustomerForRewards(customerId: number): void {
    // Set customer in tab metadata
    this.#crmMetadata.setSelectedCustomerId(this.tabId()!, customerId);

    // Navigate to reward catalog
    this.#router.navigate(['/kunde', this.tabId(), 'reward']);

    // Catalog will automatically load customer's bonus cards
  }
}

Resetting Reward Session

import { Component, inject } from '@angular/core';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';

@Component({
  selector: 'app-reset-example',
  template: '...'
})
export class ResetExampleComponent {
  #crmMetadata = inject(CrmTabMetadataService);
  #checkoutMetadata = inject(CheckoutMetadataService);
  tabId = injectTabId();

  resetRewardSession(): void {
    const tabId = this.tabId()!;

    // Clear customer selection
    this.#crmMetadata.setSelectedCustomerId(tabId, undefined);

    // Clear reward shopping cart
    this.#checkoutMetadata.setRewardShoppingCartId(tabId, undefined);

    // User returns to guest workflow with RewardStartCard
  }
}

Checking Customer Points Before Navigation

import { Component, inject, signal } from '@angular/core';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
import { getPrimaryBonusCard } from '@isa/crm/data-access';
import { computed } from '@angular/core';

@Component({
  selector: 'app-points-check-example',
  template: `
    @if (hasPoints()) {
      <button (click)="navigateToRewards()">
        Browse Rewards ({{ availablePoints() }} points)
      </button>
    } @else {
      <p>Customer has no points available</p>
    }
  `
})
export class PointsCheckExampleComponent {
  #bonusCardsResource = inject(SelectedCustomerBonusCardsResource).resource;

  bonusCards = computed(() =>
    this.#bonusCardsResource.value() ?? []
  );

  primaryCard = computed(() =>
    getPrimaryBonusCard(this.bonusCards())
  );

  availablePoints = computed(() =>
    this.primaryCard()?.totalPoints ?? 0
  );

  hasPoints = computed(() =>
    this.availablePoints() > 0
  );

  navigateToRewards(): void {
    if (this.hasPoints()) {
      this.#router.navigate(['/kunde', this.tabId(), 'reward']);
    }
  }
}

Monitoring Shopping Cart State

import { Component, inject, computed, effect } from '@angular/core';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';

@Component({
  selector: 'app-cart-monitor-example',
  template: `
    <div class="cart-summary">
      <span>Rewards in cart: {{ cartItemCount() }}</span>
      <span>Total points: {{ totalPoints() }}</span>
    </div>
  `
})
export class CartMonitorExampleComponent {
  #cartResource = inject(SelectedRewardShoppingCartResource).resource;

  cart = computed(() => this.#cartResource.value());

  cartItemCount = computed(() =>
    this.cart()?.items?.length ?? 0
  );

  totalPoints = computed(() => {
    const items = this.cart()?.items ?? [];
    return items.reduce((sum, item) =>
      sum + (item.product?.loyaltyPoints ?? 0), 0
    );
  });

  constructor() {
    effect(() => {
      console.log('Cart updated:', this.cart());
    });
  }
}

Routing and Navigation

Route Structure

The library exports a single route configuration:

// libs/checkout/feature/reward-catalog/src/lib/routes.ts
export const routes: Routes = [
  {
    path: '',
    component: RewardCatalogComponent,
    resolve: {
      querySettings: querySettingsResolverFn  // Fetches filter configuration
    },
    data: {
      scrollPositionRestoration: true         // Enables scroll position restore
    },
  },
];

Integration in App Routing

// apps/isa-app/src/app/app-routing.module.ts
const routes: Routes = [
  {
    path: 'kunde/:tabId/reward',
    loadChildren: () =>
      import('@isa/checkout/feature/reward-catalog')
        .then(m => m.routes),
  },
  // ... other routes
];

Query Settings Resolver

The querySettingsResolverFn fetches filter configuration before component initialization:

// What it does:
1. Calls CatalogueSearchService.fetchLoyaltyQuerySettings()
2. Returns QuerySettings with:
   - Available filter options
   - Sort options
   - Default values
   - Filter metadata
3. Makes data available via ActivatedRoute.snapshot.data['querySettings']

Usage in Component:

function querySettingsFactory() {
  return inject(ActivatedRoute).snapshot.data['querySettings'];
}

// In providers array
provideFilter(
  withQuerySettingsFactory(querySettingsFactory),
  withQueryParamsSync()
)

Customer Search Navigation

Helper function for navigating to customer search from reward context:

// libs/checkout/feature/reward-catalog/src/lib/helpers/get-route-to-customer.helper.ts
export const getRouteToCustomer = (tabId: number | null) => {
  return {
    path: [
      '/kunde',
      tabId,
      'customer',
      { outlets: { primary: 'search', side: 'search-customer-main' } }
    ].filter(Boolean),
    queryParams: {
      filter_customertype: 'webshop&loyalty;loyalty&!webshop'
    }
  };
};

Purpose:

  • Navigates to customer search with loyalty customer filter pre-applied
  • Uses auxiliary routes for search UI
  • Filters out customers without loyalty cards

Scroll Position Restoration

The library uses @isa/utils/scroll-position for automatic scroll restoration:

// In RewardCatalogComponent
restoreScrollPosition = injectRestoreScrollPosition();

// Automatically:
1. Saves scroll position on navigation away
2. Restores position on return
3. Works with infinite scroll pagination
4. Respects route data configuration

Filter Service Integration

The library uses @isa/shared/filter with advanced configuration:

provideFilter(
  withQuerySettingsFactory(querySettingsFactory),  // Pre-loads filter config
  withQueryParamsSync()                            // Syncs to URL query params
)

Filter Capabilities

// URL: ?search=book
// Searches across:
- Item names
- Product descriptions
- EAN/ISBN codes

2. Category Filtering

// URL: ?filter_category=literature
// Filters by product category

3. Point Range Filtering

// URL: ?filter_points_min=100&filter_points_max=500
// Filters items by redemption point range

4. Sorting

// URL: ?orderBy=points_asc
// Available options:
- points_asc: Points ascending
- points_desc: Points descending
- name_asc: Name A-Z
- name_desc: Name Z-A

Search Triggers

The library supports multiple search trigger types:

type SearchTrigger = 'scan' | 'input' | 'filter' | 'orderBy';

// Additional internal triggers:
'reload'   // Pagination (append to existing results)
'initial'  // First load (check session storage first - currently disabled)

Trigger Flow:

// User action → FilterControlsPanel emits triggerSearch
FilterControlsPanel  search(trigger)  RewardCatalogComponent
                                      
                        searchTrigger.set(trigger)
                                      
                        filterService.commit()
                                      
                        RewardListComponent detects change
                                      
                        rewardCatalogResource reloads
                                      
                        New items fetched and displayed

Filter State Management

Query Parameter Synchronization

// Filter state automatically synced to URL
// Example URL with filters:
/kunde/123/reward?search=book&filter_category=literature&orderBy=points_asc

// Benefits:
1. Shareable URLs with filter state
2. Browser back/forward support
3. Bookmark-friendly
4. Deep linking support

Filter Reset

// Reset all filters to default
resetFilter() {
  this.#filterService.reset({ commit: true });
}

// Effects:
1. All filter values cleared
2. Search text cleared
3. Sort order reset to default
4. Query params updated
5. New search triggered
6. Items list refreshed

Empty State Handling

When no items match the current filters:

<ui-empty-state
  title="Keine Suchergebnisse"
  description="Überprüfen Sie die Lesepunkte, Filter und Suchanfrage."
>
  <button uiButton (click)="resetFilter()">
    Filter zurücksetzen
  </button>
</ui-empty-state>

State Management

RewardCatalogStore

The library uses RewardCatalogStore from @isa/checkout/data-access for state management.

Store Schema:

interface RewardCatalogState {
  items: Item[];              // Current catalog items
  hits: number;               // Total items matching filters
  selectedItems: Record<number, Item>;  // Selected items by ID
}

Store Methods:

setItems(items: Item[], hits: number): void

Replaces all items (used for new searches).

// Called when searchTrigger !== 'reload'
store.setItems(newItems, totalHits);

updateItems(items: Item[], hits: number): void

Appends items to existing list (used for pagination).

// Called when searchTrigger === 'reload'
store.updateItems(nextPageItems, totalHits);

selectItem(itemId: number, item: Item): void

Adds an item to the selection.

store.selectItem(123, rewardItem);
// selectedItems: { 123: rewardItem }

removeItem(itemId: number): void

Removes an item from the selection.

store.removeItem(123);
// selectedItems: {}

clearSelectedItems(): void

Clears all selected items.

// Called after successful purchase options
store.clearSelectedItems();
// selectedItems: {}

clearState(): void

Clears all catalog items and hits (preserves selectedItems).

// Called when starting a new search
store.clearState();
// items: [], hits: 0

Session Storage Persistence

The store includes session storage integration (currently disabled):

// TODO: Re-enable when storage configuration issue resolved (#5350, #5353)
// await rewardCatalogStore.loadFromStorage();
// if (rewardCatalogStore.items().length > 0) {
//   return;  // Use cached items
// }

Intended Behavior:

  • Selected items persist across page reloads
  • Catalog items cached in session storage
  • Fast initial render from cache
  • Background refresh ensures data freshness

Shopping Cart Resource

SelectedRewardShoppingCartResource manages the reward shopping cart:

// Provided at RewardCatalogComponent level
// Automatically fetches cart based on CheckoutMetadataService.rewardShoppingCartId
// Updates reactive signals when cart changes

Resource Signals:

resource.value()      // Shopping cart data
resource.isLoading()  // Loading state
resource.error()      // Error state

Customer Bonus Cards Resource

SelectedCustomerBonusCardsResource manages customer bonus card data:

// Provided at RewardCatalogComponent level
// Automatically fetches based on CrmTabMetadataService.selectedCustomerId
// Returns all bonus cards for the customer

Resource Signals:

resource.value()      // BonusCard[]
resource.isLoading()  // Loading state
resource.error()      // Error state

Architecture Notes

Component Hierarchy

RewardCatalogComponent (root, route component)
├── RewardHeaderComponent
│   ├── RewardStartCardComponent (guest workflow)
│   └── RewardCustomerCardComponent (customer workflow)
├── FilterControlsPanelComponent (from @isa/shared/filter)
├── RewardListComponent
│   └── RewardListItemComponent (repeated)
│       ├── ProductInfoRedemptionComponent
│       ├── StockInfoComponent
│       └── RewardListItemSelectComponent
└── RewardActionComponent

Data Flow

1. Route Resolved
   ↓
2. querySettingsResolverFn fetches filter config
   ↓
3. RewardCatalogComponent initializes
   ↓
4. Filter service initialized with query settings
   ↓
5. Resources initialize (bonus cards, shopping cart)
   ↓
6. RewardListComponent creates catalog resource
   ↓
7. Catalog resource fetches items
   ↓
8. Items stored in RewardCatalogStore
   ↓
9. UI updates reactively

Resource Pattern

The library uses Angular's resource() function for data fetching:

export const createRewardCatalogResource = (params: () => {
  queryToken: QueryTokenInput;
  searchTrigger: SearchTrigger | 'reload' | 'initial';
}) => {
  return resource({
    params,
    loader: async ({ abortSignal, params }) => {
      // Fetch logic with automatic abort signal handling
      // Params changes trigger automatic reload
      // Loading states managed automatically
    }
  });
};

Benefits:

  • Automatic cancellation of in-flight requests
  • Loading state management
  • Error handling
  • Reactive param tracking
  • AbortSignal integration

Tab Context Pattern

The library uses tab metadata to track reward context:

// Set in constructor
this.#tabService.patchTabMetadata(tabId, { context: 'reward' });

// Read in other components
const metadata = this.#tabService.getTabMetadata(tabId);
if (metadata.context === 'reward') {
  // Customize behavior for reward flow
}

Purpose:

  • Customer search knows user came from reward catalog
  • Can customize customer search filters
  • Enables "return to rewards" navigation
  • Tracks user intent across multi-step flows

Responsive Design Strategy

The library uses breakpoint service for responsive layouts:

import { breakpoint, Breakpoint } from '@isa/ui/layout';

desktopBreakpoint = breakpoint([
  Breakpoint.Desktop,
  Breakpoint.DesktopL,
  Breakpoint.DesktopXL
]);

productInfoOrientation = linkedSignal(() => {
  return this.desktopBreakpoint() ? 'horizontal' : 'vertical';
});

Breakpoint Usage:

  • Product info switches orientation (horizontal/vertical)
  • Row layouts change (grid on desktop, flex on tablet)
  • Component spacing adjusts
  • Font sizes and padding responsive

Known Architectural Considerations

1. Session Storage Integration (High Priority)

Current State:

  • Session storage loading disabled due to configuration timing issue
  • Related to issues #5350, #5353
  • Store loads items too late, resource triggers before items available

Impact:

  • Initial page load always fetches from server
  • No cache benefits
  • Slower perceived performance

Proposed Solution:

  • Adjust storage loading timing
  • Ensure store hydration before resource initialization
  • Add configuration support for custom storage keys

2. Infinite Scroll Performance (Medium Priority)

Current State:

  • Uses @defer (on viewport) for item rendering
  • Intersection observer for pagination trigger
  • All items rendered in single list

Potential Issues:

  • Large lists (>1000 items) may impact performance
  • No virtual scrolling
  • Memory usage grows with item count

Proposed Solution:

  • Implement virtual scrolling with CDK
  • Render only visible items plus buffer
  • Recycle DOM elements

3. Purchase Options Modal Coupling (Low Priority)

Current State:

  • Direct dependency on @modal/purchase-options
  • Modal opened imperatively in component

Consideration:

  • Could be abstracted to a service
  • Better testability
  • Easier to replace modal implementation

4. Customer Selection Navigation (Low Priority)

Current State:

  • Hard-coded route to customer search
  • Uses auxiliary routes (primary/side outlets)

Consideration:

  • Could use navigation service for flexibility
  • Easier to customize per deployment
  • Better separation of concerns

Performance Optimizations

  1. OnPush Change Detection - All components use OnPush for minimal re-renders
  2. Deferred Rendering - List items use @defer (on viewport) for lazy loading
  3. Linked Signals - Computed values only recalculate when dependencies change
  4. Resource Caching - Shopping cart and bonus cards fetched once per session
  5. Query Param Debouncing - Filter changes debounced before URL update

Dependencies

Required Libraries

Angular Core

  • @angular/core - Angular framework
  • @angular/router - Routing and navigation
  • @angular/forms - FormsModule for checkbox binding

ISA Feature Libraries

  • @isa/checkout/data-access - RewardCatalogStore, ShoppingCartFacade, CheckoutMetadataService
  • @isa/checkout/shared/product-info - ProductInfoRedemptionComponent, StockInfoComponent
  • @isa/catalogue/data-access - CatalogueSearchService, Item type, QuerySettings
  • @isa/crm/data-access - Customer bonus cards, getPrimaryBonusCard, CrmTabMetadataService
  • @isa/shared/filter - Filter service, query param sync, FilterControlsPanelComponent
  • @isa/core/tabs - Tab service, injectTabId
  • @isa/utils/scroll-position - Scroll position restoration

ISA UI Libraries

  • @isa/ui/buttons - ButtonComponent, TextButtonComponent, IconButtonComponent
  • @isa/ui/input-controls - CheckboxComponent
  • @isa/ui/item-rows - ClientRowImports, ItemRowDataImports
  • @isa/ui/empty-state - EmptyStateComponent
  • @isa/ui/layout - InViewportDirective, breakpoint service
  • @isa/ui/skeleton-loader - SkeletonLoaderDirective

Modal Services

  • @modal/purchase-options - PurchaseOptionsModalService

Third-party

  • rxjs - firstValueFrom for observable handling

Path Alias

Import from: @isa/checkout/feature/reward-catalog

// Import routes
import { routes } from '@isa/checkout/feature/reward-catalog';

// Import helpers (if needed)
import { getRouteToCustomer } from '@isa/checkout/feature/reward-catalog';

Testing

The library uses Vitest with Angular Testing Utilities for testing.

Running Tests

# Run tests for this library
npx nx test reward-catalog --skip-nx-cache

# Run tests with coverage
npx nx test reward-catalog --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test reward-catalog --watch

Test Coverage

Current test files:

  • get-route-to-customer.helper.spec.ts - Helper function unit tests

Test Coverage:

describe('getRouteToCustomer', () => {
  it('should return route with tabId when provided');
  it('should filter out null tabId from path');
  it('should always include customer type filter in query params');
  it('should have consistent structure regardless of tabId');
  it('should include outlets configuration in path');
});

Testing Recommendations

Component Testing

import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RewardCatalogComponent } from './reward-catalog.component';
import { ActivatedRoute } from '@angular/router';

describe('RewardCatalogComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [RewardCatalogComponent],
      providers: [
        {
          provide: ActivatedRoute,
          useValue: {
            snapshot: {
              data: {
                querySettings: {
                  // Mock query settings
                }
              }
            }
          }
        }
      ]
    });
  });

  it('should initialize reward context', () => {
    const component = TestBed.createComponent(RewardCatalogComponent).componentInstance;
    // Assert tab metadata set to 'reward'
  });
});

Resource Testing

import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { createRewardCatalogResource } from './reward-catalog.resource';

describe('createRewardCatalogResource', () => {
  it('should fetch items on initial load', async () => {
    const mockService = {
      searchLoyaltyItems: vi.fn().mockResolvedValue({
        result: [/* mock items */],
        hits: 10
      })
    };

    // Test resource creation and loading
  });

  it('should append items on reload trigger', async () => {
    // Test pagination behavior
  });
});

Store Integration Testing

import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { RewardCatalogStore } from '@isa/checkout/data-access';

describe('RewardCatalogStore Integration', () => {
  it('should select and deselect items', () => {
    const store = TestBed.inject(RewardCatalogStore);

    store.selectItem(123, mockItem);
    expect(store.selectedItems()[123]).toBeDefined();

    store.removeItem(123);
    expect(store.selectedItems()[123]).toBeUndefined();
  });
});

E2E Attribute Requirements

All interactive elements include data-what and data-which attributes for E2E testing:

<!-- List item -->
<ui-client-row
  data-what="reward-list-item"
  [attr.data-which]="item.id">
</ui-client-row>

<!-- Selection checkbox -->
<input
  type="checkbox"
  data-what="reward-item-selection-checkbox"
  [attr.data-which]="item.product.ean"
/>

<!-- Customer selection CTA -->
<a
  data-what="select-customer"
  data-which="select-customer"
  uiButton
  [routerLink]="route().path">
</a>

<!-- Checkout CTA -->
<a
  data-what="continue-to-reward-checkout"
  data-which="continue-to-reward-checkout"
  uiButton
  routerLink="./cart">
</a>

<!-- Select rewards button -->
<button
  data-what="select-rewards"
  data-which="select-rewards"
  uiButton
  (click)="continueToPurchasingOptions()">
</button>

Best Practices

1. Always Use Tab Context

When navigating to the reward catalog, ensure tab context is available:

// Good
const tabId = this.#tabService.activatedTabId();
if (tabId) {
  this.#router.navigate(['/kunde', tabId, 'reward']);
}

// Bad (missing tab context)
this.#router.navigate(['/reward']);

2. Check Customer Points Before Enabling Features

Always verify customer has points before allowing reward selection:

// Good
const hasPoints = computed(() => {
  const points = this.primaryBonusCard()?.totalPoints ?? 0;
  return points > 0;
});

<button [disabled]="!hasPoints()">Browse Rewards</button>

// Bad (no validation)
<button>Browse Rewards</button>

3. Clean Up on Customer Change

Always clear reward context when customer changes:

// Good
resetCustomerAndCart() {
  this.#crmMetadata.setSelectedCustomerId(tabId, undefined);
  this.#checkoutMetadata.setRewardShoppingCartId(tabId, undefined);
}

// Bad (leaves stale cart)
resetCustomer() {
  this.#crmMetadata.setSelectedCustomerId(tabId, undefined);
  // Forgot to clear shopping cart!
}

4. Use Computed Signals for Derived State

Leverage Angular signals for reactive derived values:

// Good
primaryBonusCard = computed(() =>
  getPrimaryBonusCard(this.bonusCards())
);

availablePoints = computed(() =>
  this.primaryBonusCard()?.totalPoints ?? 0
);

// Bad (manual tracking)
availablePoints = 0;
ngOnInit() {
  effect(() => {
    const card = getPrimaryBonusCard(this.bonusCards());
    this.availablePoints = card?.totalPoints ?? 0;
  });
}

5. Handle Modal Results Properly

Always handle both success and cancel cases from purchase options modal:

// Good
const result = await firstValueFrom(modalRef.afterClosed$);
if (!result?.data) {
  return;  // User cancelled, do nothing
}

this.#store.clearSelectedItems();

if (result.data !== 'continue-shopping') {
  await this.#navigation(tabId);
}

// Bad (doesn't handle cancel)
const result = await firstValueFrom(modalRef.afterClosed$);
this.#store.clearSelectedItems();  // Clears even on cancel!

6. Use Breakpoint Service for Responsive Layouts

Use the breakpoint service instead of CSS-only solutions:

// Good
desktopBreakpoint = breakpoint([
  Breakpoint.Desktop,
  Breakpoint.DesktopL,
  Breakpoint.DesktopXL
]);

orientation = linkedSignal(() =>
  this.desktopBreakpoint() ? 'horizontal' : 'vertical'
);

// Bad (CSS hidden classes)
<div class="hidden desktop:block">...</div>
<div class="block desktop:hidden">...</div>

7. Preserve Filter State in URLs

Always use query parameters for filter state to enable bookmarking and sharing:

// Good (automatic via withQueryParamsSync)
provideFilter(
  withQuerySettingsFactory(querySettingsFactory),
  withQueryParamsSync()
)

// Bad (component-local state)
filters = signal({ category: null, points: null });

8. Test E2E Attributes

Verify E2E attributes are present in component tests:

// Good
it('should have E2E attributes on list items', () => {
  const listItem = fixture.nativeElement.querySelector('[data-what="reward-list-item"]');
  expect(listItem).toBeTruthy();
  expect(listItem.getAttribute('data-which')).toBe('123');
});

// Bad (no E2E attribute verification)
it('should render list items', () => {
  const listItem = fixture.nativeElement.querySelector('ui-client-row');
  expect(listItem).toBeTruthy();
});

9. Use Resource Pattern for Data Fetching

Prefer resource pattern over manual service calls for better loading state management:

// Good
rewardCatalogResource = createRewardCatalogResource(() => ({
  queryToken: this.#filterService.query(),
  searchTrigger: this.searchTrigger()
}));

isLoading = linkedSignal(() =>
  this.rewardCatalogResource.status() === 'loading'
);

// Bad (manual loading state)
isLoading = signal(false);

async loadItems() {
  this.isLoading.set(true);
  try {
    const items = await this.#service.searchItems();
  } finally {
    this.isLoading.set(false);
  }
}

10. Follow OnPush Change Detection

Always use OnPush change detection for performance:

// Good (all components in this library)
@Component({
  selector: 'reward-catalog',
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})

// Bad (default change detection)
@Component({
  selector: 'reward-catalog',
  // Missing changeDetection - uses Default
})

License

Internal ISA Frontend library - not for external distribution.