Merged PR 1962: Reward Shopping Cart

Related work items: #5305, #5356, #5357, #5359
This commit is contained in:
Lorenz Hilpert
2025-09-30 14:50:01 +00:00
committed by Nino Righi
parent 9d57ebf376
commit 37840b1565
56 changed files with 2023 additions and 142 deletions

View File

@@ -193,9 +193,24 @@ const routes: Routes = [
children: [
{
path: 'reward',
loadChildren: () =>
import('@isa/checkout/feature/reward-catalog').then((m) => m.routes),
children: [
{
path: '',
loadChildren: () =>
import('@isa/checkout/feature/reward-catalog').then(
(m) => m.routes,
),
},
{
path: 'cart',
loadChildren: () =>
import('@isa/checkout/feature/reward-shopping-cart').then(
(m) => m.routes,
),
},
],
},
{
path: 'return',
loadChildren: () =>

View File

@@ -44,9 +44,6 @@ export class PurchaseOptionsModalService {
return Promise.resolve(undefined);
}
return this.#customerFacade.fetchCustomer(
{ customerId },
new AbortController().signal,
);
return this.#customerFacade.fetchCustomer({ customerId });
}
}

View File

@@ -0,0 +1,183 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
} from '@storybook/angular';
import { AddressComponent, Address } from '@isa/shared/address';
import { CountryResource } from '@isa/crm/data-access';
const meta: Meta<AddressComponent> = {
title: 'shared/address/AddressComponent',
component: AddressComponent,
decorators: [
applicationConfig({
providers: [
{
provide: CountryResource,
useValue: {
resource: {
value: () => [
{ isO3166_A_3: 'DEU', name: 'Germany' },
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
{ isO3166_A_3: 'ITA', name: 'Italy' },
{ isO3166_A_3: 'ESP', name: 'Spain' },
],
},
},
},
],
}),
],
argTypes: {
address: {
control: 'object',
description: 'The address object to display',
},
},
render: (args) => ({
props: args,
template: `<shared-address ${argsToTemplate(args)}></shared-address>`,
}),
};
export default meta;
type Story = StoryObj<AddressComponent>;
export const Default: Story = {
args: {
address: {
careOf: 'John Doe',
street: 'Hauptstraße',
streetNumber: '42',
apartment: 'Apt 3B',
info: 'Building A, 3rd Floor',
zipCode: '10115',
city: 'Berlin',
country: 'DEU',
},
},
};
export const GermanAddress: Story = {
args: {
address: {
street: 'Maximilianstraße',
streetNumber: '15',
zipCode: '80539',
city: 'München',
country: 'DEU',
},
},
};
export const FrenchAddress: Story = {
args: {
address: {
street: 'Rue de la Paix',
streetNumber: '25',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
},
},
};
export const AustrianAddress: Story = {
args: {
address: {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
},
},
};
export const SwissAddress: Story = {
args: {
address: {
street: 'Bahnhofstrasse',
streetNumber: '50',
zipCode: '8001',
city: 'Zürich',
country: 'CHE',
},
},
};
export const WithCareOf: Story = {
args: {
address: {
careOf: 'Maria Schmidt',
street: 'Berliner Straße',
streetNumber: '100',
zipCode: '60311',
city: 'Frankfurt am Main',
country: 'DEU',
},
},
};
export const WithApartment: Story = {
args: {
address: {
street: 'Lindenallee',
streetNumber: '23',
apartment: 'Wohnung 5A',
zipCode: '50668',
city: 'Köln',
country: 'DEU',
},
},
};
export const WithAdditionalInfo: Story = {
args: {
address: {
street: 'Industriestraße',
streetNumber: '7',
info: 'Hintereingang, 2. Stock rechts',
zipCode: '70565',
city: 'Stuttgart',
country: 'DEU',
},
},
};
export const MinimalAddress: Story = {
args: {
address: {
street: 'Dorfstraße',
city: 'Neustadt',
},
},
};
export const CompleteInternational: Story = {
args: {
address: {
careOf: 'Jane Smith',
street: 'Fifth Avenue',
streetNumber: '350',
apartment: 'Suite 2000',
info: 'Empire State Building',
zipCode: '10118',
city: 'New York',
state: 'NY',
country: 'USA',
},
},
};
export const EmptyAddress: Story = {
args: {
address: {},
},
};

View File

