mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1962: Reward Shopping Cart
Related work items: #5305, #5356, #5357, #5359
This commit is contained in:
committed by
Nino Righi
parent
9d57ebf376
commit
37840b1565
@@ -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: () =>
|
||||
|
||||
@@ -44,9 +44,6 @@ export class PurchaseOptionsModalService {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return this.#customerFacade.fetchCustomer(
|
||||
{ customerId },
|
||||
new AbortController().signal,
|
||||
);
|
||||
return this.#customerFacade.fetchCustomer({ customerId });
|
||||
}
|
||||
}
|
||||
|
||||
183
apps/isa-app/stories/shared/address/address.component.stories.ts
Normal file
183
apps/isa-app/stories/shared/address/address.component.stories.ts
Normal 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: {},
|
||||
},
|
||||
};
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
@@ -4,3 +4,4 @@ export * from './lib/schemas';
|
||||
export * from './lib/store';
|
||||
export * from './lib/helpers';
|
||||
export * from './lib/services';
|
||||
export * from './lib/resources';
|
||||
|
||||
@@ -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';
|
||||
|
||||
1
libs/checkout/data-access/src/lib/resources/index.ts
Normal file
1
libs/checkout/data-access/src/lib/resources/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './shopping-cart.resource';
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
7
libs/checkout/feature/reward-shopping-cart/README.md
Normal file
7
libs/checkout/feature/reward-shopping-cart/README.md
Normal 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.
|
||||
34
libs/checkout/feature/reward-shopping-cart/eslint.config.cjs
Normal file
34
libs/checkout/feature/reward-shopping-cart/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/checkout/feature/reward-shopping-cart/project.json
Normal file
20
libs/checkout/feature/reward-shopping-cart/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/checkout/feature/reward-shopping-cart/src/index.ts
Normal file
1
libs/checkout/feature/reward-shopping-cart/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/routes';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-col gap-4 py-4;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: RewardShoppingCartComponent,
|
||||
},
|
||||
];
|
||||
13
libs/checkout/feature/reward-shopping-cart/src/test-setup.ts
Normal file
13
libs/checkout/feature/reward-shopping-cart/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/checkout/feature/reward-shopping-cart/tsconfig.json
Normal file
30
libs/checkout/feature/reward-shopping-cart/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/checkout/feature/reward-shopping-cart/tsconfig.lib.json
Normal file
27
libs/checkout/feature/reward-shopping-cart/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
27
libs/checkout/feature/reward-shopping-cart/vite.config.mts
Normal file
27
libs/checkout/feature/reward-shopping-cart/vite.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
64
libs/core/tabs/src/lib/navigate-back-button.component.ts
Normal file
64
libs/core/tabs/src/lib/navigate-back-button.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getCustomerName(
|
||||
customer: { firstName?: string; lastName?: string } | undefined,
|
||||
): string {
|
||||
if (!customer) {
|
||||
return '';
|
||||
}
|
||||
return `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BonusCardInfo } from '../models';
|
||||
|
||||
export function getPrimaryBonusCard(bonusCards: BonusCardInfo[]) {
|
||||
return bonusCards.find((card) => card.isPrimary);
|
||||
}
|
||||
2
libs/crm/data-access/src/lib/helpers/index.ts
Normal file
2
libs/crm/data-access/src/lib/helpers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './get-customer-name.component';
|
||||
export * from './get-primary-bonus-card.helper';
|
||||
3
libs/crm/data-access/src/lib/models/country.ts
Normal file
3
libs/crm/data-access/src/lib/models/country.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { CountryDTO } from '@generated/swagger/crm-api';
|
||||
|
||||
export type Country = CountryDTO;
|
||||
@@ -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';
|
||||
|
||||
12
libs/crm/data-access/src/lib/resources/country.resource.ts
Normal file
12
libs/crm/data-access/src/lib/resources/country.resource.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
59
libs/crm/data-access/src/lib/resources/customer.resource.ts
Normal file
59
libs/crm/data-access/src/lib/resources/customer.resource.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
3
libs/crm/data-access/src/lib/resources/index.ts
Normal file
3
libs/crm/data-access/src/lib/resources/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './country.resource';
|
||||
export * from './customer-bonus-cards.resource';
|
||||
export * from './customer.resource';
|
||||
30
libs/crm/data-access/src/lib/services/country.service.ts
Normal file
30
libs/crm/data-access/src/lib/services/country.service.ts
Normal 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[];
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/address.component';
|
||||
export * from './lib/inline-address.component';
|
||||
export * from './lib/types';
|
||||
|
||||
398
libs/shared/address/src/lib/address.component.spec.ts
Normal file
398
libs/shared/address/src/lib/address.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
84
libs/shared/address/src/lib/address.component.ts
Normal file
84
libs/shared/address/src/lib/address.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
223
libs/shared/address/src/lib/inline-address.component.spec.ts
Normal file
223
libs/shared/address/src/lib/inline-address.component.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user