- {{ prd.manufacturer }} | {{ prd.ean }}
+
+ {{ prd.manufacturer }}
+ | {{ prd.ean }}
{{ prd.publicationDate | date: 'dd. MMMM yyyy' }}
diff --git a/libs/core/navigation/README.md b/libs/core/navigation/README.md
new file mode 100644
index 000000000..2ac555732
--- /dev/null
+++ b/libs/core/navigation/README.md
@@ -0,0 +1,792 @@
+# @isa/core/navigation
+
+A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
+
+## Overview
+
+`@isa/core/navigation` solves the problem of **lost navigation state** during intermediate navigations. Unlike Angular's router state which is lost after intermediate navigations, this library persists navigation context in **tab metadata** with automatic cleanup when tabs close.
+
+### The Problem It Solves
+
+```typescript
+// ❌ Problem: Router state is lost during intermediate navigations
+await router.navigate(['/customer/search'], {
+ state: { returnUrl: '/reward/cart' } // Works for immediate navigation
+});
+
+// After intermediate navigations:
+// /customer/search → /customer/details → /add-shipping-address
+// ⚠️ The returnUrl is LOST!
+```
+
+### The Solution
+
+```typescript
+// ✅ Solution: Context preservation survives intermediate navigations
+navState.preserveContext({ returnUrl: '/reward/cart' });
+// Context persists in tab metadata, automatically cleaned up when tab closes
+
+// After multiple intermediate navigations:
+const context = navState.restoreAndClearContext();
+// ✅ returnUrl is PRESERVED!
+```
+
+## Features
+
+- ✅ **Survives Intermediate Navigations** - State persists across multiple navigation steps
+- ✅ **Automatic Tab Scoping** - Contexts automatically isolated per tab using `TabService`
+- ✅ **Automatic Cleanup** - Contexts cleared automatically when tabs close (no manual cleanup needed)
+- ✅ **Hierarchical Scoping** - Combine tab ID with custom scopes (e.g., `"customer-details"`)
+- ✅ **Type-Safe** - Full TypeScript generics support
+- ✅ **Simple API** - No context IDs to track, scope is the identifier
+- ✅ **Auto-Restore on Refresh** - Contexts survive page refresh (via TabService UserStorage persistence)
+- ✅ **Map-Based Storage** - One context per scope for clarity
+- ✅ **Platform-Agnostic** - Works with Angular Universal (SSR)
+- ✅ **Zero URL Pollution** - No query parameters needed
+
+## Installation
+
+This library is part of the ISA Frontend monorepo. Import from the path alias:
+
+```typescript
+import { NavigationStateService } from '@isa/core/navigation';
+```
+
+## Quick Start
+
+### Basic Flow
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { NavigationStateService } from '@isa/core/navigation';
+
+@Component({
+ selector: 'app-cart',
+ template: ``
+})
+export class CartComponent {
+ private router = inject(Router);
+ private navState = inject(NavigationStateService);
+
+ async editCustomer() {
+ // Start flow - preserve context (auto-scoped to active tab)
+ this.navState.preserveContext({
+ returnUrl: '/reward/cart',
+ customerId: 123
+ });
+
+ await this.router.navigate(['/customer/search']);
+ }
+}
+
+@Component({
+ selector: 'app-customer-details',
+ template: ``
+})
+export class CustomerDetailsComponent {
+ private router = inject(Router);
+ private navState = inject(NavigationStateService);
+
+ async complete() {
+ // End flow - restore and auto-cleanup (auto-scoped to active tab)
+ const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
+
+ if (context?.returnUrl) {
+ await this.router.navigateByUrl(context.returnUrl);
+ }
+ }
+}
+```
+
+### Simplified Navigation
+
+Use `navigateWithPreservedContext()` to combine navigation + context preservation:
+
+```typescript
+async editCustomer() {
+ // Navigate and preserve in one call
+ const { success } = await this.navState.navigateWithPreservedContext(
+ ['/customer/search'],
+ { returnUrl: '/reward/cart', customerId: 123 }
+ );
+}
+```
+
+## Core API
+
+### Context Management
+
+#### `preserveContext(state, customScope?)`
+
+Save navigation context that survives intermediate navigations.
+
+```typescript
+// Default tab scope
+navState.preserveContext({
+ returnUrl: '/reward/cart',
+ selectedItems: [1, 2, 3]
+});
+
+// Custom scope within tab
+navState.preserveContext(
+ { customerId: 42 },
+ 'customer-details' // Stored as 'customer-details' in active tab's metadata
+);
+```
+
+**Parameters:**
+- `state`: The data to preserve (any object)
+- `customScope` (optional): Custom scope within the tab (e.g., `'customer-details'`)
+
+**Storage Location:**
+- Stored in active tab's metadata at: `tab.metadata['navigation-contexts'][scopeKey]`
+- Default scope: `'default'`
+- Custom scope: `customScope` value
+
+---
+
+#### `restoreContext(customScope?)`
+
+Retrieve preserved context **without** removing it.
+
+```typescript
+// Default tab scope
+const context = navState.restoreContext<{ returnUrl: string }>();
+if (context?.returnUrl) {
+ console.log('Return URL:', context.returnUrl);
+}
+
+// Custom scope
+const context = navState.restoreContext<{ customerId: number }>('customer-details');
+```
+
+**Parameters:**
+- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
+
+**Returns:** The preserved data, or `null` if not found
+
+---
+
+#### `restoreAndClearContext(customScope?)`
+
+Retrieve preserved context **and automatically remove** it (recommended for cleanup).
+
+```typescript
+// Default tab scope
+const context = navState.restoreAndClearContext<{ returnUrl: string }>();
+if (context?.returnUrl) {
+ await router.navigateByUrl(context.returnUrl);
+}
+
+// Custom scope
+const context = navState.restoreAndClearContext<{ customerId: number }>('customer-details');
+```
+
+**Parameters:**
+- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
+
+**Returns:** The preserved data, or `null` if not found
+
+---
+
+#### `clearPreservedContext(customScope?)`
+
+Manually remove a context without retrieving its data.
+
+```typescript
+// Clear default tab scope
+navState.clearPreservedContext();
+
+// Clear custom scope
+navState.clearPreservedContext('customer-details');
+```
+
+---
+
+#### `hasPreservedContext(customScope?)`
+
+Check if a context exists.
+
+```typescript
+// Check default tab scope
+if (navState.hasPreservedContext()) {
+ const context = navState.restoreContext();
+}
+
+// Check custom scope
+if (navState.hasPreservedContext('customer-details')) {
+ const context = navState.restoreContext('customer-details');
+}
+```
+
+---
+
+### Navigation Helpers
+
+#### `navigateWithPreservedContext(commands, state, customScope?, extras?)`
+
+Navigate and preserve context in one call.
+
+```typescript
+const { success } = await navState.navigateWithPreservedContext(
+ ['/customer/search'],
+ { returnUrl: '/reward/cart' },
+ 'customer-flow', // optional customScope
+ { queryParams: { foo: 'bar' } } // optional NavigationExtras
+);
+
+// Later...
+const context = navState.restoreAndClearContext('customer-flow');
+```
+
+---
+
+### Cleanup Methods
+
+#### `clearScopeContexts()`
+
+Clear all contexts for the active tab (both default and custom scopes).
+
+```typescript
+// Clear all contexts for active tab
+const cleared = this.navState.clearScopeContexts();
+console.log(`Cleaned up ${cleared} contexts`);
+```
+
+**Returns:** Number of contexts cleared
+
+**Note:** This is typically not needed because contexts are **automatically cleaned up when the tab closes**. Use this only for explicit cleanup during the tab's lifecycle.
+
+---
+
+## Usage Patterns
+
+### Pattern 1: Multi-Step Flow with Intermediate Navigations
+
+**Problem:** You need to return to a page after multiple intermediate navigations.
+
+```typescript
+// Component A: Start of flow
+export class RewardCartComponent {
+ navState = inject(NavigationStateService);
+ router = inject(Router);
+
+ async selectCustomer() {
+ // Preserve returnUrl (auto-scoped to tab)
+ this.navState.preserveContext({
+ returnUrl: '/reward/cart'
+ });
+
+ await this.router.navigate(['/customer/search']);
+ }
+}
+
+// Component B: Intermediate navigation
+export class CustomerSearchComponent {
+ router = inject(Router);
+
+ async viewDetails(customerId: number) {
+ await this.router.navigate(['/customer/details', customerId]);
+ // Context still persists!
+ }
+}
+
+// Component C: Another intermediate navigation
+export class CustomerDetailsComponent {
+ router = inject(Router);
+
+ async addShippingAddress() {
+ await this.router.navigate(['/add-shipping-address']);
+ // Context still persists!
+ }
+}
+
+// Component D: End of flow
+export class FinalStepComponent {
+ navState = inject(NavigationStateService);
+ router = inject(Router);
+
+ async complete() {
+ // Restore context (auto-scoped to tab) and navigate back
+ const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
+
+ if (context?.returnUrl) {
+ await this.router.navigateByUrl(context.returnUrl);
+ }
+ }
+}
+```
+
+---
+
+### Pattern 2: Multiple Flows in Same Tab
+
+Use custom scopes to manage different flows within the same tab.
+
+```typescript
+export class ComplexPageComponent {
+ navState = inject(NavigationStateService);
+
+ async startCustomerFlow() {
+ // Store context for customer flow
+ this.navState.preserveContext(
+ { returnUrl: '/dashboard', step: 1 },
+ 'customer-flow'
+ );
+ // Stored in active tab metadata under scope 'customer-flow'
+ }
+
+ async startProductFlow() {
+ // Store context for product flow
+ this.navState.preserveContext(
+ { returnUrl: '/dashboard', selectedProducts: [1, 2] },
+ 'product-flow'
+ );
+ // Stored in active tab metadata under scope 'product-flow'
+ }
+
+ async completeCustomerFlow() {
+ // Restore from customer flow
+ const context = this.navState.restoreAndClearContext('customer-flow');
+ }
+
+ async completeProductFlow() {
+ // Restore from product flow
+ const context = this.navState.restoreAndClearContext('product-flow');
+ }
+}
+```
+
+---
+
+### Pattern 3: Complex Context Data
+
+```typescript
+interface CheckoutContext {
+ returnUrl: string;
+ selectedItems: number[];
+ customerId: number;
+ shippingAddressId?: number;
+ metadata: {
+ source: 'reward' | 'checkout';
+ timestamp: number;
+ };
+}
+
+// Save
+navState.preserveContext({
+ returnUrl: '/reward/cart',
+ selectedItems: [1, 2, 3],
+ customerId: 456,
+ metadata: {
+ source: 'reward',
+ timestamp: Date.now()
+ }
+});
+
+// Restore with type safety
+const context = navState.restoreAndClearContext();
+if (context) {
+ console.log('Items:', context.selectedItems);
+ console.log('Customer:', context.customerId);
+}
+```
+
+---
+
+### Pattern 4: No Manual Cleanup Needed
+
+```typescript
+export class TabAwareComponent {
+ navState = inject(NavigationStateService);
+
+ async startFlow() {
+ // Set context
+ this.navState.preserveContext({ returnUrl: '/home' });
+
+ // No need to clear in ngOnDestroy!
+ // Context is automatically cleaned up when tab closes
+ }
+
+ // ❌ NOT NEEDED:
+ // ngOnDestroy() {
+ // this.navState.clearScopeContexts();
+ // }
+}
+```
+
+---
+
+## Architecture
+
+### How It Works
+
+```mermaid
+graph LR
+ A[NavigationStateService] --> B[NavigationContextService]
+ B --> C[TabService]
+ C --> D[Tab Metadata Storage]
+ D --> E[UserStorage Persistence]
+
+ style A fill:#e1f5ff
+ style B fill:#e1f5ff
+ style C fill:#fff4e1
+ style D fill:#e8f5e9
+ style E fill:#f3e5f5
+```
+
+1. **Context Storage**: Contexts are stored in **tab metadata** using `TabService`
+2. **Automatic Scoping**: Active tab ID determines storage location automatically
+3. **Hierarchical Keys**: Scopes are organized as `tab.metadata['navigation-contexts'][customScope]`
+4. **Automatic Cleanup**: Contexts removed automatically when tabs close (via tab lifecycle)
+5. **Persistent Across Refresh**: Tab metadata persists via UserStorage, so contexts survive page refresh
+6. **Map-Based**: One context per scope for clarity
+
+### Tab Metadata Structure
+
+```typescript
+// Example: Tab with ID 123
+tab.metadata = {
+ 'navigation-contexts': {
+ 'default': {
+ data: { returnUrl: '/cart', selectedItems: [1, 2, 3] },
+ createdAt: 1234567890000
+ },
+ 'customer-details': {
+ data: { customerId: 42, step: 2 },
+ createdAt: 1234567891000
+ },
+ 'product-flow': {
+ data: { productIds: [100, 200], source: 'recommendation' },
+ createdAt: 1234567892000
+ }
+ },
+ // ... other tab metadata
+}
+```
+
+### Storage Layers
+
+```
+┌─────────────────────────────────────┐
+│ NavigationStateService (Public API)│
+└──────────────┬──────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────┐
+│ NavigationContextService (Storage) │
+└──────────────┬──────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────┐
+│ TabService.patchTabMetadata() │
+└──────────────┬──────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────┐
+│ Tab Metadata Storage (In-Memory) │
+└──────────────┬──────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────┐
+│ UserStorage (SessionStorage) │
+│ (Automatic Persistence) │
+└─────────────────────────────────────┘
+```
+
+### Integration with TabService
+
+This library **requires** `@isa/core/tabs` for automatic tab scoping:
+
+```typescript
+import { TabService } from '@isa/core/tabs';
+
+// NavigationContextService uses:
+const tabId = this.tabService.activatedTabId(); // Returns: number | null
+
+if (tabId !== null) {
+ // Store in: tab.metadata['navigation-contexts'][customScope]
+ this.tabService.patchTabMetadata(tabId, {
+ 'navigation-contexts': {
+ [customScope]: { data, createdAt }
+ }
+ });
+}
+```
+
+**When no tab is active** (tabId = null):
+- Operations throw an error to prevent data loss
+- This ensures contexts are always properly scoped to a tab
+
+---
+
+## Migration Guide
+
+### From SessionStorage to Tab Metadata
+
+This library previously used SessionStorage for context persistence. It has been refactored to use tab metadata for better integration with the tab lifecycle and automatic cleanup.
+
+### What Changed
+
+**Storage Location:**
+- **Before**: SessionStorage with key `'isa:navigation:context-map'`
+- **After**: Tab metadata at `tab.metadata['navigation-contexts']`
+
+**Cleanup:**
+- **Before**: Manual cleanup required + automatic expiration after 24 hours
+- **After**: Automatic cleanup when tab closes (no manual cleanup needed)
+
+**Scope Keys:**
+- **Before**: `"123"` (tab ID), `"123-customer-details"` (tab ID + custom scope)
+- **After**: `"default"`, `"customer-details"` (custom scope only, tab ID implicit from storage location)
+
+**TTL Parameter:**
+- **Before**: `preserveContext(data, customScope, ttl)` - TTL respected
+- **After**: `preserveContext(data, customScope, ttl)` - TTL parameter ignored (kept for compatibility)
+
+### What Stayed the Same
+
+✅ **Public API**: All public methods remain unchanged
+✅ **Type Safety**: Full TypeScript support with generics
+✅ **Hierarchical Scoping**: Custom scopes still work the same way
+✅ **Usage Patterns**: All existing code continues to work
+✅ **Persistence**: Contexts still survive page refresh (via TabService UserStorage)
+
+### Benefits of Tab Metadata Approach
+
+1. **Automatic Cleanup**: No need to manually clear contexts or worry about stale data
+2. **Better Integration**: Seamless integration with tab lifecycle management
+3. **Simpler Mental Model**: Contexts are "owned" by tabs, not global storage
+4. **No TTL Management**: Tab lifecycle handles cleanup automatically
+5. **Safer**: Impossible to leak contexts across unrelated tabs
+
+### Migration Steps
+
+**No action required!** The public API is unchanged. Your existing code will continue to work:
+
+```typescript
+// ✅ This code works exactly the same before and after migration
+navState.preserveContext({ returnUrl: '/cart' });
+const context = navState.restoreAndClearContext<{ returnUrl: string }>();
+```
+
+**Optional: Remove manual cleanup code**
+
+If you have manual cleanup in `ngOnDestroy`, you can safely remove it:
+
+```typescript
+// Before (still works, but unnecessary):
+ngOnDestroy() {
+ this.navState.clearScopeContexts();
+}
+
+// After (automatic cleanup):
+ngOnDestroy() {
+ // No cleanup needed - tab lifecycle handles it!
+}
+```
+
+**Note on TTL parameter**
+
+If you were using the TTL parameter, be aware it's now ignored:
+
+```typescript
+// Before: TTL respected
+navState.preserveContext({ data: 'foo' }, undefined, 60000); // Expires in 1 minute
+
+// After: TTL ignored (context lives until tab closes)
+navState.preserveContext({ data: 'foo' }, undefined, 60000); // Ignored parameter
+```
+
+---
+
+## Testing
+
+### Mocking NavigationStateService
+
+```typescript
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NavigationStateService } from '@isa/core/navigation';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+describe('MyComponent', () => {
+ let component: MyComponent;
+ let fixture: ComponentFixture;
+ let navStateMock: any;
+
+ beforeEach(async () => {
+ navStateMock = {
+ preserveContext: vi.fn(),
+ restoreContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
+ restoreAndClearContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
+ clearPreservedContext: vi.fn().mockReturnValue(true),
+ hasPreservedContext: vi.fn().mockReturnValue(true),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [MyComponent],
+ providers: [
+ { provide: NavigationStateService, useValue: navStateMock }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MyComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should preserve context when navigating', async () => {
+ await component.startFlow();
+
+ expect(navStateMock.preserveContext).toHaveBeenCalledWith({
+ returnUrl: '/reward/cart'
+ });
+ });
+
+ it('should restore context and navigate back', async () => {
+ navStateMock.restoreAndClearContext.mockReturnValue({ returnUrl: '/cart' });
+
+ await component.complete();
+
+ expect(navStateMock.restoreAndClearContext).toHaveBeenCalled();
+ // Assert navigation occurred
+ });
+});
+```
+
+---
+
+## Best Practices
+
+### ✅ Do
+
+- **Use `restoreAndClearContext()`** for automatic cleanup when completing flows
+- **Use custom scopes** for multiple concurrent flows in the same tab
+- **Leverage type safety** with TypeScript generics (``)
+- **Trust automatic cleanup** - no need to manually clear contexts when tabs close
+- **Check for null** when restoring contexts (they may not exist)
+
+### ❌ Don't
+
+- **Don't store large objects** - keep contexts lean (return URLs, IDs, simple flags)
+- **Don't use for persistent data** - use NgRx or services for long-lived state
+- **Don't rely on TTL** - the TTL parameter is ignored in the current implementation
+- **Don't manually clear in ngOnDestroy** - tab lifecycle handles it automatically
+- **Don't store sensitive data** - contexts may be visible in browser dev tools
+
+### When to Use Navigation Context
+
+✅ **Good Use Cases:**
+- Return URLs for multi-step flows
+- Wizard/multi-step form state
+- Temporary search filters or selections
+- Flow-specific context (customer ID during checkout)
+
+❌ **Bad Use Cases:**
+- User preferences (use NgRx or services)
+- Authentication tokens (use dedicated auth service)
+- Large datasets (use data services with caching)
+- Cross-tab communication (use BroadcastChannel or shared services)
+
+---
+
+## Configuration
+
+### Constants
+
+All configuration is in `navigation-context.constants.ts`:
+
+```typescript
+// Metadata key for storing contexts in tab metadata
+export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
+```
+
+**Note:** Previous SessionStorage constants (`DEFAULT_CONTEXT_TTL`, `CLEANUP_INTERVAL`, `NAVIGATION_CONTEXT_STORAGE_KEY`) have been removed as they are no longer needed with tab metadata storage.
+
+---
+
+## API Reference Summary
+
+| Method | Parameters | Returns | Purpose |
+|--------|-----------|---------|---------|
+| `preserveContext(state, customScope?)` | state: T, customScope?: string | void | Save context |
+| `restoreContext(customScope?)` | customScope?: string | T \| null | Get context (keep) |
+| `restoreAndClearContext(customScope?)` | customScope?: string | T \| null | Get + remove |
+| `clearPreservedContext(customScope?)` | customScope?: string | boolean | Remove context |
+| `hasPreservedContext(customScope?)` | customScope?: string | boolean | Check exists |
+| `navigateWithPreservedContext(...)` | commands, state, customScope?, extras? | Promise<{success}> | Navigate + preserve |
+| `clearScopeContexts()` | none | number | Bulk cleanup (rarely needed) |
+
+---
+
+## Troubleshooting
+
+### Context Not Found After Refresh
+
+**Problem**: Context is `null` after page refresh.
+
+**Solution**: Ensure `TabService` is properly initialized and the tab ID is restored from UserStorage. Contexts rely on tab metadata which persists via UserStorage.
+
+### Context Cleared Unexpectedly
+
+**Problem**: Context disappears before you retrieve it.
+
+**Solution**: Check if you're using `restoreAndClearContext()` multiple times. This method removes the context after retrieval. Use `restoreContext()` if you need to access it multiple times.
+
+### "No active tab" Error
+
+**Problem**: Getting error "No active tab - cannot set navigation context".
+
+**Solution**: Ensure `TabService` has an active tab before using navigation context. This typically happens during app initialization before tabs are ready.
+
+### Context Not Isolated Between Tabs
+
+**Problem**: Contexts from one tab appearing in another.
+
+**Solution**: This should not happen with tab metadata storage. If you see this, it may indicate a TabService issue. Check that `TabService.activatedTabId()` returns the correct tab ID.
+
+---
+
+## Running Tests
+
+```bash
+# Run tests
+npx nx test core-navigation
+
+# Run tests with coverage
+npx nx test core-navigation --coverage.enabled=true
+
+# Run tests without cache (CI)
+npx nx test core-navigation --skip-cache
+```
+
+**Test Results:**
+- 79 tests passing
+- 2 test files (navigation-state.service.spec.ts, navigation-context.service.spec.ts)
+
+---
+
+## CI/CD Integration
+
+This library generates JUnit and Cobertura reports for Azure Pipelines:
+
+- **JUnit Report**: `testresults/junit-core-navigation.xml`
+- **Cobertura Report**: `coverage/libs/core/navigation/cobertura-coverage.xml`
+
+---
+
+## Contributing
+
+This library follows the ISA Frontend monorepo conventions:
+
+- **Path Alias**: `@isa/core/navigation`
+- **Testing Framework**: Vitest with Angular Testing Utilities
+- **Code Style**: ESLint + Prettier
+- **Test Coverage**: Required for all public APIs
+- **Dependencies**: Requires `@isa/core/tabs` for tab scoping
+
+---
+
+## License
+
+Internal ISA Frontend monorepo library.
diff --git a/libs/core/navigation/eslint.config.cjs b/libs/core/navigation/eslint.config.cjs
new file mode 100644
index 000000000..bdab98018
--- /dev/null
+++ b/libs/core/navigation/eslint.config.cjs
@@ -0,0 +1,34 @@
+const nx = require('@nx/eslint-plugin');
+const baseConfig = require('../../../eslint.config.js');
+
+module.exports = [
+ ...baseConfig,
+ ...nx.configs['flat/angular'],
+ ...nx.configs['flat/angular-template'],
+ {
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'core',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'core',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.html'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/libs/core/navigation/project.json b/libs/core/navigation/project.json
new file mode 100644
index 000000000..e9a6243fe
--- /dev/null
+++ b/libs/core/navigation/project.json
@@ -0,0 +1,20 @@
+{
+ "name": "core-navigation",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/core/navigation/src",
+ "prefix": "core",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "test": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "reportsDirectory": "../../../coverage/libs/core/navigation"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/libs/core/navigation/src/index.ts b/libs/core/navigation/src/index.ts
new file mode 100644
index 000000000..6a10b041d
--- /dev/null
+++ b/libs/core/navigation/src/index.ts
@@ -0,0 +1,5 @@
+export * from './lib/navigation-state.types';
+export * from './lib/navigation-state.service';
+export * from './lib/navigation-context.types';
+export * from './lib/navigation-context.service';
+export * from './lib/navigation-context.constants';
diff --git a/libs/core/navigation/src/lib/navigation-context.constants.ts b/libs/core/navigation/src/lib/navigation-context.constants.ts
new file mode 100644
index 000000000..b23d06293
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-context.constants.ts
@@ -0,0 +1,22 @@
+/**
+ * Constants for navigation context storage in tab metadata.
+ * Navigation contexts are stored directly in tab metadata instead of sessionStorage,
+ * providing automatic cleanup when tabs are closed and better integration with the tab system.
+ */
+
+/**
+ * Key used to store navigation contexts in tab metadata.
+ * Contexts are stored as: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
+ *
+ * @example
+ * ```typescript
+ * // Structure in tab metadata:
+ * tab.metadata = {
+ * 'navigation-contexts': {
+ * 'default': { data: { returnUrl: '/cart' }, createdAt: 123 },
+ * 'customer-details': { data: { customerId: 42 }, createdAt: 456 }
+ * }
+ * }
+ * ```
+ */
+export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
diff --git a/libs/core/navigation/src/lib/navigation-context.service.spec.ts b/libs/core/navigation/src/lib/navigation-context.service.spec.ts
new file mode 100644
index 000000000..c24dff77c
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-context.service.spec.ts
@@ -0,0 +1,668 @@
+import { TestBed } from '@angular/core/testing';
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { signal } from '@angular/core';
+import { NavigationContextService } from './navigation-context.service';
+import { TabService } from '@isa/core/tabs';
+import { ReturnUrlContext } from './navigation-context.types';
+import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
+
+describe('NavigationContextService', () => {
+ let service: NavigationContextService;
+ let tabServiceMock: {
+ activatedTabId: ReturnType>;
+ entityMap: ReturnType;
+ patchTabMetadata: ReturnType;
+ };
+
+ beforeEach(() => {
+ // Create mock TabService with signals and methods
+ tabServiceMock = {
+ activatedTabId: signal(null),
+ entityMap: vi.fn(),
+ patchTabMetadata: vi.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ NavigationContextService,
+ { provide: TabService, useValue: tabServiceMock },
+ ],
+ });
+
+ service = TestBed.inject(NavigationContextService);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('setContext', () => {
+ it('should set context in tab metadata', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ const data: ReturnUrlContext = { returnUrl: '/test-page' };
+
+ // Act
+ await service.setContext(data);
+
+ // Assert
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
+ tabId,
+ expect.objectContaining({
+ [NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
+ default: expect.objectContaining({
+ data,
+ createdAt: expect.any(Number),
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should set context with custom scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const customScope = 'customer-details';
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ const data = { customerId: 42 };
+
+ // Act
+ await service.setContext(data, customScope);
+
+ // Assert
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
+ tabId,
+ expect.objectContaining({
+ [NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
+ [customScope]: expect.objectContaining({
+ data,
+ createdAt: expect.any(Number),
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should throw error when no active tab', async () => {
+ // Arrange
+ tabServiceMock.activatedTabId.set(null);
+
+ // Act & Assert
+ await expect(service.setContext({ returnUrl: '/test' })).rejects.toThrow(
+ 'No active tab - cannot set navigation context',
+ );
+ });
+
+ it('should merge with existing contexts', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+
+ const existingContexts = {
+ 'existing-scope': {
+ data: { existingData: 'value' },
+ createdAt: Date.now() - 1000,
+ },
+ };
+
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: existingContexts,
+ },
+ },
+ });
+
+ const newData = { returnUrl: '/new-page' };
+
+ // Act
+ await service.setContext(newData);
+
+ // Assert
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
+ tabId,
+ expect.objectContaining({
+ [NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
+ 'existing-scope': existingContexts['existing-scope'],
+ default: expect.objectContaining({
+ data: newData,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should accept TTL parameter for backward compatibility', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ await service.setContext({ returnUrl: '/test' }, undefined, 60000);
+
+ // Assert - TTL is ignored but method should still work
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalled();
+ });
+ });
+
+ describe('getContext', () => {
+ it('should return null when no active tab', async () => {
+ // Arrange
+ tabServiceMock.activatedTabId.set(null);
+
+ // Act
+ const result = await service.getContext();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it('should return null when context does not exist', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const result = await service.getContext();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it('should retrieve context from default scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const data: ReturnUrlContext = { returnUrl: '/test-page' };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ default: {
+ data,
+ createdAt: Date.now(),
+ },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.getContext();
+
+ // Assert
+ expect(result).toEqual(data);
+ });
+
+ it('should retrieve context from custom scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const customScope = 'checkout-flow';
+ const data = { step: 2, productId: 456 };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ [customScope]: {
+ data,
+ createdAt: Date.now(),
+ },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.getContext(customScope);
+
+ // Assert
+ expect(result).toEqual(data);
+ });
+
+ it('should return null when tab not found', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({});
+
+ // Act
+ const result = await service.getContext();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it('should handle invalid metadata gracefully', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: 'invalid', // Invalid type
+ },
+ },
+ });
+
+ // Act
+ const result = await service.getContext();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getAndClearContext', () => {
+ it('should return null when no active tab', async () => {
+ // Arrange
+ tabServiceMock.activatedTabId.set(null);
+
+ // Act
+ const result = await service.getAndClearContext();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it('should retrieve and remove context from default scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const data: ReturnUrlContext = { returnUrl: '/test-page' };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ default: {
+ data,
+ createdAt: Date.now(),
+ },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.getAndClearContext();
+
+ // Assert
+ expect(result).toEqual(data);
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {},
+ });
+ });
+
+ it('should retrieve and remove context from custom scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const customScope = 'wizard-flow';
+ const data = { currentStep: 3 };
+ const otherScopeData = { otherData: 'value' };
+
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ [customScope]: {
+ data,
+ createdAt: Date.now(),
+ },
+ 'other-scope': {
+ data: otherScopeData,
+ createdAt: Date.now(),
+ },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.getAndClearContext(customScope);
+
+ // Assert
+ expect(result).toEqual(data);
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ 'other-scope': expect.objectContaining({
+ data: otherScopeData,
+ }),
+ },
+ });
+ });
+
+ it('should return null when context not found', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const result = await service.getAndClearContext();
+
+ // Assert
+ expect(result).toBeNull();
+ expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('clearContext', () => {
+ it('should return true when context exists and is cleared', async () => {
+ // Arrange
+ const tabId = 123;
+ const data = { returnUrl: '/test' };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ default: { data, createdAt: Date.now() },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.clearContext();
+
+ // Assert
+ expect(result).toBe(true);
+ });
+
+ it('should return false when context not found', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const result = await service.clearContext();
+
+ // Assert
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('clearScope', () => {
+ it('should clear all contexts for active tab', async () => {
+ // Arrange
+ const tabId = 123;
+ const contexts = {
+ default: { data: { url: '/test' }, createdAt: Date.now() },
+ 'scope-1': { data: { value: 1 }, createdAt: Date.now() },
+ 'scope-2': { data: { value: 2 }, createdAt: Date.now() },
+ };
+
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
+ },
+ },
+ });
+
+ // Act
+ const clearedCount = await service.clearScope();
+
+ // Assert
+ expect(clearedCount).toBe(3);
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {},
+ });
+ });
+
+ it('should return 0 when no contexts exist', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const clearedCount = await service.clearScope();
+
+ // Assert
+ expect(clearedCount).toBe(0);
+ expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
+ });
+
+ it('should return 0 when no active tab', async () => {
+ // Arrange
+ tabServiceMock.activatedTabId.set(null);
+
+ // Act
+ const clearedCount = await service.clearScope();
+
+ // Assert
+ expect(clearedCount).toBe(0);
+ });
+ });
+
+ describe('clearAll', () => {
+ it('should clear all contexts for active tab', async () => {
+ // Arrange
+ const tabId = 123;
+ const contexts = {
+ default: { data: { url: '/test' }, createdAt: Date.now() },
+ };
+
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
+ },
+ },
+ });
+
+ // Act
+ await service.clearAll();
+
+ // Assert
+ expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {},
+ });
+ });
+ });
+
+ describe('hasContext', () => {
+ it('should return true when context exists', async () => {
+ // Arrange
+ const tabId = 123;
+ const data = { returnUrl: '/test' };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ default: { data, createdAt: Date.now() },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.hasContext();
+
+ // Assert
+ expect(result).toBe(true);
+ });
+
+ it('should return false when context does not exist', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const result = await service.hasContext();
+
+ // Assert
+ expect(result).toBe(false);
+ });
+
+ it('should check custom scope', async () => {
+ // Arrange
+ const tabId = 123;
+ const customScope = 'wizard';
+ const data = { step: 1 };
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {
+ [customScope]: { data, createdAt: Date.now() },
+ },
+ },
+ },
+ });
+
+ // Act
+ const result = await service.hasContext(customScope);
+
+ // Assert
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('getContextCount', () => {
+ it('should return total number of contexts for active tab', async () => {
+ // Arrange
+ const tabId = 123;
+ const contexts = {
+ default: { data: { url: '/test' }, createdAt: Date.now() },
+ 'scope-1': { data: { value: 1 }, createdAt: Date.now() },
+ 'scope-2': { data: { value: 2 }, createdAt: Date.now() },
+ };
+
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
+ },
+ },
+ });
+
+ // Act
+ const count = await service.getContextCount();
+
+ // Assert
+ expect(count).toBe(3);
+ });
+
+ it('should return 0 when no contexts exist', async () => {
+ // Arrange
+ const tabId = 123;
+ tabServiceMock.activatedTabId.set(tabId);
+ tabServiceMock.entityMap.mockReturnValue({
+ [tabId]: {
+ id: tabId,
+ name: 'Test Tab',
+ metadata: {},
+ },
+ });
+
+ // Act
+ const count = await service.getContextCount();
+
+ // Assert
+ expect(count).toBe(0);
+ });
+
+ it('should return 0 when no active tab', async () => {
+ // Arrange
+ tabServiceMock.activatedTabId.set(null);
+
+ // Act
+ const count = await service.getContextCount();
+
+ // Assert
+ expect(count).toBe(0);
+ });
+ });
+});
diff --git a/libs/core/navigation/src/lib/navigation-context.service.ts b/libs/core/navigation/src/lib/navigation-context.service.ts
new file mode 100644
index 000000000..4ee947cf8
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-context.service.ts
@@ -0,0 +1,373 @@
+import { Injectable, inject } from '@angular/core';
+import { TabService } from '@isa/core/tabs';
+import { logger } from '@isa/core/logging';
+import {
+ NavigationContext,
+ NavigationContextData,
+ NavigationContextsMetadataSchema,
+} from './navigation-context.types';
+import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
+
+/**
+ * Service for managing navigation context using tab metadata storage.
+ *
+ * This service provides a type-safe approach to preserving navigation state
+ * across intermediate navigations, solving the problem of lost router state
+ * in multi-step flows.
+ *
+ * Key Features:
+ * - Stores contexts in tab metadata (automatic cleanup when tab closes)
+ * - Type-safe with Zod validation
+ * - Scoped to individual tabs (no cross-tab pollution)
+ * - Simple API with hierarchical scoping support
+ *
+ * Storage Architecture:
+ * - Contexts stored at: `tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY]`
+ * - Structure: `{ [customScope]: { data, createdAt } }`
+ * - No manual cleanup needed (handled by tab lifecycle)
+ *
+ * @example
+ * ```typescript
+ * // Start of flow - preserve context (auto-scoped to active tab)
+ * contextService.setContext({
+ * returnUrl: '/original-page',
+ * customerId: 123
+ * });
+ *
+ * // ... intermediate navigations happen ...
+ *
+ * // End of flow - restore and cleanup
+ * const context = contextService.getAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ * ```
+ */
+@Injectable({ providedIn: 'root' })
+export class NavigationContextService {
+ readonly #tabService = inject(TabService);
+ readonly #log = logger(() => ({ module: 'navigation-context' }));
+
+ /**
+ * Get the navigation contexts map from tab metadata.
+ *
+ * @param tabId The tab ID to get contexts for
+ * @returns Record of scope keys to contexts, or empty object if not found
+ */
+ #getContextsMap(tabId: number): Record {
+ const tab = this.#tabService.entityMap()[tabId];
+ if (!tab) {
+ this.#log.debug('Tab not found', () => ({ tabId }));
+ return {};
+ }
+
+ const contextsMap = tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY];
+ if (!contextsMap) {
+ return {};
+ }
+
+ // Validate with Zod schema
+ const result = NavigationContextsMetadataSchema.safeParse(contextsMap);
+ if (!result.success) {
+ this.#log.warn('Invalid contexts map in tab metadata', () => ({
+ tabId,
+ validationErrors: result.error.errors,
+ }));
+ return {};
+ }
+
+ return result.data as Record;
+ }
+
+ /**
+ * Save the navigation contexts map to tab metadata.
+ *
+ * @param tabId The tab ID to save contexts to
+ * @param contextsMap The contexts map to save
+ */
+ #saveContextsMap(
+ tabId: number,
+ contextsMap: Record,
+ ): void {
+ this.#tabService.patchTabMetadata(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: contextsMap,
+ });
+ }
+
+ /**
+ * Set a context in the active tab's metadata.
+ *
+ * Creates or overwrites a navigation context and persists it to tab metadata.
+ * The context will automatically be cleaned up when the tab is closed.
+ *
+ * @template T The type of data being stored in the context
+ * @param data The navigation data to preserve
+ * @param customScope Optional custom scope (defaults to 'default')
+ * @param _ttl Optional TTL parameter (kept for API compatibility but ignored)
+ *
+ * @example
+ * ```typescript
+ * // Set context for default scope
+ * contextService.setContext({ returnUrl: '/products', selectedIds: [1, 2, 3] });
+ *
+ * // Set context for custom scope
+ * contextService.setContext({ customerId: 42 }, 'customer-details');
+ * ```
+ */
+ async setContext(
+ data: T,
+ customScope?: string,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _ttl?: number, // Kept for API compatibility but ignored
+ ): Promise {
+ const tabId = this.#tabService.activatedTabId();
+ if (tabId === null) {
+ throw new Error('No active tab - cannot set navigation context');
+ }
+
+ const scopeKey = customScope || 'default';
+ const context: NavigationContext = {
+ data,
+ createdAt: Date.now(),
+ };
+
+ const contextsMap = this.#getContextsMap(tabId);
+ contextsMap[scopeKey] = context;
+ this.#saveContextsMap(tabId, contextsMap);
+
+ this.#log.debug('Context set in tab metadata', () => ({
+ tabId,
+ scopeKey,
+ dataKeys: Object.keys(data),
+ totalContexts: Object.keys(contextsMap).length,
+ }));
+ }
+
+ /**
+ * Get a context from the active tab's metadata without removing it.
+ *
+ * Retrieves a preserved navigation context by scope.
+ *
+ * @template T The expected type of the context data
+ * @param customScope Optional custom scope (defaults to 'default')
+ * @returns The context data, or null if not found
+ *
+ * @example
+ * ```typescript
+ * // Get context for default scope
+ * const context = contextService.getContext<{ returnUrl: string }>();
+ *
+ * // Get context for custom scope
+ * const context = contextService.getContext<{ customerId: number }>('customer-details');
+ * ```
+ */
+ async getContext(
+ customScope?: string,
+ ): Promise {
+ const tabId = this.#tabService.activatedTabId();
+ if (tabId === null) {
+ this.#log.debug('No active tab - cannot get context');
+ return null;
+ }
+
+ const scopeKey = customScope || 'default';
+ const contextsMap = this.#getContextsMap(tabId);
+ const context = contextsMap[scopeKey];
+
+ if (!context) {
+ this.#log.debug('Context not found', () => ({ tabId, scopeKey }));
+ return null;
+ }
+
+ this.#log.debug('Context retrieved', () => ({
+ tabId,
+ scopeKey,
+ dataKeys: Object.keys(context.data),
+ }));
+
+ return context.data as T;
+ }
+
+ /**
+ * Get a context from the active tab's metadata and remove it.
+ *
+ * Retrieves a preserved navigation context and removes it from the metadata.
+ * Use this when completing a flow to clean up automatically.
+ *
+ * @template T The expected type of the context data
+ * @param customScope Optional custom scope (defaults to 'default')
+ * @returns The context data, or null if not found
+ *
+ * @example
+ * ```typescript
+ * // Get and clear context for default scope
+ * const context = contextService.getAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ *
+ * // Get and clear context for custom scope
+ * const context = contextService.getAndClearContext<{ customerId: number }>('customer-details');
+ * ```
+ */
+ async getAndClearContext<
+ T extends NavigationContextData = NavigationContextData,
+ >(customScope?: string): Promise {
+ const tabId = this.#tabService.activatedTabId();
+ if (tabId === null) {
+ this.#log.debug('No active tab - cannot get and clear context');
+ return null;
+ }
+
+ const scopeKey = customScope || 'default';
+ const contextsMap = this.#getContextsMap(tabId);
+ const context = contextsMap[scopeKey];
+
+ if (!context) {
+ this.#log.debug('Context not found for clearing', () => ({
+ tabId,
+ scopeKey,
+ }));
+ return null;
+ }
+
+ // Remove from map
+ delete contextsMap[scopeKey];
+ this.#saveContextsMap(tabId, contextsMap);
+
+ this.#log.debug('Context retrieved and cleared', () => ({
+ tabId,
+ scopeKey,
+ dataKeys: Object.keys(context.data),
+ remainingContexts: Object.keys(contextsMap).length,
+ }));
+
+ return context.data as T;
+ }
+
+ /**
+ * Clear a specific context from the active tab's metadata.
+ *
+ * Removes a context without returning its data.
+ * Useful for explicit cleanup without needing the data.
+ *
+ * @param customScope Optional custom scope (defaults to 'default')
+ * @returns true if context was found and cleared, false otherwise
+ *
+ * @example
+ * ```typescript
+ * // Clear context for default scope
+ * contextService.clearContext();
+ *
+ * // Clear context for custom scope
+ * contextService.clearContext('customer-details');
+ * ```
+ */
+ async clearContext(customScope?: string): Promise {
+ const result = await this.getAndClearContext(customScope);
+ return result !== null;
+ }
+
+ /**
+ * Clear all contexts for the active tab.
+ *
+ * Removes all contexts from the active tab's metadata.
+ * Useful for cleanup when a workflow is cancelled or completed.
+ *
+ * @returns The number of contexts cleared
+ *
+ * @example
+ * ```typescript
+ * // Clear all contexts for active tab
+ * const cleared = contextService.clearScope();
+ * console.log(`Cleared ${cleared} contexts`);
+ * ```
+ */
+ async clearScope(): Promise {
+ const tabId = this.#tabService.activatedTabId();
+ if (tabId === null) {
+ this.#log.warn('Cannot clear scope: no active tab');
+ return 0;
+ }
+
+ const contextsMap = this.#getContextsMap(tabId);
+ const contextCount = Object.keys(contextsMap).length;
+
+ if (contextCount === 0) {
+ return 0;
+ }
+
+ // Clear entire metadata key
+ this.#tabService.patchTabMetadata(tabId, {
+ [NAVIGATION_CONTEXT_METADATA_KEY]: {},
+ });
+
+ this.#log.debug('Tab scope cleared', () => ({
+ tabId,
+ clearedCount: contextCount,
+ }));
+
+ return contextCount;
+ }
+
+ /**
+ * Clear all contexts from the active tab (alias for clearScope).
+ *
+ * This method is kept for backward compatibility with the previous API.
+ * It clears all contexts for the active tab only, not globally.
+ *
+ * @example
+ * ```typescript
+ * contextService.clearAll();
+ * ```
+ */
+ async clearAll(): Promise {
+ await this.clearScope();
+ this.#log.debug('All contexts cleared for active tab');
+ }
+
+ /**
+ * Check if a context exists for the active tab.
+ *
+ * @param customScope Optional custom scope (defaults to 'default')
+ * @returns true if context exists, false otherwise
+ *
+ * @example
+ * ```typescript
+ * // Check default scope
+ * if (contextService.hasContext()) {
+ * const context = contextService.getContext();
+ * }
+ *
+ * // Check custom scope
+ * if (contextService.hasContext('customer-details')) {
+ * const context = contextService.getContext('customer-details');
+ * }
+ * ```
+ */
+ async hasContext(customScope?: string): Promise {
+ const context = await this.getContext(customScope);
+ return context !== null;
+ }
+
+ /**
+ * Get the current context count for the active tab (for debugging/monitoring).
+ *
+ * @returns The total number of contexts in the active tab's metadata
+ *
+ * @example
+ * ```typescript
+ * const count = await contextService.getContextCount();
+ * console.log(`Active tab has ${count} contexts`);
+ * ```
+ */
+ async getContextCount(): Promise {
+ const tabId = this.#tabService.activatedTabId();
+ if (tabId === null) {
+ return 0;
+ }
+
+ const contextsMap = this.#getContextsMap(tabId);
+ return Object.keys(contextsMap).length;
+ }
+}
diff --git a/libs/core/navigation/src/lib/navigation-context.types.ts b/libs/core/navigation/src/lib/navigation-context.types.ts
new file mode 100644
index 000000000..bada5b424
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-context.types.ts
@@ -0,0 +1,102 @@
+import { z } from 'zod';
+
+/**
+ * Base interface for navigation context data.
+ * Extend this interface for type-safe context preservation.
+ *
+ * @example
+ * ```typescript
+ * interface MyFlowContext extends NavigationContextData {
+ * returnUrl: string;
+ * selectedItems: number[];
+ * }
+ * ```
+ */
+export interface NavigationContextData {
+ [key: string]: unknown;
+}
+
+/**
+ * Navigation context stored in tab metadata.
+ * Represents a single preserved navigation state with metadata.
+ * Stored at: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
+ */
+export interface NavigationContext {
+ /** The preserved navigation state/data */
+ data: NavigationContextData;
+ /** Timestamp when context was created (for debugging and monitoring) */
+ createdAt: number;
+ /**
+ * Optional expiration timestamp (reserved for future TTL implementation)
+ * @deprecated Currently unused - contexts are cleaned up automatically when tabs close
+ */
+ expiresAt?: number;
+}
+
+/**
+ * Zod schema for navigation context data validation.
+ */
+export const NavigationContextDataSchema = z.record(z.string(), z.unknown());
+
+/**
+ * Zod schema for navigation context validation.
+ */
+export const NavigationContextSchema = z.object({
+ data: NavigationContextDataSchema,
+ createdAt: z.number().positive(),
+ expiresAt: z.number().positive().optional(),
+});
+
+/**
+ * Zod schema for navigation contexts stored in tab metadata.
+ * Structure: { [customScope: string]: NavigationContext }
+ *
+ * @example
+ * ```typescript
+ * {
+ * "default": { data: { returnUrl: '/cart' }, createdAt: 123, expiresAt: 456 },
+ * "customer-details": { data: { customerId: 42 }, createdAt: 123 }
+ * }
+ * ```
+ */
+export const NavigationContextsMetadataSchema = z.record(
+ z.string(),
+ NavigationContextSchema
+);
+
+/**
+ * Common navigation context for "return URL" pattern.
+ * Used when navigating through a flow and needing to return to the original location.
+ *
+ * @example
+ * ```typescript
+ * navContextService.preserveContext({
+ * returnUrl: '/original-page'
+ * });
+ * ```
+ */
+export interface ReturnUrlContext extends NavigationContextData {
+ returnUrl: string;
+}
+
+/**
+ * Extended context with additional flow metadata.
+ * Useful for complex multi-step flows that need to preserve additional state.
+ *
+ * @example
+ * ```typescript
+ * interface CheckoutFlowContext extends FlowContext {
+ * returnUrl: string;
+ * selectedProductIds: number[];
+ * shippingAddressId?: number;
+ * }
+ * ```
+ */
+export interface FlowContext extends NavigationContextData {
+ /** Step identifier for multi-step flows */
+ currentStep?: string;
+ /** Total number of steps (if known) */
+ totalSteps?: number;
+ /** Flow-specific metadata */
+ metadata?: Record;
+}
diff --git a/libs/core/navigation/src/lib/navigation-state.service.spec.ts b/libs/core/navigation/src/lib/navigation-state.service.spec.ts
new file mode 100644
index 000000000..db689b3e0
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-state.service.spec.ts
@@ -0,0 +1,227 @@
+import { TestBed } from '@angular/core/testing';
+import { Location } from '@angular/common';
+import { Router } from '@angular/router';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { NavigationStateService } from './navigation-state.service';
+import { NavigationContextService } from './navigation-context.service';
+import { ReturnUrlContext } from './navigation-context.types';
+
+describe('NavigationStateService', () => {
+ let service: NavigationStateService;
+ let locationMock: { getState: ReturnType };
+ let routerMock: { navigate: ReturnType };
+ let contextServiceMock: {
+ setContext: ReturnType;
+ getContext: ReturnType;
+ getAndClearContext: ReturnType;
+ clearContext: ReturnType;
+ hasContext: ReturnType;
+ clearScope: ReturnType;
+ };
+
+ beforeEach(() => {
+ locationMock = {
+ getState: vi.fn(),
+ };
+
+ routerMock = {
+ navigate: vi.fn(),
+ };
+
+ contextServiceMock = {
+ setContext: vi.fn().mockResolvedValue(undefined),
+ getContext: vi.fn().mockResolvedValue(null),
+ getAndClearContext: vi.fn().mockResolvedValue(null),
+ clearContext: vi.fn().mockResolvedValue(false),
+ hasContext: vi.fn().mockResolvedValue(false),
+ clearScope: vi.fn().mockResolvedValue(0),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ NavigationStateService,
+ { provide: Location, useValue: locationMock },
+ { provide: Router, useValue: routerMock },
+ { provide: NavigationContextService, useValue: contextServiceMock },
+ ],
+ });
+
+ service = TestBed.inject(NavigationStateService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ // Context Preservation Methods Tests
+
+ describe('preserveContext', () => {
+ it('should call contextService.setContext with correct parameters', async () => {
+ const data: ReturnUrlContext = { returnUrl: '/test-page' };
+ const scopeKey = 'process-123';
+
+ await service.preserveContext(data, scopeKey);
+
+ expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
+ });
+
+ it('should work without scope key', async () => {
+ const data: ReturnUrlContext = { returnUrl: '/test-page' };
+
+ await service.preserveContext(data);
+
+ expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, undefined);
+ });
+ });
+
+ describe('restoreContext', () => {
+ it('should call contextService.getContext with correct parameters', async () => {
+ const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
+ contextServiceMock.getContext.mockResolvedValue(expectedData);
+
+ const result = await service.restoreContext('scope-123');
+
+ expect(contextServiceMock.getContext).toHaveBeenCalledWith('scope-123');
+ expect(result).toEqual(expectedData);
+ });
+
+ it('should return null when context not found', async () => {
+ contextServiceMock.getContext.mockResolvedValue(null);
+
+ const result = await service.restoreContext();
+
+ expect(result).toBeNull();
+ });
+
+ it('should work without parameters', async () => {
+ const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
+ contextServiceMock.getContext.mockResolvedValue(expectedData);
+
+ const result = await service.restoreContext();
+
+ expect(contextServiceMock.getContext).toHaveBeenCalledWith(undefined);
+ expect(result).toEqual(expectedData);
+ });
+ });
+
+ describe('restoreAndClearContext', () => {
+ it('should call contextService.getAndClearContext with correct parameters', async () => {
+ const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
+ contextServiceMock.getAndClearContext.mockResolvedValue(expectedData);
+
+ const result = await service.restoreAndClearContext('scope-123');
+
+ expect(contextServiceMock.getAndClearContext).toHaveBeenCalledWith('scope-123');
+ expect(result).toEqual(expectedData);
+ });
+
+ it('should return null when context not found', async () => {
+ contextServiceMock.getAndClearContext.mockResolvedValue(null);
+
+ const result = await service.restoreAndClearContext();
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('clearPreservedContext', () => {
+ it('should call contextService.clearContext and return result', async () => {
+ contextServiceMock.clearContext.mockResolvedValue(true);
+
+ const result = await service.clearPreservedContext('scope-123');
+
+ expect(contextServiceMock.clearContext).toHaveBeenCalledWith('scope-123');
+ expect(result).toBe(true);
+ });
+
+ it('should return false when context not found', async () => {
+ contextServiceMock.clearContext.mockResolvedValue(false);
+
+ const result = await service.clearPreservedContext();
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('hasPreservedContext', () => {
+ it('should call contextService.hasContext and return result', async () => {
+ contextServiceMock.hasContext.mockResolvedValue(true);
+
+ const result = await service.hasPreservedContext('scope-123');
+
+ expect(contextServiceMock.hasContext).toHaveBeenCalledWith('scope-123');
+ expect(result).toBe(true);
+ });
+
+ it('should return false when context not found', async () => {
+ contextServiceMock.hasContext.mockResolvedValue(false);
+
+ const result = await service.hasPreservedContext();
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('navigateWithPreservedContext', () => {
+ it('should preserve context and navigate', async () => {
+ const data: ReturnUrlContext = { returnUrl: '/reward/cart', customerId: 123 };
+ const commands = ['/customer/search'];
+ const scopeKey = 'process-123';
+
+ routerMock.navigate.mockResolvedValue(true);
+
+ const result = await service.navigateWithPreservedContext(commands, data, scopeKey);
+
+ expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
+ expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
+ state: data,
+ });
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should merge navigation extras', async () => {
+ const data: ReturnUrlContext = { returnUrl: '/test' };
+ const commands = ['/page'];
+ const extras = { queryParams: { foo: 'bar' } };
+
+ routerMock.navigate.mockResolvedValue(true);
+
+ await service.navigateWithPreservedContext(commands, data, undefined, extras);
+
+ expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
+ queryParams: { foo: 'bar' },
+ state: data,
+ });
+ });
+
+ it('should return false when navigation fails', async () => {
+ const data: ReturnUrlContext = { returnUrl: '/test' };
+ const commands = ['/page'];
+
+ routerMock.navigate.mockResolvedValue(false);
+
+ const result = await service.navigateWithPreservedContext(commands, data);
+
+ expect(result).toEqual({ success: false });
+ });
+ });
+
+ describe('clearScopeContexts', () => {
+ it('should call contextService.clearScope and return count', async () => {
+ contextServiceMock.clearScope.mockResolvedValue(3);
+
+ const result = await service.clearScopeContexts();
+
+ expect(contextServiceMock.clearScope).toHaveBeenCalled();
+ expect(result).toBe(3);
+ });
+
+ it('should return 0 when no contexts cleared', async () => {
+ contextServiceMock.clearScope.mockResolvedValue(0);
+
+ const result = await service.clearScopeContexts();
+
+ expect(result).toBe(0);
+ });
+ });
+});
diff --git a/libs/core/navigation/src/lib/navigation-state.service.ts b/libs/core/navigation/src/lib/navigation-state.service.ts
new file mode 100644
index 000000000..e25bc34d0
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-state.service.ts
@@ -0,0 +1,287 @@
+import { Injectable, inject } from '@angular/core';
+import { Router, NavigationExtras } from '@angular/router';
+import { NavigationContextService } from './navigation-context.service';
+import { NavigationContextData } from './navigation-context.types';
+
+/**
+ * Service for managing navigation context preservation across multi-step flows.
+ *
+ * This service provides automatic context preservation using tab metadata,
+ * allowing navigation state to survive intermediate navigations. Contexts are
+ * automatically scoped to the active tab and cleaned up when the tab closes.
+ *
+ * ## Context Preservation for Multi-Step Flows
+ *
+ * @example
+ * ```typescript
+ * // Start of flow - preserve context (automatically scoped to active tab)
+ * await navigationStateService.preserveContext({
+ * returnUrl: '/reward/cart',
+ * customerId: 123
+ * });
+ *
+ * // ... multiple intermediate navigations happen ...
+ * await router.navigate(['/customer/details']);
+ * await router.navigate(['/add-shipping-address']);
+ *
+ * // End of flow - restore and cleanup (auto-scoped to active tab)
+ * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ * ```
+ *
+ * ## Simplified Navigation with Context
+ *
+ * @example
+ * ```typescript
+ * // Navigate and preserve context in one call
+ * const { success } = await navigationStateService.navigateWithPreservedContext(
+ * ['/customer/search'],
+ * { returnUrl: '/reward/cart' }
+ * );
+ *
+ * // Later, restore and navigate back
+ * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ * ```
+ *
+ * ## Automatic Tab Cleanup
+ *
+ * @example
+ * ```typescript
+ * ngOnDestroy() {
+ * // Clean up all contexts when tab closes (auto-uses active tab ID)
+ * this.navigationStateService.clearScopeContexts();
+ * }
+ * ```
+ *
+ * Key Features:
+ * - ✅ Automatic tab scoping using TabService
+ * - ✅ Stored in tab metadata (automatic cleanup when tab closes)
+ * - ✅ Type-safe with TypeScript generics and Zod validation
+ * - ✅ Automatic cleanup with restoreAndClearContext()
+ * - ✅ Support for multiple custom scopes per tab
+ * - ✅ No manual expiration management needed
+ * - ✅ Platform-agnostic (works with SSR)
+ */
+@Injectable({ providedIn: 'root' })
+export class NavigationStateService {
+ readonly #router = inject(Router);
+ readonly #contextService = inject(NavigationContextService);
+
+ // Context Preservation Methods
+
+ /**
+ * Preserve navigation state for multi-step flows.
+ *
+ * This method stores navigation context in tab metadata, allowing it to
+ * persist across intermediate navigations within a flow. Contexts are automatically
+ * scoped to the active tab, with optional custom scope for different flows.
+ *
+ * Use this when starting a flow that will have intermediate navigations
+ * before returning to the original location.
+ *
+ * @template T The type of state data being preserved
+ * @param state The navigation state to preserve
+ * @param customScope Optional custom scope within the tab (e.g., 'customer-details')
+ *
+ * @example
+ * ```typescript
+ * // Preserve context for default tab scope
+ * await navigationStateService.preserveContext({ returnUrl: '/products' });
+ *
+ * // Preserve context for custom scope within tab
+ * await navigationStateService.preserveContext({ customerId: 42 }, 'customer-details');
+ *
+ * // ... multiple intermediate navigations ...
+ *
+ * // Restore at end of flow
+ * const context = await navigationStateService.restoreContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ * ```
+ */
+ async preserveContext(
+ state: T,
+ customScope?: string,
+ ): Promise {
+ await this.#contextService.setContext(state, customScope);
+ }
+
+ /**
+ * Restore preserved navigation state.
+ *
+ * Retrieves a previously preserved navigation context for the active tab scope,
+ * or a custom scope if specified.
+ *
+ * This method does NOT remove the context - use clearPreservedContext() or
+ * restoreAndClearContext() for automatic cleanup.
+ *
+ * @template T The expected type of the preserved state
+ * @param customScope Optional custom scope (defaults to active tab scope)
+ * @returns The preserved state, or null if not found
+ *
+ * @example
+ * ```typescript
+ * // Restore from default tab scope
+ * const context = navigationStateService.restoreContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * console.log('Returning to:', context.returnUrl);
+ * }
+ *
+ * // Restore from custom scope
+ * const context = navigationStateService.restoreContext<{ customerId: number }>('customer-details');
+ * ```
+ */
+ async restoreContext(
+ customScope?: string,
+ ): Promise {
+ return await this.#contextService.getContext(customScope);
+ }
+
+ /**
+ * Restore and automatically clear preserved navigation state.
+ *
+ * Retrieves a preserved navigation context and removes it from tab metadata in one operation.
+ * Use this when completing a flow to clean up automatically.
+ *
+ * @template T The expected type of the preserved state
+ * @param customScope Optional custom scope (defaults to active tab scope)
+ * @returns The preserved state, or null if not found
+ *
+ * @example
+ * ```typescript
+ * // Restore and clear from default tab scope
+ * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ *
+ * // Restore and clear from custom scope
+ * const context = await navigationStateService.restoreAndClearContext<{ customerId: number }>('customer-details');
+ * ```
+ */
+ async restoreAndClearContext<
+ T extends NavigationContextData = NavigationContextData,
+ >(customScope?: string): Promise {
+ return await this.#contextService.getAndClearContext(customScope);
+ }
+
+ /**
+ * Clear a preserved navigation context.
+ *
+ * Removes a context from tab metadata without returning its data.
+ * Use this for explicit cleanup when you no longer need the preserved state.
+ *
+ * @param customScope Optional custom scope (defaults to active tab scope)
+ * @returns true if context was found and cleared, false otherwise
+ *
+ * @example
+ * ```typescript
+ * // Clear default tab scope context
+ * await navigationStateService.clearPreservedContext();
+ *
+ * // Clear custom scope context
+ * await navigationStateService.clearPreservedContext('customer-details');
+ * ```
+ */
+ async clearPreservedContext(customScope?: string): Promise {
+ return await this.#contextService.clearContext(customScope);
+ }
+
+ /**
+ * Check if a preserved context exists.
+ *
+ * @param customScope Optional custom scope (defaults to active tab scope)
+ * @returns true if context exists, false otherwise
+ *
+ * @example
+ * ```typescript
+ * // Check default tab scope
+ * if (navigationStateService.hasPreservedContext()) {
+ * const context = navigationStateService.restoreContext();
+ * }
+ *
+ * // Check custom scope
+ * if (navigationStateService.hasPreservedContext('customer-details')) {
+ * const context = navigationStateService.restoreContext('customer-details');
+ * }
+ * ```
+ */
+ async hasPreservedContext(customScope?: string): Promise {
+ return await this.#contextService.hasContext(customScope);
+ }
+
+ /**
+ * Navigate while preserving context state.
+ *
+ * Convenience method that combines navigation with context preservation.
+ * The context will be stored in tab metadata and available throughout the
+ * navigation flow and any intermediate navigations. Context is automatically
+ * scoped to the active tab.
+ *
+ * @param commands Navigation commands (same as Router.navigate)
+ * @param state The state to preserve
+ * @param customScope Optional custom scope within the tab
+ * @param extras Optional navigation extras
+ * @returns Promise resolving to navigation success status
+ *
+ * @example
+ * ```typescript
+ * // Navigate and preserve context
+ * const { success } = await navigationStateService.navigateWithPreservedContext(
+ * ['/customer/search'],
+ * { returnUrl: '/reward/cart', customerId: 123 }
+ * );
+ *
+ * // Later, retrieve and navigate back
+ * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
+ * if (context?.returnUrl) {
+ * await router.navigateByUrl(context.returnUrl);
+ * }
+ * ```
+ */
+ async navigateWithPreservedContext(
+ commands: unknown[],
+ state: T,
+ customScope?: string,
+ extras?: NavigationExtras,
+ ): Promise<{ success: boolean }> {
+ await this.preserveContext(state, customScope);
+
+ // Also pass state via router for immediate access
+ const navigationExtras: NavigationExtras = {
+ ...extras,
+ state,
+ };
+
+ const success = await this.#router.navigate(commands, navigationExtras);
+
+ return { success };
+ }
+
+ /**
+ * Clear all preserved contexts for the active tab.
+ *
+ * Removes all contexts for the active tab (both default and custom scopes).
+ * Useful for cleanup when a tab is closed.
+ *
+ * @returns The number of contexts cleared
+ *
+ * @example
+ * ```typescript
+ * // Clear all contexts for active tab
+ * ngOnDestroy() {
+ * const cleared = this.navigationStateService.clearScopeContexts();
+ * console.log(`Cleared ${cleared} contexts`);
+ * }
+ * ```
+ */
+ async clearScopeContexts(): Promise {
+ return await this.#contextService.clearScope();
+ }
+}
diff --git a/libs/core/navigation/src/lib/navigation-state.types.ts b/libs/core/navigation/src/lib/navigation-state.types.ts
new file mode 100644
index 000000000..13dc096fd
--- /dev/null
+++ b/libs/core/navigation/src/lib/navigation-state.types.ts
@@ -0,0 +1,23 @@
+/**
+ * Type definition for navigation state that can be passed through Angular Router.
+ * Use generic type parameter to ensure type safety for your specific state shape.
+ *
+ * @example
+ * ```typescript
+ * interface MyNavigationState extends NavigationState {
+ * returnUrl: string;
+ * customerId: number;
+ * }
+ * ```
+ */
+export interface NavigationState {
+ [key: string]: unknown;
+}
+
+/**
+ * Common navigation state for "return URL" pattern.
+ * Used when navigating to a page and needing to return to the previous location.
+ */
+export interface ReturnUrlNavigationState extends NavigationState {
+ returnUrl: string;
+}
diff --git a/libs/core/navigation/src/test-setup.ts b/libs/core/navigation/src/test-setup.ts
new file mode 100644
index 000000000..cebf5ae72
--- /dev/null
+++ b/libs/core/navigation/src/test-setup.ts
@@ -0,0 +1,13 @@
+import '@angular/compiler';
+import '@analogjs/vitest-angular/setup-zone';
+
+import {
+ BrowserTestingModule,
+ platformBrowserTesting,
+} from '@angular/platform-browser/testing';
+import { getTestBed } from '@angular/core/testing';
+
+getTestBed().initTestEnvironment(
+ BrowserTestingModule,
+ platformBrowserTesting(),
+);
diff --git a/libs/core/navigation/tsconfig.json b/libs/core/navigation/tsconfig.json
new file mode 100644
index 000000000..3268ed4dc
--- /dev/null
+++ b/libs/core/navigation/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "importHelpers": true,
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "preserve"
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "typeCheckHostBindings": true,
+ "strictTemplates": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/libs/core/navigation/tsconfig.lib.json b/libs/core/navigation/tsconfig.lib.json
new file mode 100644
index 000000000..312ee86bb
--- /dev/null
+++ b/libs/core/navigation/tsconfig.lib.json
@@ -0,0 +1,27 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": [
+ "src/**/*.spec.ts",
+ "src/test-setup.ts",
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx"
+ ],
+ "include": ["src/**/*.ts"]
+}
diff --git a/libs/core/navigation/tsconfig.spec.json b/libs/core/navigation/tsconfig.spec.json
new file mode 100644
index 000000000..5785a8a5f
--- /dev/null
+++ b/libs/core/navigation/tsconfig.spec.json
@@ -0,0 +1,29 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "types": [
+ "vitest/globals",
+ "vitest/importMeta",
+ "vite/client",
+ "node",
+ "vitest"
+ ]
+ },
+ "include": [
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ],
+ "files": ["src/test-setup.ts"]
+}
diff --git a/libs/core/navigation/vite.config.mts b/libs/core/navigation/vite.config.mts
new file mode 100644
index 000000000..18ed5d6b9
--- /dev/null
+++ b/libs/core/navigation/vite.config.mts
@@ -0,0 +1,33 @@
+///
+import { defineConfig } from 'vite';
+import angular from '@analogjs/vite-plugin-angular';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
+
+export default
+// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
+defineConfig(() => ({
+ root: __dirname,
+ cacheDir: '../../../node_modules/.vite/libs/core/navigation',
+ plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+ test: {
+ watch: false,
+ globals: true,
+ environment: 'jsdom',
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ setupFiles: ['src/test-setup.ts'],
+ reporters: [
+ 'default',
+ ['junit', { outputFile: '../../../testresults/junit-core-navigation.xml' }],
+ ],
+ coverage: {
+ reportsDirectory: '../../../coverage/libs/core/navigation',
+ provider: 'v8' as const,
+ reporter: ['text', 'cobertura'],
+ },
+ },
+}));
diff --git a/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts b/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts
index b2d730729..fab17d60c 100644
--- a/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts
+++ b/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts
@@ -3,10 +3,10 @@ import { StorageProvider } from './storage-provider';
@Injectable({ providedIn: 'root' })
export class SessionStorageProvider implements StorageProvider {
- async set(key: string, value: unknown): Promise {
+ set(key: string, value: unknown): void {
sessionStorage.setItem(key, JSON.stringify(value));
}
- async get(key: string): Promise {
+ get(key: string): unknown {
const data = sessionStorage.getItem(key);
if (data) {
return JSON.parse(data);
@@ -14,7 +14,7 @@ export class SessionStorageProvider implements StorageProvider {
return data;
}
- async clear(key: string): Promise {
+ clear(key: string): void {
sessionStorage.removeItem(key);
}
}
diff --git a/libs/crm/data-access/src/lib/models/index.ts b/libs/crm/data-access/src/lib/models/index.ts
index 7b1022853..832153a29 100644
--- a/libs/crm/data-access/src/lib/models/index.ts
+++ b/libs/crm/data-access/src/lib/models/index.ts
@@ -4,3 +4,4 @@ export * from './country';
export * from './customer-type';
export * from './customer.model';
export * from './payer';
+export * from './shipping-address.model';
diff --git a/libs/crm/data-access/src/lib/models/shipping-address.model.ts b/libs/crm/data-access/src/lib/models/shipping-address.model.ts
new file mode 100644
index 000000000..a0841926f
--- /dev/null
+++ b/libs/crm/data-access/src/lib/models/shipping-address.model.ts
@@ -0,0 +1,3 @@
+import { ShippingAddressDTO } from '@generated/swagger/crm-api';
+
+export type ShippingAddress = ShippingAddressDTO;
diff --git a/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts b/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts
new file mode 100644
index 000000000..44d5c34cc
--- /dev/null
+++ b/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts
@@ -0,0 +1,54 @@
+import { effect, inject, Injectable, resource, signal } from '@angular/core';
+import { CrmTabMetadataService, ShippingAddressService } from '../services';
+import { TabService } from '@isa/core/tabs';
+import { ShippingAddress } from '../models';
+
+@Injectable()
+export class CustomerShippingAddressResource {
+ #shippingAddressService = inject(ShippingAddressService);
+
+ #params = signal<{
+ shippingAddressId: number | undefined;
+ }>({
+ shippingAddressId: undefined,
+ });
+
+ params(params: { shippingAddressId?: number }) {
+ this.#params.update((p) => ({ ...p, ...params }));
+ }
+
+ readonly resource = resource({
+ params: () => this.#params(),
+ loader: async ({ params, abortSignal }): Promise => {
+ if (!params.shippingAddressId) {
+ return undefined;
+ }
+
+ const res = await this.#shippingAddressService.fetchShippingAddress(
+ {
+ shippingAddressId: params.shippingAddressId,
+ },
+ abortSignal,
+ );
+
+ return res.result as ShippingAddress;
+ },
+ });
+}
+
+@Injectable()
+export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
+ #tabId = inject(TabService).activatedTabId;
+ #customerMetadata = inject(CrmTabMetadataService);
+
+ constructor() {
+ super();
+ effect(() => {
+ const tabId = this.#tabId();
+ const shippingAddressId = tabId
+ ? this.#customerMetadata.selectedShippingAddressId(tabId)
+ : undefined;
+ this.params({ shippingAddressId });
+ });
+ }
+}
diff --git a/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts b/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts
new file mode 100644
index 000000000..a04d1ed7e
--- /dev/null
+++ b/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts
@@ -0,0 +1,58 @@
+import { effect, inject, Injectable, resource, signal } from '@angular/core';
+import { CrmTabMetadataService, ShippingAddressService } from '../services';
+import { TabService } from '@isa/core/tabs';
+import { ShippingAddress } from '../models';
+
+@Injectable()
+export class CustomerShippingAddressesResource {
+ #shippingAddressService = inject(ShippingAddressService);
+
+ #params = signal<{
+ customerId: number | undefined;
+ take?: number | null;
+ skip?: number | null;
+ }>({
+ customerId: undefined,
+ });
+
+ params(params: { customerId?: number; take?: number | null; skip?: number | null }) {
+ this.#params.update((p) => ({ ...p, ...params }));
+ }
+
+ readonly resource = resource({
+ params: () => this.#params(),
+ loader: async ({ params, abortSignal }): Promise => {
+ if (!params.customerId) {
+ return undefined;
+ }
+
+ const res = await this.#shippingAddressService.fetchCustomerShippingAddresses(
+ {
+ customerId: params.customerId,
+ take: params.take,
+ skip: params.skip,
+ },
+ abortSignal,
+ );
+
+ return res.result as ShippingAddress[];
+ },
+ });
+}
+
+@Injectable()
+export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
+ #tabId = inject(TabService).activatedTabId;
+ #customerMetadata = inject(CrmTabMetadataService);
+
+ constructor() {
+ super();
+ effect(() => {
+ const tabId = this.#tabId();
+ const customerId = tabId
+ ? this.#customerMetadata.selectedCustomerId(tabId)
+ : undefined;
+ this.params({ customerId });
+ });
+ }
+}
diff --git a/libs/crm/data-access/src/lib/resources/index.ts b/libs/crm/data-access/src/lib/resources/index.ts
index 6d09a3b33..3e168dc6e 100644
--- a/libs/crm/data-access/src/lib/resources/index.ts
+++ b/libs/crm/data-access/src/lib/resources/index.ts
@@ -1,3 +1,5 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
+export * from './customer-shipping-address.resource';
+export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
diff --git a/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts b/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts
new file mode 100644
index 000000000..f8dfa2445
--- /dev/null
+++ b/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+export const FetchCustomerShippingAddressesSchema = z.object({
+ customerId: z.number().int(),
+ take: z.number().int().optional().nullable(),
+ skip: z.number().int().optional().nullable(),
+});
+
+export type FetchCustomerShippingAddresses = z.infer;
+export type FetchCustomerShippingAddressesInput = z.input;
diff --git a/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts b/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts
new file mode 100644
index 000000000..8f34ef34e
--- /dev/null
+++ b/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const FetchShippingAddressSchema = z.object({
+ shippingAddressId: z.number().int(),
+});
+
+export type FetchShippingAddress = z.infer;
+export type FetchShippingAddressInput = z.input;
diff --git a/libs/crm/data-access/src/lib/schemas/index.ts b/libs/crm/data-access/src/lib/schemas/index.ts
index f5f8c7da0..3d0c1d901 100644
--- a/libs/crm/data-access/src/lib/schemas/index.ts
+++ b/libs/crm/data-access/src/lib/schemas/index.ts
@@ -1,2 +1,4 @@
export * from './fetch-customer-cards.schema';
+export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
+export * from './fetch-shipping-address.schema';
diff --git a/libs/crm/data-access/src/lib/services/index.ts b/libs/crm/data-access/src/lib/services/index.ts
index de716db44..bd55c18f9 100644
--- a/libs/crm/data-access/src/lib/services/index.ts
+++ b/libs/crm/data-access/src/lib/services/index.ts
@@ -1,3 +1,4 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
+export * from './shipping-address.service';
diff --git a/libs/crm/data-access/src/lib/services/shipping-address.service.ts b/libs/crm/data-access/src/lib/services/shipping-address.service.ts
new file mode 100644
index 000000000..d3bcdc762
--- /dev/null
+++ b/libs/crm/data-access/src/lib/services/shipping-address.service.ts
@@ -0,0 +1,79 @@
+import { inject, Injectable } from '@angular/core';
+import { ShippingAddressService as GeneratedShippingAddressService } from '@generated/swagger/crm-api';
+import {
+ FetchCustomerShippingAddressesInput,
+ FetchCustomerShippingAddressesSchema,
+ FetchShippingAddressInput,
+ FetchShippingAddressSchema,
+} from '../schemas';
+import {
+ catchResponseArgsErrorPipe,
+ ListResponseArgs,
+ ResponseArgs,
+ takeUntilAborted,
+} from '@isa/common/data-access';
+import { firstValueFrom } from 'rxjs';
+import { ShippingAddress } from '../models';
+import { logger } from '@isa/core/logging';
+
+@Injectable({ providedIn: 'root' })
+export class ShippingAddressService {
+ #shippingAddressService = inject(GeneratedShippingAddressService);
+ #logger = logger(() => ({
+ service: 'ShippingAddressService',
+ }));
+
+ async fetchCustomerShippingAddresses(
+ params: FetchCustomerShippingAddressesInput,
+ abortSignal?: AbortSignal,
+ ): Promise> {
+ this.#logger.info('Fetching customer shipping addresses from API');
+ const { customerId, take, skip } =
+ FetchCustomerShippingAddressesSchema.parse(params);
+
+ let req$ = this.#shippingAddressService
+ .ShippingAddressGetShippingAddresses({ customerId, take, skip })
+ .pipe(catchResponseArgsErrorPipe());
+
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
+ }
+
+ try {
+ const res = await firstValueFrom(req$);
+ this.#logger.debug('Successfully fetched customer shipping addresses');
+ return res as ListResponseArgs;
+ } catch (error) {
+ this.#logger.error('Error fetching customer shipping addresses', error);
+ return {
+ result: [],
+ totalCount: 0,
+ } as unknown as ListResponseArgs;
+ }
+ }
+
+ async fetchShippingAddress(
+ params: FetchShippingAddressInput,
+ abortSignal?: AbortSignal,
+ ): Promise> {
+ this.#logger.info('Fetching shipping address from API');
+ const { shippingAddressId } = FetchShippingAddressSchema.parse(params);
+
+ let req$ = this.#shippingAddressService
+ .ShippingAddressGetShippingaddress(shippingAddressId)
+ .pipe(catchResponseArgsErrorPipe());
+
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
+ }
+
+ try {
+ const res = await firstValueFrom(req$);
+ this.#logger.debug('Successfully fetched shipping address');
+ return res as ResponseArgs;
+ } catch (error) {
+ this.#logger.error('Error fetching shipping address', error);
+ return undefined as unknown as ResponseArgs;
+ }
+ }
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index e37159182..cbc97288c 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -58,6 +58,7 @@
"@isa/common/print": ["libs/common/print/src/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
+ "@isa/core/navigation": ["libs/core/navigation/src/index.ts"],
"@isa/core/notifications": ["libs/core/notifications/src/index.ts"],
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],