@@ -0,0 +1,191 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
} from '@storybook/angular';
import { InlineAddressComponent, Address } from '@isa/shared/address';
import { CountryResource } from '@isa/crm/data-access';
const meta: Meta<InlineAddressComponent> = {
title: 'shared/address/InlineAddressComponent',
component: InlineAddressComponent,
decorators: [
applicationConfig({
providers: [
{
provide: CountryResource,
useValue: {
resource: {
value: () => [
{ isO3166_A_3: 'DEU', name: 'Germany' },
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
{ isO3166_A_3: 'ITA', name: 'Italy' },
{ isO3166_A_3: 'ESP', name: 'Spain' },
],
},
},
},
],
}),
],
argTypes: {
address: {
control: 'object',
description: 'The address object to display in inline format',
},
},
render: (args) => ({
props: args,
template: `<shared-inline-address ${argsToTemplate(args)}></shared-inline-address>`,
}),
};
export default meta;
type Story = StoryObj<InlineAddressComponent>;
export const Default: Story = {
args: {
address: {
street: 'Hauptstraße',
streetNumber: '42',
zipCode: '10115',
city: 'Berlin',
country: 'DEU',
},
},
};
export const GermanAddress: Story = {
args: {
address: {
street: 'Maximilianstraße',
streetNumber: '15',
zipCode: '80539',
city: 'München',
country: 'DEU',
},
},
};
export const FrenchAddress: Story = {
args: {
address: {
street: 'Rue de la Paix',
streetNumber: '25',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
},
},
};
export const AustrianAddress: Story = {
args: {
address: {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
},
},
};
export const SwissAddress: Story = {
args: {
address: {
street: 'Bahnhofstrasse',
streetNumber: '50',
zipCode: '8001',
city: 'Zürich',
country: 'CHE',
},
},
};
export const USAddress: Story = {
args: {
address: {
street: 'Fifth Avenue',
streetNumber: '350',
zipCode: '10118',
city: 'New York',
country: 'USA',
},
},
};
export const ShortAddress: Story = {
args: {
address: {
street: 'Dorfstraße',
streetNumber: '5',
city: 'Neustadt',
},
},
};
export const StreetOnly: Story = {
args: {
address: {
street: 'Hauptstraße',
streetNumber: '10',
},
},
};
export const CityOnly: Story = {
args: {
address: {
zipCode: '12345',
city: 'Beispielstadt',
},
},
};
export const NoCountry: Story = {
args: {
address: {
street: 'Teststraße',
streetNumber: '99',
zipCode: '54321',
city: 'Musterstadt',
},
},
};
export const WithCountryLookup: Story = {
args: {
address: {
street: 'Via Roma',
streetNumber: '10',
zipCode: '00100',
city: 'Roma',
country: 'ITA',
},
},
};
export const SpanishAddress: Story = {
args: {
address: {
street: 'Calle Mayor',
streetNumber: '1',
zipCode: '28013',
city: 'Madrid',
country: 'ESP',
},
},
};
export const EmptyAddress: Story = {
args: {
address: {},
},
};

View File

@@ -4,3 +4,4 @@ export * from './lib/schemas';
export * from './lib/store';
export * from './lib/helpers';
export * from './lib/services';
export * from './lib/resources';

View File

@@ -1,2 +1,7 @@
export * from './constants';
export * from './facades';
export * from './helpers';
export * from './models';
export * from './schemas';
export * from './services';
export * from './store';

View File

@@ -0,0 +1 @@
export * from './shopping-cart.resource';

View File

@@ -0,0 +1,66 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { CheckoutMetadataService } from '../services';
import { ShoppingCartService } from '../services';
@Injectable()
export class ShoppingCartResource {
#shoppingCartService = inject(ShoppingCartService);
#params = signal<{ shoppingCartId: number | undefined }>({
shoppingCartId: undefined,
});
params(params: { shoppingCartId: number | undefined }) {
this.#params.set(params);
}
readonly resource = resource({
params: () => this.#params(),
loader: ({ params, abortSignal }) =>
params?.shoppingCartId
? this.#shoppingCartService.getShoppingCart(
params.shoppingCartId!,
abortSignal,
)
: Promise.resolve(null),
});
}
@Injectable()
export class SelectedShoppingCartResource extends ShoppingCartResource {
#tabId = inject(TabService).activatedTabId;
#checkoutMetadata = inject(CheckoutMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shoppingCartId = tabId
? this.#checkoutMetadata.getShoppingCartId(tabId)
: undefined;
this.params({ shoppingCartId });
});
}
}
@Injectable()
export class SelectedRewardShoppingCartResource extends ShoppingCartResource {
#tabId = inject(TabService).activatedTabId;
#checkoutMetadata = inject(CheckoutMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shoppingCartId = tabId
? this.#checkoutMetadata.getRewardShoppingCartId(tabId)
: undefined;
this.params({ shoppingCartId });
});
}
}

View File

@@ -1,19 +1,14 @@
import { Routes } from '@angular/router';
import { RewardCatalogComponent } from './reward-catalog.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
export const routes: Routes = [
{
path: '',
component: RewardCatalogComponent,
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,
},
},
{
path: '',
redirectTo: '',
pathMatch: 'full',
},
];
import { Routes } from '@angular/router';
import { RewardCatalogComponent } from './reward-catalog.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
export const routes: Routes = [
{
path: '',
component: RewardCatalogComponent,
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,
},
},
];

View File

@@ -0,0 +1,7 @@
# checkout-feature-reward-shopping-cart
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test checkout-feature-reward-shopping-cart` to execute the unit tests.

View File

@@ -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: 'checkout',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'checkout',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "checkout-feature-reward-shopping-cart",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/checkout/feature/reward-shopping-cart/src",
"prefix": "checkout",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../../coverage/libs/checkout/feature/reward-shopping-cart"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/routes';

View File

@@ -0,0 +1,4 @@
:host {
@apply bg-isa-neutral-400 rounded-2xl p-6 text-isa-neutral-900;
@apply flex flex-row items-start justify-start gap-[5rem];
}

View File

@@ -0,0 +1,31 @@
<div class="isa-text-body-1-bold">
<h4 class="isa-text-body-1-regular mb-1">Rechnugsadresse</h4>
<div>
@if (payer(); as payer) {
<div>{{ payerName() }}</div>
<shared-address [address]="payerAddress()" />
} @else {
Keine Rechnungsadresse vorhanden
}
</div>
</div>
<div class="isa-text-body-1-bold">
<h4 class="isa-text-body-1-regular mb-1">Lieferadresse</h4>
<div>
@if (shippingAddress(); as shippingAddress) {
<div>{{ shippingName() }}</div>
<shared-address [address]="shippingAddressAddress()" />
} @else {
Keine Lieferadresse vorhanden
}
</div>
</div>
<div class="flex-grow"></div>
<div class="pt-5">
<ui-icon-button
class="bg-isa-neutral-400"
name="isaActionEdit"
size="large"
color="secondary"
></ui-icon-button>
</div>

View File

@@ -0,0 +1,56 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import {
SelectedCustomerResource,
getCustomerName,
} from '@isa/crm/data-access';
import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { AddressComponent } from '@isa/shared/address';
@Component({
selector: 'checkout-billing-and-shipping-address-card',
templateUrl: './billing-and-shipping-address-card.component.html',
styleUrls: ['./billing-and-shipping-address-card.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent, NgIcon, AddressComponent],
providers: [provideIcons({ isaActionEdit })],
})
export class BillingAndShippingAddressCardComponent {
#customerResource = inject(SelectedCustomerResource).resource;
isLoading = this.#customerResource.isLoading;
customer = computed(() => {
return this.#customerResource.value();
});
payer = computed(() => {
return this.customer();
});
payerName = computed(() => {
return getCustomerName(this.payer());
});
payerAddress = computed(() => {
return this.customer()?.address;
});
shippingAddress = computed(() => {
return this.customer();
});
shippingName = computed(() => {
return getCustomerName(this.shippingAddress());
});
shippingAddressAddress = computed(() => {
return this.shippingAddress()?.address;
});
}

View File

@@ -0,0 +1,16 @@
:host {
@apply bg-isa-neutral-400 rounded-2xl p-6 text-isa-neutral-900;
@apply grid grid-cols-[repeat(3,auto)] gap-6 items-stretch justify-between;
}
.info-block {
@apply flex flex-col justify-between flex-grow-0;
}
.info-block--value {
@apply isa-text-body-2-regular;
}
.info-block--label {
@apply isa-text-body-2-bold;
}

View File

@@ -0,0 +1,18 @@
<div class="info-block">
<div class="info-block--label">{{ customerName() }}</div>
<div class="info-block--value">{{ bonusCardPoints() }} Lesepunkte</div>
</div>
<div class="info-block">
<div class="info-block--label">Prämien ausgewählt</div>
<div class="info-block--value">{{ selectedItems() }}</div>
</div>
<div class="info-block">
<div class="info-block--label">Prämienkosten</div>
<div class="info-block--value">{{ totalPointsRequired() }} Lesepunkte</div>
</div>
<div class="info-block">
<div class="info-block--label">Verbleibende Lesepunkte</div>
<div class="info-block--value">
{{ bonusCardPoints() - totalPointsRequired() }} Lesepunkte
</div>
</div>

View File

@@ -0,0 +1,64 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
getPrimaryBonusCard,
} from '@isa/crm/data-access';
@Component({
selector: 'checkout-customer-reward-card',
templateUrl: './customer-reward-card.component.html',
styleUrls: ['./customer-reward-card.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
})
export class CheckoutCustomerRewardCardComponent {
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#customerResource = inject(SelectedCustomerResource).resource;
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
customerLoading = this.#customerResource.isLoading;
customerName = computed(() => {
const customer = this.#customerResource.value();
if (!customer) {
return 'Kein Kunde ausgewählt';
}
return `${customer.firstName} ${customer.lastName}`;
});
shoppingCartLoading = this.#shoppingCartResource.isLoading;
selectedItems = computed(() => {
const cart = this.#shoppingCartResource.value();
return cart?.items?.length ?? 0;
});
totalPointsRequired = computed(() => {
const cart = this.#shoppingCartResource.value();
if (!cart?.items?.length) {
return 0;
}
const loyalty = cart.items?.map((i) => i.data?.loyalty?.value ?? 0);
return loyalty.reduce((a, b) => a + b, 0);
});
bonusCardsLoading = this.#customerBonusCardsResource.isLoading;
bonusCards = computed(() => {
return this.#customerBonusCardsResource.value() ?? [];
});
bonusCardPoints = computed(() => {
const primary = getPrimaryBonusCard(this.bonusCards());
return primary?.totalPoints ?? 0;
});
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'checkout-reward-shopping-cart-items',
templateUrl: './reward-shopping-cart-items.component.html',
styleUrls: ['./reward-shopping-cart-items.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RewardShoppingCartItemsComponent {}

View File

@@ -0,0 +1,3 @@
:host {
@apply flex flex-col gap-4 py-4;
}

View File

@@ -0,0 +1,15 @@
<tabs-navigate-back-button> </tabs-navigate-back-button>
<div class="flex flex-col gap-2 mb-4 text-center">
<h1 class="isa-text-subtitle-1-regular text-isa-black">Prämienausgabe</h1>
<p class="isa-text-body-2-regular text-isa-secondary-900">
Kontrolliere Sie Lieferart und Versand um die Prämienausgabe abzuschließen.
<br />
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
</p>
<button class="-mt-2" uiTextButton color="strong">
Prämie oder Warenkorb
</button>
</div>
<checkout-customer-reward-card></checkout-customer-reward-card>
<checkout-billing-and-shipping-address-card></checkout-billing-and-shipping-address-card>
<checkout-reward-shopping-cart-items></checkout-reward-shopping-cart-items>

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NavigateBackButtonComponent } from '@isa/core/tabs';
import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component';
import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component';
import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
getPrimaryBonusCard,
} from '@isa/crm/data-access';
@Component({
selector: 'checkout-reward-shopping-cart',
templateUrl: './reward-shopping-cart.component.html',
styleUrls: ['./reward-shopping-cart.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NavigateBackButtonComponent,
TextButtonComponent,
CheckoutCustomerRewardCardComponent,
BillingAndShippingAddressCardComponent,
RewardShoppingCartItemsComponent,
],
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
],
})
export class RewardShoppingCartComponent {}

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
export const routes: Routes = [
{
path: '',
component: RewardShoppingCartComponent,
},
];

View File

@@ -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(),
);

View File

@@ -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"
}
]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
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 defineConfig(() => ({
root: __dirname,
cacheDir: '../node_modules/.vite/checkout-feature-reward-shopping-cart',
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'],
coverage: {
reportsDirectory: '../coverage/checkout-feature-reward-shopping-cart',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,3 +1,4 @@
export * from './lib/navigate-back-button.component';
export * from './lib/tab.injector';
export * from './lib/tab.resolver-fn';
export * from './lib/schemas';

View File

@@ -1,5 +1,6 @@
import z from 'zod';
import { Tab } from './schemas';
import { EntityMap } from '@ngrx/signals/entities';
export function getTabHelper(
tabId: number,
@@ -12,7 +13,7 @@ export function getMetadataHelper<T extends z.ZodTypeAny>(
tabId: number,
key: string,
schema: T,
entities: Record<number, Tab>,
entities: EntityMap<Tab>,
): z.infer<T> | undefined {
const metadata = getTabHelper(tabId, entities)?.metadata;

View File

@@ -0,0 +1,64 @@
import { Component, inject, computed } from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { TabService } from './tab';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'tabs-navigate-back-button',
imports: [NgIcon, ButtonComponent],
providers: [provideIcons({ isaActionChevronLeft })],
template: `
<button
uiButton
color="tertiary"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1"
[class.cursor-not-allowed]="!canNavigateBack()"
data-what="back-button"
(click)="back()"
[disabled]="!canNavigateBack()"
>
<ng-icon
name="isaActionChevronLeft"
size="1.5rem"
class="-ml-2"
></ng-icon>
<span>zurück</span>
</button>
`,
})
export class NavigateBackButtonComponent {
#tabService = inject(TabService);
#router = inject(Router);
canNavigateBack = computed(() => {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
return false;
}
const tab = this.#tabService.entityMap()[tabId];
if (!tab) {
return false;
}
const currentLocation = tab.location;
return currentLocation.current > 0;
});
back() {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
return;
}
const location = this.#tabService.navigateBack(tabId);
if (!location) {
return;
}
this.#router.navigateByUrl(location.url);
}
}

View File

@@ -1,4 +1,6 @@
export * from './lib/facades';
export * from './lib/constants';
export * from './lib/models';
export * from './lib/services';
export * from './lib/facades';
export * from './lib/constants';
export * from './lib/models';
export * from './lib/resources';
export * from './lib/helpers';
export * from './lib/services';

View File

@@ -1,15 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services';
import { FetchCustomerCardsInput } from '../schemas';
@Injectable({ providedIn: 'root' })
export class CustomerCardsFacade {
#crmSearchService = inject(CrmSearchService);
async get(params: FetchCustomerCardsInput, abortSignal: AbortSignal) {
return await this.#crmSearchService.fetchCustomerCards(
{ ...params },
abortSignal,
);
}
}
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services';
import { FetchCustomerCardsInput } from '../schemas';
@Injectable({ providedIn: 'root' })
export class CustomerCardsFacade {
#crmSearchService = inject(CrmSearchService);
async get(params: FetchCustomerCardsInput, abortSignal?: AbortSignal) {
return await this.#crmSearchService.fetchCustomerCards(
{ ...params },
abortSignal,
);
}
}

View File

@@ -9,7 +9,7 @@ export class CustomerFacade {
async fetchCustomer(
params: FetchCustomerInput,
abortSignal: AbortSignal,
abortSignal?: AbortSignal,
): Promise<Customer | undefined> {
const res = await this.#customerService.fetchCustomer(params, abortSignal);
return res.result;

View File

@@ -0,0 +1,8 @@
export function getCustomerName(
customer: { firstName?: string; lastName?: string } | undefined,
): string {
if (!customer) {
return '';
}
return `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim();
}

View File

@@ -0,0 +1,5 @@
import { BonusCardInfo } from '../models';
export function getPrimaryBonusCard(bonusCards: BonusCardInfo[]) {
return bonusCards.find((card) => card.isPrimary);
}

View File

@@ -0,0 +1,2 @@
export * from './get-customer-name.component';
export * from './get-primary-bonus-card.helper';

View File

@@ -0,0 +1,3 @@
import { CountryDTO } from '@generated/swagger/crm-api';
export type Country = CountryDTO;

View File

@@ -1,2 +1,3 @@
export * from './bonus-card-info.model';
export * from './customer.model';
export * from './bonus-card-info.model';
export * from './country';
export * from './customer.model';

View File

@@ -0,0 +1,12 @@
import { inject, Injectable, resource } from '@angular/core';
import { CountryService } from '../services';
@Injectable({ providedIn: 'root' })
export class CountryResource {
#countryService = inject(CountryService);
readonly resource = resource({
loader: async ({ abortSignal }) =>
this.#countryService.getCountries(abortSignal),
});
}

View File

@@ -0,0 +1,49 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmSearchService, CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
@Injectable()
export class CustomerBonusCardsResource {
#customerService = inject(CrmSearchService);
#params = signal<{ customerId: number | undefined }>({
customerId: undefined,
});
params(params: { customerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }) => {
if (!params.customerId) {
return undefined;
}
const res = await this.#customerService.fetchCustomerCards(
{
customerId: params.customerId,
},
abortSignal,
);
return res.result;
},
});
}
@Injectable()
export class SelectedCustomerBonusCardsResource extends CustomerBonusCardsResource {
#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 });
});
}
}

View File

@@ -0,0 +1,59 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmSearchService, CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
@Injectable()
export class CustomerResource {
#customerService = inject(CrmSearchService);
#params = signal<{ customerId: number | undefined; eagerLoading: number }>({
customerId: undefined,
eagerLoading: 3,
});
params(params: { customerId?: number; eagerLoading?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }) => {
if (!params.customerId) {
return undefined;
}
const res = await this.#customerService.fetchCustomer(
{
customerId: params.customerId,
eagerLoading: params.eagerLoading,
},
abortSignal,
);
return res.result;
},
});
}
@Injectable()
export class SelectedCustomerResource extends CustomerResource {
#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 });
});
}
setEagerLoading(eagerLoading: number) {
this.params({ eagerLoading });
}
}

View File

@@ -0,0 +1,3 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
export * from './customer.resource';

View File

@@ -0,0 +1,30 @@
import { CountryService as ApiCountryService } from '@generated/swagger/crm-api';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
import { inject, Injectable } from '@angular/core';
import { Country } from '../models';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class CountryService {
#apiCountryService = inject(ApiCountryService);
@Cache()
async getCountries(abortSignal?: AbortSignal): Promise<Country[]> {
let req$ = this.#apiCountryService.CountryGetCountries({});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
req$ = req$.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res.result as Country[];
}
}

View File

@@ -1,86 +1,74 @@
import { inject, Injectable } from '@angular/core';
import {
CustomerService,
ResponseArgsOfCustomerDTO,
ResponseArgsOfIEnumerableOfBonusCardInfoDTO,
} from '@generated/swagger/crm-api';
import {
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
FetchCustomerInput,
FetchCustomerSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo, Customer } from '../models';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
#customerService = inject(CustomerService);
#logger = logger(() => ({
service: 'CrmSearchService',
}));
async fetchCustomer(
params: FetchCustomerInput,
abortSignal: AbortSignal,
): Promise<ResponseArgs<Customer>> {
this.#logger.info('Fetching customer from API');
const { customerId, eagerLoading } = FetchCustomerSchema.parse(params);
const req$ = this.#customerService
.CustomerGetCustomer({ customerId, eagerLoading })
.pipe(
takeUntilAborted(abortSignal),
catchResponseArgsErrorPipe<ResponseArgsOfCustomerDTO>(),
);
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer');
return res as ResponseArgs<Customer>;
} catch (error:
| ResponseArgsError<ResponseArgsOfCustomerDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching customer', error);
return undefined as unknown as ResponseArgs<Customer>;
}
}
async fetchCustomerCards(
params: FetchCustomerCardsInput,
abortSignal: AbortSignal,
): Promise<ResponseArgs<BonusCardInfo[]>> {
this.#logger.info('Fetching customer bonuscards from API');
const { customerId } = FetchCustomerCardsSchema.parse(params);
const req$ = this.#customerService
.CustomerGetBonuscards(customerId)
.pipe(
takeUntilAborted(abortSignal),
catchResponseArgsErrorPipe<ResponseArgsOfIEnumerableOfBonusCardInfoDTO>(),
);
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer bonuscards');
return res as ResponseArgs<BonusCardInfo[]>;
} catch (error:
| ResponseArgsError<ResponseArgsOfIEnumerableOfBonusCardInfoDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching customer cards', error);
return [] as unknown as ResponseArgs<BonusCardInfo[]>;
}
}
}
import { inject, Injectable } from '@angular/core';
import { CustomerService } from '@generated/swagger/crm-api';
import {
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
FetchCustomerInput,
FetchCustomerSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo, Customer } from '../models';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
#customerService = inject(CustomerService);
#logger = logger(() => ({
service: 'CrmSearchService',
}));
async fetchCustomer(
params: FetchCustomerInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<Customer>> {
this.#logger.info('Fetching customer from API');
const { customerId, eagerLoading } = FetchCustomerSchema.parse(params);
let req$ = this.#customerService
.CustomerGetCustomer({ customerId, eagerLoading })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer');
return res as ResponseArgs<Customer>;
} catch (error) {
this.#logger.error('Error fetching customer', error);
return undefined as unknown as ResponseArgs<Customer>;
}
}
async fetchCustomerCards(
params: FetchCustomerCardsInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<BonusCardInfo[]>> {
this.#logger.info('Fetching customer bonuscards from API');
const { customerId } = FetchCustomerCardsSchema.parse(params);
let req$ = this.#customerService
.CustomerGetBonuscards(customerId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer bonuscards');
return res as ResponseArgs<BonusCardInfo[]>;
} catch (error) {
this.#logger.error('Error fetching customer cards', error);
return [] as unknown as ResponseArgs<BonusCardInfo[]>;
}
}
}

View File

@@ -1,2 +1,3 @@
export * from './crm-tab-metadata.service';
export * from './crm-search.service';
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';

View File

@@ -1,2 +1,3 @@
export * from './lib/address.component';
export * from './lib/inline-address.component';
export * from './lib/types';

View File

@@ -0,0 +1,398 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal, ResourceRef } from '@angular/core';
import { vi } from 'vitest';
import { CountryResource } from '@isa/crm/data-access';
import { AddressComponent } from './address.component';
import { Address } from './types';
describe('AddressComponent', () => {
let component: AddressComponent;
let fixture: ComponentFixture<AddressComponent>;
let mockCountryResource: CountryResource;
beforeEach(async () => {
const mockResourceRef = {
value: vi.fn().mockReturnValue([
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
]),
isLoading: vi.fn().mockReturnValue(false),
hasValue: vi.fn().mockReturnValue(true),
error: vi.fn().mockReturnValue(null),
status: vi.fn().mockReturnValue('Resolved'),
reload: vi.fn(),
} as unknown as ResourceRef<any>;
mockCountryResource = {
resource: mockResourceRef,
} as CountryResource;
await TestBed.configureTestingModule({
imports: [AddressComponent],
providers: [{ provide: CountryResource, useValue: mockCountryResource }],
}).compileComponents();
fixture = TestBed.createComponent(AddressComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Complete address display', () => {
it('should display all address fields including info, careOf, and apartment', () => {
// Arrange
const address: Address = {
careOf: 'John Doe',
street: 'Main Street',
streetNumber: '42',
apartment: 'Apt 3B',
info: 'Building A, 3rd Floor',
zipCode: '12345',
city: 'Berlin',
country: 'DEU',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const careOfLine = compiled.querySelector('[data-which="care-of-line"]');
const streetLine = compiled.querySelector('[data-which="street-line"]');
const info = compiled.querySelector('[data-which="info"]');
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(careOfLine?.textContent?.trim()).toBe('c/o John Doe');
expect(streetLine?.textContent?.trim()).toBe('Main Street 42 Apt 3B');
expect(info?.textContent?.trim()).toBe('Building A, 3rd Floor');
expect(cityLine?.textContent?.trim()).toBe('12345 Berlin');
});
});
describe('German address handling', () => {
it('should not display country for DEU', () => {
// Arrange
const address: Address = {
street: 'Hauptstraße',
streetNumber: '10',
zipCode: '80331',
city: 'München',
country: 'DEU',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('80331 München');
expect(cityLine?.textContent).not.toContain('DEU');
});
it('should display country when country name is "Germany" (not code DEU)', () => {
// Arrange
const address: Address = {
street: 'Berliner Allee',
streetNumber: '5',
zipCode: '40212',
city: 'Düsseldorf',
country: 'Germany',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('40212 Düsseldorf, Germany');
});
});
describe('Non-German address handling', () => {
it('should display country name from country code for non-German addresses', () => {
// Arrange
const address: Address = {
street: 'Rue de la Paix',
streetNumber: '15',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('75002 Paris, France');
});
it('should display country name for Austrian addresses', () => {
// Arrange
const address: Address = {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('1010 Wien, Austria');
});
it('should fallback to country code when country name not found', () => {
// Arrange
const address: Address = {
street: 'Unknown Street',
streetNumber: '1',
zipCode: '12345',
city: 'Unknown City',
country: 'XYZ',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('12345 Unknown City, XYZ');
});
});
describe('Care of line', () => {
it('should display care of line with c/o prefix', () => {
// Arrange
const address: Address = {
careOf: 'Jane Smith',
street: 'Test Street',
streetNumber: '1',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const careOfLine = compiled.querySelector('[data-which="care-of-line"]');
expect(careOfLine?.textContent?.trim()).toBe('c/o Jane Smith');
});
it('should not render care of line when field is empty', () => {
// Arrange
const address: Address = {
street: 'Test Street',
streetNumber: '1',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const careOfLine = compiled.querySelector('[data-which="care-of-line"]');
expect(careOfLine).toBeNull();
});
});
describe('Apartment handling', () => {
it('should include apartment in street line', () => {
// Arrange
const address: Address = {
street: 'Main Street',
streetNumber: '10',
apartment: 'Unit 5A',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const streetLine = compiled.querySelector('[data-which="street-line"]');
expect(streetLine?.textContent?.trim()).toBe('Main Street 10 Unit 5A');
});
});
describe('Address without info', () => {
it('should not render info line when field is empty', () => {
// Arrange
const address: Address = {
street: 'Test Street',
streetNumber: '1',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const info = compiled.querySelector('[data-which="info"]');
expect(info).toBeNull();
});
});
describe('Partial addresses', () => {
it('should handle address with only street', () => {
// Arrange
const address: Address = {
street: 'Incomplete Street',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const streetLine = compiled.querySelector('[data-which="street-line"]');
expect(streetLine?.textContent?.trim()).toBe('Incomplete Street');
});
it('should handle address with only city information', () => {
// Arrange
const address: Address = {
zipCode: '12345',
city: 'City Only',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const cityLine = compiled.querySelector('[data-which="city-line"]');
expect(cityLine?.textContent?.trim()).toBe('12345 City Only');
});
it('should not render lines when fields are missing', () => {
// Arrange
const address: Address = {
info: 'Only info',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const streetLine = compiled.querySelector('[data-which="street-line"]');
const cityLine = compiled.querySelector('[data-which="city-line"]');
const info = compiled.querySelector('[data-which="info"]');
expect(streetLine).toBeNull();
expect(cityLine).toBeNull();
expect(info?.textContent?.trim()).toBe('Only info');
});
});
describe('E2E attributes', () => {
it('should have data-what attribute on main container', () => {
// Arrange
const address: Address = {
street: 'Test Street',
streetNumber: '1',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const mainContainer = compiled.querySelector('[data-what="address-display"]');
expect(mainContainer).toBeTruthy();
});
it('should have data-which attributes on all rendered lines', () => {
// Arrange
const address: Address = {
careOf: 'Jane Doe',
street: 'Test Street',
streetNumber: '1',
info: 'Extra info',
zipCode: '12345',
city: 'Test City',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('[data-which="care-of-line"]')).toBeTruthy();
expect(compiled.querySelector('[data-which="street-line"]')).toBeTruthy();
expect(compiled.querySelector('[data-which="info"]')).toBeTruthy();
expect(compiled.querySelector('[data-which="city-line"]')).toBeTruthy();
});
});
describe('Empty or undefined address', () => {
it('should not render anything when address is undefined', () => {
// Arrange
fixture.componentRef.setInput('address', undefined);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const mainContainer = compiled.querySelector('[data-what="address-display"]');
expect(mainContainer).toBeNull();
});
it('should not render anything when address is empty object', () => {
// Arrange
const address: Address = {};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
const streetLine = compiled.querySelector('[data-which="street-line"]');
const cityLine = compiled.querySelector('[data-which="city-line"]');
const info = compiled.querySelector('[data-which="info"]');
expect(streetLine).toBeNull();
expect(cityLine).toBeNull();
expect(info).toBeNull();
});
});
});

View File

@@ -0,0 +1,84 @@
import { Component, computed, inject, input } from '@angular/core';
import { CountryResource } from '@isa/crm/data-access';
import { Address } from './types';
@Component({
selector: 'shared-address',
standalone: true,
template: `
@if (address(); as a) {
<div class="flex flex-col" data-what="address-display">
@if (careOfLine()) {
<div data-which="care-of-line">
{{ careOfLine() }}
</div>
}
@if (streetLine()) {
<div data-which="street-line">
{{ streetLine() }}
</div>
}
@if (a.info) {
<div data-which="info">
{{ a.info }}
</div>
}
@if (cityLine()) {
<div data-which="city-line">
{{ cityLine() }}
</div>
}
</div>
}
`,
})
export class AddressComponent {
#countryResource = inject(CountryResource);
address = input<Address>();
careOfLine = computed(() => {
const a = this.address();
if (!a || !a.careOf) return '';
return `c/o ${a.careOf}`;
});
streetLine = computed(() => {
const a = this.address();
if (!a) return '';
const parts: string[] = [];
if (a.street) parts.push(a.street);
if (a.streetNumber) parts.push(a.streetNumber);
if (a.apartment) parts.push(a.apartment);
return parts.join(' ');
});
cityLine = computed(() => {
const a = this.address();
if (!a) return '';
const parts: string[] = [];
if (a.zipCode) parts.push(a.zipCode);
if (a.city) parts.push(a.city);
const baseLine = parts.join(' ');
// Display country only if it exists and is not Germany
if (a.country && a.country !== 'DEU') {
const countryName = this.#getCountryName(a.country);
return baseLine ? `${baseLine}, ${countryName}` : countryName;
}
return baseLine;
});
#getCountryName(countryCode: string): string {
const countries = this.#countryResource.resource.value();
if (!countries || !Array.isArray(countries)) return countryCode;
const country = countries.find((c) => c.isO3166_A_3 === countryCode);
return country?.name || countryCode;
}
}

View File

@@ -0,0 +1,223 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResourceRef } from '@angular/core';
import { vi } from 'vitest';
import { CountryResource } from '@isa/crm/data-access';
import { InlineAddressComponent } from './inline-address.component';
import { Address } from './types';
describe('InlineAddressComponent', () => {
let component: InlineAddressComponent;
let fixture: ComponentFixture<InlineAddressComponent>;
let mockCountryResource: CountryResource;
beforeEach(async () => {
const mockResourceRef = {
value: vi.fn().mockReturnValue([
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
]),
isLoading: vi.fn().mockReturnValue(false),
hasValue: vi.fn().mockReturnValue(true),
error: vi.fn().mockReturnValue(null),
status: vi.fn().mockReturnValue('Resolved'),
reload: vi.fn(),
} as unknown as ResourceRef<any>;
mockCountryResource = {
resource: mockResourceRef,
} as CountryResource;
await TestBed.configureTestingModule({
imports: [InlineAddressComponent],
providers: [{ provide: CountryResource, useValue: mockCountryResource }],
}).compileComponents();
fixture = TestBed.createComponent(InlineAddressComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Complete address display', () => {
it('should display address in inline format', () => {
// Arrange
const address: Address = {
street: 'Main Street',
streetNumber: '42',
zipCode: '12345',
city: 'Berlin',
country: 'DEU',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Main Street 42, 12345 Berlin');
});
});
describe('German address handling', () => {
it('should not display country for DEU', () => {
// Arrange
const address: Address = {
street: 'Hauptstraße',
streetNumber: '10',
zipCode: '80331',
city: 'München',
country: 'DEU',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Hauptstraße 10, 80331 München');
expect(compiled.textContent).not.toContain('DEU');
});
});
describe('Non-German address handling', () => {
it('should display country name from country code for non-German addresses', () => {
// Arrange
const address: Address = {
street: 'Rue de la Paix',
streetNumber: '15',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Rue de la Paix 15, 75002 Paris, France');
});
it('should display country name for Austrian addresses', () => {
// Arrange
const address: Address = {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Stephansplatz 1, 1010 Wien, Austria');
});
it('should fallback to country code when country name not found', () => {
// Arrange
const address: Address = {
street: 'Unknown Street',
streetNumber: '1',
zipCode: '12345',
city: 'Unknown City',
country: 'XYZ',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Unknown Street 1, 12345 Unknown City, XYZ');
});
});
describe('Partial addresses', () => {
it('should handle address with only street and number', () => {
// Arrange
const address: Address = {
street: 'Test Street',
streetNumber: '99',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Test Street 99');
});
it('should handle address with only city information', () => {
// Arrange
const address: Address = {
zipCode: '12345',
city: 'City Only',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('12345 City Only');
});
it('should handle address with street and city but no zip', () => {
// Arrange
const address: Address = {
street: 'Main Street',
streetNumber: '5',
city: 'TestCity',
};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('Main Street 5, TestCity');
});
});
describe('Empty or undefined address', () => {
it('should not render anything when address is undefined', () => {
// Arrange
fixture.componentRef.setInput('address', undefined);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('');
});
it('should not render anything when address is empty object', () => {
// Arrange
const address: Address = {};
fixture.componentRef.setInput('address', address);
// Act
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent?.trim()).toBe('');
});
});
});

View File

@@ -1,14 +1,51 @@
import { Component, input, OnInit } from '@angular/core';
import { Component, computed, inject, input } from '@angular/core';
import { CountryResource } from '@isa/crm/data-access';
import { Address } from './types';
@Component({
selector: 'shared-inline-address',
standalone: true,
template: `
@if (address(); as a) {
{{ a.street }} {{ a.streetNumber }}, {{ a.postalCode }} {{ a.city }}
@if (addressLine()) {
{{ addressLine() }}
}
`,
})
export class InlineAddressComponent {
#countryResource = inject(CountryResource);
address = input<Address>();
addressLine = computed(() => {
const a = this.address();
if (!a) return '';
const parts: string[] = [];
// Street and number without comma between them
const streetPart = [a.street, a.streetNumber].filter(Boolean).join(' ');
if (streetPart) parts.push(streetPart);
// Zip and city
const cityPart = [a.zipCode, a.city].filter(Boolean).join(' ');
if (cityPart) parts.push(cityPart);
const baseLine = parts.join(', ');
// Display country only if it exists and is not Germany
if (a.country && a.country !== 'DEU') {
const countryName = this.#getCountryName(a.country);
return baseLine ? `${baseLine}, ${countryName}` : countryName;
}
return baseLine;
});
#getCountryName(countryCode: string): string {
const countries = this.#countryResource.resource.value();
if (!countries || !Array.isArray(countries)) return countryCode;
const country = countries.find((c) => c.isO3166_A_3 === countryCode);
return country?.name || countryCode;
}
}

View File

@@ -1,7 +1,14 @@
export interface Address {
street?: string;
streetNumber?: string;
postalCode?: string;
apartment?: string;
careOf?: string;
city?: string;
country?: string;
district?: string;
info?: string;
po?: string;
region?: string;
state?: string;
street?: string;
streetNumber?: string;
zipCode?: string;
}

View File

@@ -44,6 +44,9 @@
"@isa/checkout/feature/reward-catalog": [
"libs/checkout/feature/reward-catalog/src/index.ts"
],
"@isa/checkout/feature/reward-shopping-cart": [
"libs/checkout/feature/reward-shopping-cart/src/index.ts"
],
"@isa/checkout/shared/product-info": [
"libs/checkout/shared/product-info/src/index.ts"
],