Merged PR 658: Merge into develop

Related work items: #1637, #1710, #1719, #1720
This commit is contained in:
Lorenz Hilpert
2021-05-10 16:26:44 +00:00
parent b6b78245e1
commit 59e4f0f1d1
623 changed files with 12034 additions and 20469 deletions

View File

@@ -2296,6 +2296,286 @@
}
}
}
},
"@page/catalog": {
"projectType": "library",
"root": "apps/page/catalog",
"sourceRoot": "apps/page/catalog/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/page/catalog/tsconfig.lib.json",
"project": "apps/page/catalog/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/page/catalog/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/page/catalog/src/test.ts",
"tsConfig": "apps/page/catalog/tsconfig.spec.json",
"karmaConfig": "apps/page/catalog/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/page/catalog/tsconfig.lib.json",
"apps/page/catalog/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@ui/slider": {
"projectType": "library",
"root": "apps/ui/slider",
"sourceRoot": "apps/ui/slider/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/ui/slider/tsconfig.lib.json",
"project": "apps/ui/slider/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/ui/slider/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/ui/slider/src/test.ts",
"tsConfig": "apps/ui/slider/tsconfig.spec.json",
"karmaConfig": "apps/ui/slider/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/ui/slider/tsconfig.lib.json",
"apps/ui/slider/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@ui/stars": {
"projectType": "library",
"root": "apps/ui/stars",
"sourceRoot": "apps/ui/stars/src",
"prefix": "ui",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/ui/stars/tsconfig.lib.json",
"project": "apps/ui/stars/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/ui/stars/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/ui/stars/src/test.ts",
"tsConfig": "apps/ui/stars/tsconfig.spec.json",
"karmaConfig": "apps/ui/stars/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/ui/stars/tsconfig.lib.json",
"apps/ui/stars/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@modal/reviews": {
"projectType": "library",
"root": "apps/modal/reviews",
"sourceRoot": "apps/modal/reviews/src",
"prefix": "ui",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/modal/reviews/tsconfig.lib.json",
"project": "apps/modal/reviews/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/modal/reviews/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/modal/reviews/src/test.ts",
"tsConfig": "apps/modal/reviews/tsconfig.spec.json",
"karmaConfig": "apps/modal/reviews/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/modal/reviews/tsconfig.lib.json",
"apps/modal/reviews/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@modal/availabilities": {
"projectType": "library",
"root": "apps/modal/availabilities",
"sourceRoot": "apps/modal/availabilities/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/modal/availabilities/tsconfig.lib.json",
"project": "apps/modal/availabilities/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/modal/availabilities/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/modal/availabilities/src/test.ts",
"tsConfig": "apps/modal/availabilities/tsconfig.spec.json",
"karmaConfig": "apps/modal/availabilities/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/modal/availabilities/tsconfig.lib.json",
"apps/modal/availabilities/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@modal/images": {
"projectType": "library",
"root": "apps/modal/images",
"sourceRoot": "apps/modal/images/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/modal/images/tsconfig.lib.json",
"project": "apps/modal/images/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/modal/images/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/modal/images/src/test.ts",
"tsConfig": "apps/modal/images/tsconfig.spec.json",
"karmaConfig": "apps/modal/images/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/modal/images/tsconfig.lib.json",
"apps/modal/images/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@swagger/remi": {
"projectType": "library",
"root": "apps/swagger/remi",
"sourceRoot": "apps/swagger/remi/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/swagger/remi/tsconfig.lib.json",
"project": "apps/swagger/remi/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/swagger/remi/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/swagger/remi/src/test.ts",
"tsConfig": "apps/swagger/remi/tsconfig.spec.json",
"karmaConfig": "apps/swagger/remi/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/swagger/remi/tsconfig.lib.json",
"apps/swagger/remi/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "sales"

View File

@@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { AvailabilityService } from './availability.service';
import { DomainAvailabilityService } from './availability.service';
@NgModule({
providers: [AvailabilityService],
providers: [DomainAvailabilityService],
})
export class AvailabilityModule {}

View File

@@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { AvailabilityService } from './availability.service';
import { DomainAvailabilityService } from './availability.service';
describe('AvailabilityService', () => {
let service: AvailabilityService;
let service: DomainAvailabilityService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AvailabilityService);
service = TestBed.inject(DomainAvailabilityService);
});
it('should be created', () => {

View File

@@ -7,17 +7,19 @@ import { StockService } from '@swagger/cat';
import { map, shareReplay, switchMap, withLatestFrom, mergeMap, tap } from 'rxjs/operators';
import { isArray, isNullOrUndefined, memorize } from '@utils/common';
import { OrderService } from '@swagger/oms';
import { RemiService, StockDTO } from '@swagger/remi';
import { ItemData } from './defs/item-data.model';
import { SsoService } from 'sso';
@Injectable({ providedIn: 'root' })
export class AvailabilityService {
export class DomainAvailabilityService {
constructor(
private swaggerAvailabilityService: SwaggerAvailabilityService,
private stockService: StockService,
private storeCheckoutService: StoreCheckoutService,
private orderService: OrderService,
private sso: SsoService
private sso: SsoService,
private remi: RemiService
) {}
@memorize()
@@ -37,57 +39,42 @@ export class AvailabilityService {
}
@memorize()
getCurrentBranch(): Observable<BranchDTO> {
return this.getBranches().pipe(
map((branches) => branches.find((branch) => branch.branchNumber === this.sso.getClaimByKey('branch_no'))),
getCurrentStock(): Observable<StockDTO> {
return this.remi.RemiCurrentStock().pipe(
map((response) => response.result),
shareReplay()
);
}
@memorize()
getCurrentBranch(): Observable<BranchDTO> {
return this.remi.RemiCurrentBranch().pipe(
map((response) => response.result),
shareReplay()
);
}
// TODO: Stock-Aufruf aus Remi API
@memorize({ ttl: 10000 })
getTakeAwayAvailability({
item,
branch,
quantity,
}: {
item: ItemData;
quantity: number;
branch: BranchDTO;
}): Observable<AvailabilityDTO> {
const takeAwaySupplier$ = this.getTakeAwaySupplier();
getTakeAwayAvailability({ item, quantity }: { item: ItemData; quantity: number }): Observable<AvailabilityDTO> {
return this.getCurrentStock().pipe(
switchMap((s) => this.remi.RemiInStock({ articleIds: [item.itemId], stockId: s.id })),
withLatestFrom(this.getTakeAwaySupplier(), this.getCurrentBranch()),
map(([response, supplier, branch]) => {
const stockInfo = response.result.find((si) => si.branchId === branch.id);
const availability: AvailabilityDTO = {
availabilityType: quantity > stockInfo?.inStock ? 1 : 1024, // 1024 (=Available)
inStock: stockInfo?.inStock,
ssc: quantity > stockInfo?.inStock ? '' : '999',
sscText: quantity > stockInfo?.inStock ? '' : 'Filialentnahme',
price: item?.price,
supplier: { id: supplier?.id },
};
return this.stockService
.StockInStock({
itemIds: [item.itemId],
branchIds: [branch.id],
})
.pipe(
map((response) => response.result),
withLatestFrom(takeAwaySupplier$),
map(([stocInfos, supplier]) => {
const stockInfo = stocInfos.find((f) => f.branchId === branch.id && f.itemId === item.itemId);
if (isNullOrUndefined(supplier)) {
return undefined;
}
if (isNullOrUndefined(stockInfo)) {
return undefined;
}
const availability: AvailabilityDTO = {
availabilityType: quantity > stockInfo.inStock ? 1 : 1024, // 1024 (=Available)
inStock: stockInfo.inStock,
ssc: quantity > stockInfo.inStock ? '' : '999',
sscText: quantity > stockInfo.inStock ? '' : 'Filialentnahme',
price: item?.price,
supplier: { id: supplier?.id },
};
return availability;
}),
shareReplay()
);
return availability;
}),
shareReplay()
);
}
@memorize({ ttl: 10000 })

View File

@@ -1,16 +1,18 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainCatalogService } from './catalog.service';
import { ThumbnailUrlPipe } from './thumbnail-url.pipe';
import { DomainCatalogThumbnailService } from './thumbnail.service';
@NgModule({
declarations: [],
declarations: [ThumbnailUrlPipe],
imports: [],
exports: [],
exports: [ThumbnailUrlPipe],
})
export class DomainCatalogModule {
static forRoot(): ModuleWithProviders<DomainCatalogModule> {
return {
ngModule: DomainCatalogModule,
providers: [DomainCatalogService],
providers: [DomainCatalogService, DomainCatalogThumbnailService],
};
}
}

View File

@@ -1,14 +1,117 @@
import { Injectable } from '@angular/core';
import { PromotionService } from '@swagger/cat';
import { ApplicationService } from '@core/application';
import { AutocompleteTokenDTO, PromotionService, QueryTokenDTO, SearchService, StockService } from '@swagger/cat';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
import { map, shareReplay } from 'rxjs/operators';
@Injectable()
export class DomainCatalogService {
constructor(private promotionService: PromotionService) {}
constructor(
private searchService: SearchService,
private promotionService: PromotionService,
private applicationService: ApplicationService,
private stockService: StockService
) {}
@memorize()
getFilters() {
return this.searchService.SearchSearchFilter().pipe(
map((res) => res.result),
shareReplay()
);
}
@memorize()
getOrderBy() {
return this.searchService.SearchSearchSort().pipe(
map((res) => res.result),
shareReplay()
);
}
getSearchHistory({ take }: { take: number }) {
return this.searchService.SearchHistory(take ?? 10).pipe(map((res) => res.result));
}
search({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService.SearchSearch({
queryToken,
stockId: null,
});
}
getDetailsById({ id }: { id: number }) {
return this.searchService.SearchDetail({
id,
stockId: null,
});
}
getDetailsByEan({ ean }: { ean: string }) {
return this.searchService.SearchDetailByEAN({
ean,
stockId: null,
});
}
searchByIds({ ids }: { ids: number[] }) {
return this.searchService.SearchById({
ids,
stockId: null,
});
}
searchByEans({ eans }: { eans: string[] }) {
return this.searchService.SearchByEAN({
stockId: null,
branchNumber: null,
eans,
});
}
searchTop({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService.SearchTop({
stockId: null,
queryToken,
});
}
searchComplete({ queryToken }: { queryToken: AutocompleteTokenDTO }) {
return this.searchService.SearchAutocomplete({
stockId: null,
queryToken,
});
}
@memorize()
getPromotionPoints({ items }: { items: { id: number; quantity: number; price?: number }[] }) {
return this.promotionService.PromotionLesepunkte(items).pipe(shareReplay());
return this.promotionService
.PromotionLesepunkte({
items,
stockId: null,
})
.pipe(shareReplay());
}
@memorize()
getSettings() {
return this.searchService.SearchSettings().pipe(
map((res) => res.result),
shareReplay()
);
}
getRecommendations({ digId }: { digId: number }) {
return this.searchService.SearchGetRecommendations({
digId: digId + '',
sessionId: this.applicationService.activatedProcessId + '',
});
}
getInStock({ branchIds, itemIds }: { branchIds: number[]; itemIds: number[] }) {
return this.stockService.StockInStock({
branchIds,
itemIds,
});
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
import { DomainCatalogThumbnailService } from './thumbnail.service';
@Pipe({
name: 'thumbnailUrl',
pure: false,
})
export class ThumbnailUrlPipe implements PipeTransform, OnDestroy {
private input$ = new BehaviorSubject<{ width?: number; height?: number; ean?: string }>(undefined);
private result: string;
private onDestroy$ = new Subject();
constructor(private domainCatalogThumbnailService: DomainCatalogThumbnailService, private cdr: ChangeDetectorRef) {}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
this.input$.complete();
}
transform(ean: string, width: number, height: number): any {
this.input$.next({ ean, width, height });
this.input$
.pipe(
takeUntil(this.onDestroy$),
switchMap((input) => this.domainCatalogThumbnailService.getThumnaulUrl(input))
)
.subscribe((result) => {
this.result = result;
this.cdr.markForCheck();
});
return this.result;
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { memorize } from '@utils/common';
import { map, shareReplay } from 'rxjs/operators';
import { DomainCatalogService } from './catalog.service';
@Injectable()
export class DomainCatalogThumbnailService {
constructor(private domainCatalogService: DomainCatalogService) {}
@memorize()
getThumnaulUrl({ ean, height, width }: { width?: number; height?: number; ean?: string }) {
return this.domainCatalogService.getSettings().pipe(
map((settings) => {
let thumbnailUrl = settings.imageUrl.replace(/{ean}/, ean);
return thumbnailUrl;
}),
shareReplay()
);
}
}

View File

@@ -3,4 +3,6 @@
*/
export * from './lib/catalog.service';
export * from './lib/thumbnail.service';
export * from './lib/thumbnail-url.pipe';
export * from './lib/catalog.module';

View File

@@ -24,14 +24,12 @@ import {
} from '@swagger/checkout';
import { DisplayOrderDTO, OrderCheckoutService } from '@swagger/oms';
import { isNullOrUndefined, memorize } from '@utils/common';
import { combineLatest, Observable, of, concat, zip, BehaviorSubject, isObservable } from 'rxjs';
import { bufferCount, filter, first, map, mergeMap, shareReplay, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import { combineLatest, Observable, of, concat, isObservable } from 'rxjs';
import { bufferCount, filter, first, map, mergeMap, shareReplay, tap, withLatestFrom } from 'rxjs/operators';
import * as DomainCheckoutSelectors from './store/domain-checkout.selectors';
import * as DomainCheckoutActions from './store/domain-checkout.actions';
import { AvailabilityService } from '@domain/availability';
import { ItemDTO, SearchService } from '@swagger/cat';
import { SsoService } from 'sso';
import { DomainAvailabilityService } from '@domain/availability';
@Injectable()
export class DomainCheckoutService {
@@ -39,9 +37,7 @@ export class DomainCheckoutService {
private store: Store<any>,
private storeCheckoutService: StoreCheckoutService,
private orderCheckoutService: OrderCheckoutService,
private availabilityService: AvailabilityService,
private searchService: SearchService,
private sso: SsoService
private availabilityService: DomainAvailabilityService
) {}
//#region shoppingcart

View File

@@ -0,0 +1,25 @@
# Availabilities
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.1.2.
## Code scaffolding
Run `ng generate component component-name --project availabilities` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project availabilities`.
> Note: Don't forget to add `--project availabilities` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build availabilities` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build availabilities`, go to the dist folder `cd dist/availabilities` and run `npm publish`.
## Running unit tests
Run `ng test availabilities` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../../coverage/modal/availabilities'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/modal/availabilities",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@modal/availabilities",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^10.1.2",
"@angular/core": "^10.1.2"
},
"dependencies": {
"tslib": "^2.0.0"
}
}

View File

@@ -0,0 +1,49 @@
<button class="close-btn" (click)="close()">
<ui-icon icon="close" size="21px"></ui-icon>
</button>
<h1>Weitere Verfügbarkeiten</h1>
<ui-searchbox>
<input #input [ngModel]="search$ | async" uiSearchboxInput type="text" placeholder="PLZ, Ort" (keydown.enter)="filter(input.value)" />
<button
[class.hide]="fetching$ | async"
type="submit"
uiSearchboxSearchButton
(click)="filter(input.value)"
[disabled]="fetching$ | async"
>
<ui-icon class="spin" *ngIf="fetching$ | async" icon="spinner" size="32px"></ui-icon>
<ui-icon *ngIf="!(fetching$ | async)" icon="search" size="24px"></ui-icon>
</button>
<button *ngIf="input.value" type="reset" uiSearchboxClearButton (click)="filter(''); input.value = ''">
<ui-icon icon="close" size="22px"></ui-icon>
</button>
</ui-searchbox>
<p class="subtitle">
<span class="bold">{{ item.product?.name }}</span> ist in den <span class="bold">Umkreisfilialen</span> folgendermaßen verfügbar:
</p>
<hr />
<div class="branches">
<ng-container *ngFor="let availability of availabilities$ | async">
<div class="branch">
<div class="branch-info">
<span class="branch-name">
{{ availability.name }}
</span>
<span class="branch-address">
{{ availability.address.street }} {{ availability.address.streetNumber }}, {{ availability.address.zipCode }}
{{ availability.address.city }}
</span>
</div>
<span class="branch-stock">
<ui-icon icon="home" size="22px"></ui-icon>
{{ availability?.stockInfo?.inStock || 0 }}x
</span>
</div>
<hr />
</ng-container>
</div>

View File

@@ -0,0 +1,73 @@
:host {
@apply flex flex-col relative;
}
h1 {
@apply text-xl font-bold text-center;
}
.close-btn {
@apply absolute right-0 top-0 bg-transparent border-none text-ucla-blue;
}
.subtitle {
@apply flex flex-row justify-center text-regular my-6 whitespace-nowrap;
}
.bold {
@apply font-bold mx-1;
}
ui-searchbox {
@apply max-w-lg mx-auto w-full mt-12;
input {
caret-color: #f70400;
}
[uiSearchboxSearchButton] {
@apply bg-white text-brand;
&.hide {
@apply bg-white;
}
.spin {
@apply bg-white text-ucla-blue animate-spin;
}
}
[uiSearchboxClearButton] {
@apply text-inactive-customer;
}
}
hr {
@apply text-ucla-blue -ml-4;
width: calc(100% + 2rem);
}
.branches {
min-height: 600px;
.branch {
@apply flex flex-row justify-between px-2 py-6;
.branch-info {
.branch-name {
@apply font-bold;
}
.branch-address {
@apply ml-5;
}
}
.branch-stock {
@apply flex flex-row items-center font-bold;
ui-icon {
@apply text-ucla-blue mr-1;
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DomainAvailabilityService } from '@domain/availability';
import { ItemDTO } from '@swagger/cat';
import { BranchDTO } from '@swagger/checkout';
import { UiModalRef } from '@ui/modal';
import { combineLatest } from 'rxjs';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { geoDistance } from '@utils/common';
import { DomainCatalogService } from '@domain/catalog';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'modal-availabilities',
templateUrl: 'availabilities.component.html',
styleUrls: ['availabilities.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalAvailabilitiesComponent {
search$ = new BehaviorSubject('');
fetching$ = new BehaviorSubject(true);
item = this.modalRef.data.item;
userbranch$ = this.domainAvailabilityService.getCurrentBranch();
branches$ = combineLatest([this.domainAvailabilityService.getBranches(), this.userbranch$]).pipe(
filter(([branches, _]) => branches && branches?.length > 0),
map(([branches, userbranch]) =>
branches
.filter((branch) => branch && branch?.isOnline && branch?.isShippingEnabled && branch?.isOrderingEnabled)
.sort((a, b) => this.branchSorterFn(a, b, userbranch))
)
);
inStock$ = this.branches$.pipe(
switchMap((branches) =>
this.domainCatalogService.getInStock({ branchIds: branches.map((b) => b.id), itemIds: [this.item.id] }).pipe(map((res) => res.result))
)
);
availabilities$ = combineLatest([this.inStock$, this.branches$, this.search$]).pipe(
tap(() => this.fetching$.next(true)),
map(([stockInfo, branches, search]) =>
branches
.filter((b) => b.name?.toLowerCase()?.indexOf(search?.toLowerCase()) > -1 || b.address?.zipCode.indexOf(search) > -1)
.map((b) => {
return {
...b,
stockInfo: stockInfo.find((i) => i.branchId === b.id),
};
})
.splice(0, 8)
),
tap(() => this.fetching$.next(false))
);
constructor(
private modalRef: UiModalRef<void, { item: ItemDTO }>,
private domainAvailabilityService: DomainAvailabilityService,
private domainCatalogService: DomainCatalogService
) {}
filter(query: string) {
this.search$.next(query);
}
close() {
this.modalRef.close();
}
private branchSorterFn(a: BranchDTO, b: BranchDTO, userBranch: BranchDTO) {
return (
geoDistance(userBranch?.address?.geoLocation, a?.address?.geoLocation) -
geoDistance(userBranch?.address?.geoLocation, b?.address?.geoLocation)
);
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
import { UiSearchboxModule } from '@ui/searchbox';
import { UiSpinnerModule } from 'apps/ui/spinner/src/lib/ui-spinner.module';
import { ModalAvailabilitiesComponent } from './availabilities.component';
@NgModule({
declarations: [ModalAvailabilitiesComponent],
imports: [CommonModule, FormsModule, UiSearchboxModule, UiIconModule, UiSpinnerModule],
exports: [ModalAvailabilitiesComponent],
})
export class ModalAvailabilitiesModule {}

View File

@@ -0,0 +1,5 @@
/*
* Public API Surface of availabilities
*/
export * from './lib/availabilities.component';
export * from './lib/availabilities.module';

View File

@@ -0,0 +1,24 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -0,0 +1,25 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/lib",
"target": "es2015",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"lib": [
"dom",
"es2018"
]
},
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableResourceInlining": true
},
"exclude": [
"src/test.ts",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,10 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"enableIvy": false
}
}

View File

@@ -0,0 +1,17 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [
true,
"attribute",
"lib",
"camelCase"
],
"component-selector": [
true,
"element",
"lib",
"kebab-case"
]
}
}

View File

@@ -0,0 +1,25 @@
# Images
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.1.2.
## Code scaffolding
Run `ng generate component component-name --project images` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project images`.
> Note: Don't forget to add `--project images` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build images` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build images`, go to the dist folder `cd dist/images` and run `npm publish`.
## Running unit tests
Run `ng test images` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../../coverage/modal/images'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/modal/images",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@modal/images",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^10.1.2",
"@angular/core": "^10.1.2"
},
"dependencies": {
"tslib": "^2.0.0"
}
}

View File

@@ -0,0 +1,25 @@
<button class="close-btn" (click)="close()">
<ui-icon icon="close" size="21px"></ui-icon>
</button>
<h1>{{ title }}</h1>
<div class="image-wrapper">
<img class="image" [src]="activeImage.url" alt="product-image" />
</div>
<div class="thumbnails-wrapper">
<button *ngFor="let image of images" (click)="activeImage = image" [class.selected]="activeImage.url === image.url">
<img class="thumbnail" [src]="image.thumbUrl" />
</button>
</div>
<div class="controls">
<button (click)="prevImage()">
<ui-icon icon="arrow_head" size="22px" rotate="180deg"></ui-icon>
</button>
<span>{{ images.indexOf(activeImage) + 1 }} von {{ images.length }}</span>
<button (click)="nextImage()">
<ui-icon icon="arrow_head" size="22px"></ui-icon>
</button>
</div>

View File

@@ -0,0 +1,54 @@
:host {
@apply flex flex-col relative;
}
h1 {
@apply text-center text-xl;
}
.close-btn {
@apply absolute right-0 top-0 bg-transparent border-none text-ucla-blue;
}
.image-wrapper {
@apply flex flex-row justify-center mt-2;
.image {
max-width: 400px;
max-height: 655px;
}
}
.thumbnails-wrapper {
@apply flex flex-row justify-center mt-8;
button {
@apply border-none bg-transparent outline-none mr-4;
opacity: 0.5;
}
.thumbnail {
max-width: 43px;
max-height: 69px;
}
.selected {
opacity: 1;
}
}
.controls {
@apply flex flex-row justify-center mt-8 mr-4;
button {
@apply bg-transparent outline-none border-none;
ui-icon {
@apply text-ucla-blue;
}
}
span {
@apply text-ucla-blue mx-2;
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ImageDTO } from '@swagger/cat';
import { UiModalRef } from '@ui/modal';
@Component({
selector: 'modal-images',
templateUrl: 'images.component.html',
styleUrls: ['images.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalImagesComponent {
title = this.modalRef.data.title;
images = this.modalRef.data.images;
activeImage = this.modalRef.data.images[0];
constructor(private modalRef: UiModalRef<void, { title: string; images: ImageDTO[] }>) {}
nextImage() {
const index = this.images.indexOf(this.activeImage);
if (index + 1 >= this.images.length) {
this.activeImage = this.images[0];
} else {
this.activeImage = this.images[index + 1];
}
}
prevImage() {
const index = this.images.indexOf(this.activeImage);
if (index - 1 < 0) {
this.activeImage = this.images[this.images.length - 1];
} else {
this.activeImage = this.images[index - 1];
}
}
close() {
this.modalRef.close();
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { UiIconModule } from '@ui/icon';
import { ModalImagesComponent } from './images.component';
@NgModule({
imports: [CommonModule, UiIconModule],
declarations: [ModalImagesComponent],
exports: [ModalImagesComponent],
})
export class ModalImagesModule {}

View File

@@ -0,0 +1,5 @@
/*
* Public API Surface of images
*/
export * from './lib/images.component';
export * from './lib/images.module';

View File

@@ -0,0 +1,24 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -0,0 +1,25 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/lib",
"target": "es2015",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"lib": [
"dom",
"es2018"
]
},
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableResourceInlining": true
},
"exclude": [
"src/test.ts",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,10 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"enableIvy": false
}
}

View File

@@ -0,0 +1,17 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [
true,
"attribute",
"lib",
"camelCase"
],
"component-selector": [
true,
"element",
"lib",
"kebab-case"
]
}
}

View File

@@ -0,0 +1,25 @@
# Reviews
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.1.2.
## Code scaffolding
Run `ng generate component component-name --project reviews` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project reviews`.
> Note: Don't forget to add `--project reviews` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build reviews` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build reviews`, go to the dist folder `cd dist/reviews` and run `npm publish`.
## Running unit tests
Run `ng test reviews` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../../coverage/modal/reviews'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/modal/reviews",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@modal/reviews",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^10.1.2",
"@angular/core": "^10.1.2"
},
"dependencies": {
"tslib": "^2.0.0"
}
}

View File

@@ -0,0 +1,42 @@
<button class="close-btn" (click)="close()">
<ui-icon icon="close" size="21px"></ui-icon>
</button>
<h1>{{ reviews.length }} Rezensionen</h1>
<div class="full-rating">
<ui-stars [rating]="rating" size="18px"></ui-stars>
<span class="label">{{ rating }} Sterne</span>
</div>
<div class="reviews">
<hr />
<ng-container *ngFor="let review of reviews">
<div class="review">
<div class="row">
<ui-stars [rating]="review.rating"></ui-stars>
<span class="title">{{ review.title }}</span>
</div>
<div class="row">
<span class="author"> {{ review.author }} | {{ review.created | date: 'dd.MM.yy' }} </span>
</div>
<div class="row">
<span>
<span class="text" *ngIf="expandIds.indexOf(review.id) === -1">{{ review.text | substr: 150 }}</span>
<span class="text" *ngIf="expandIds.indexOf(review.id) > -1">{{ review.text }}</span>
</span>
</div>
<div class="row right">
<button *ngIf="expandIds.indexOf(review.id) === -1" class="btn-expand" (click)="expand(review.id)">
Mehr
<ui-icon icon="arrow"></ui-icon>
</button>
<button *ngIf="expandIds.indexOf(review.id) > -1" class="btn-collapse" (click)="expand(review.id)">
<ui-icon icon="arrow" rotate="180deg"></ui-icon>
Weniger
</button>
</div>
</div>
<hr />
</ng-container>
</div>

View File

@@ -0,0 +1,54 @@
:host {
@apply flex flex-col relative;
max-height: 850px;
}
h1 {
@apply text-center text-xl;
}
.close-btn {
@apply absolute right-0 top-0 bg-transparent border-none text-ucla-blue;
}
.full-rating {
@apply flex items-center justify-center text-lg font-bold text-ucla-blue mt-5;
.label {
@apply ml-2;
}
}
.reviews {
@apply mt-7 mb-4 overflow-y-auto -ml-4;
width: calc(100% + 2rem);
.review {
@apply mt-5 mx-4;
hr {
@apply mt-2;
}
.row {
@apply flex flex-row items-center mt-1;
}
.right {
@apply justify-end;
}
.title {
@apply ml-5 text-regular font-bold;
}
.btn-expand,
.btn-collapse {
@apply border-none bg-transparent outline-none text-regular text-ucla-blue font-bold -mt-2;
ui-icon {
@apply inline mx-1 align-middle;
}
}
}
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ReviewDTO } from '@swagger/cat';
import { UiModalRef } from '@ui/modal';
@Component({
selector: 'modal-reviews',
templateUrl: 'reviews.component.html',
styleUrls: ['reviews.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalReviewsComponent {
reviews = this.modalRef.data;
rating = Math.floor((this.reviews.map((r) => r.rating).reduce((total, num) => total + num) / this.reviews.length) * 2) / 2;
expandIds: number[] = [];
constructor(private modalRef: UiModalRef<void, ReviewDTO[]>) {}
expand(id: number) {
let index = this.expandIds.indexOf(id);
if (index > -1) {
this.expandIds.splice(index, 1);
} else {
this.expandIds.push(id);
}
}
close() {
this.modalRef.close();
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { UiCommonModule } from '@ui/common';
import { UiIconModule } from '@ui/icon';
import { UiStarsModule } from '@ui/stars';
import { ModalReviewsComponent } from './reviews.component';
@NgModule({
imports: [CommonModule, UiStarsModule, UiCommonModule, UiIconModule],
declarations: [ModalReviewsComponent],
exports: [ModalReviewsComponent],
})
export class ModalReviewsModule {}

View File

@@ -0,0 +1,5 @@
/*
* Public API Surface of reviews
*/
export * from './lib/reviews.component';
export * from './lib/reviews.module';

View File

@@ -0,0 +1,24 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -0,0 +1,25 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/lib",
"target": "es2015",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"lib": [
"dom",
"es2018"
]
},
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableResourceInlining": true
},
"exclude": [
"src/test.ts",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,10 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"enableIvy": false
}
}

View File

@@ -0,0 +1,17 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [
true,
"attribute",
"ui",
"camelCase"
],
"component-selector": [
true,
"element",
"ui",
"kebab-case"
]
}
}

View File

@@ -0,0 +1,25 @@
# Catalog
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.1.2.
## Code scaffolding
Run `ng generate component component-name --project catalog` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project catalog`.
> Note: Don't forget to add `--project catalog` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build catalog` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build catalog`, go to the dist folder `cd dist/catalog` and run `npm publish`.
## Running unit tests
Run `ng test catalog` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../../coverage/page/catalog'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/page/catalog",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@page/catalog",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^10.1.2",
"@angular/core": "^10.1.2"
},
"dependencies": {
"tslib": "^2.0.0"
}
}

View File

@@ -0,0 +1,179 @@
<div class="product-card">
<ng-container *ngIf="store.item$ | async; let item">
<div class="product-details">
<div class="product-image">
<button class="image-button" (click)="showImages()">
<img [src]="item.imageId | productImage: 195:315:true" alt="product image" />
<ui-icon icon="search_add" size="22px"></ui-icon>
</button>
</div>
<div class="product-info">
<div class="row">
<div>
<a
*ngFor="let contributor of contributors$ | async"
class="autor"
[routerLink]="['/product/search']"
[queryParams]="{ query: contributor, primaryFilter: 'author' }"
>
{{ contributor }}
</a>
</div>
<button class="cta-print right" (click)="print()">Drucken</button>
</div>
<div class="title">
{{ item.product?.name }}
</div>
<div class="row">
<div>
<div class="format">
<img class="format-icon" [src]="'/assets/images/Icon_' + item.product?.format + '.svg'" alt="format icon" />
{{ item.product?.formatDetail }}
</div>
<div>{{ item.product?.volume }}</div>
<div>{{ item.product?.publicationDate | date: 'MMMM y' }}</div>
</div>
<div class="right">
<div class="price">
{{ item.catalogAvailability?.price?.value?.value | currency: item.catalogAvailability?.price?.value?.currency:'code' }}
</div>
<div>{{ store.promotionPoints$ | async }} Lesepunkte</div>
</div>
</div>
<div class="row stock">
<div>{{ item.product?.manufacturer }}</div>
<div class="right quantity">
<div class="fetching small" *ngIf="store.fetchingTakeAwayAvailability$ | async"></div>
<ng-container *ngIf="!(store.fetchingTakeAwayAvailability$ | async)">
<ng-container *ngIf="store.takeAwayAvailability$ | async; let takeAwayAvailability">
<ui-icon icon="home" size="22px"></ui-icon>
{{ takeAwayAvailability.inStock || 0 }}x
</ng-container>
</ng-container>
</div>
</div>
<div>
{{ item.product?.locale }}
</div>
<div class="row">
<div>{{ item.product?.ean }}</div>
<div class="right">
<div class="availability-icons">
<div class="fetching medium" *ngIf="fetchingAvailabilities$ | async"></div>
<ng-container *ngIf="!(fetchingAvailabilities$ | async)">
<ui-icon *ngIf="store.isTakeAwayAvailabilityAvailable$ | async" icon="shopping_bag" size="18px"></ui-icon>
<ui-icon *ngIf="store.isPickUpAvailabilityAvailable$ | async" icon="box_out" size="18px"></ui-icon>
<ui-icon class="truck" *ngIf="showDeliveryTruck$ | async" icon="truck" size="30px"></ui-icon>
<ui-icon class="truck_b2b" *ngIf="showDeliveryB2BTruck$ | async" icon="truck_b2b" size="40px"></ui-icon>
<span *ngIf="store.isDownload$ | async" class="download-icon">
<ui-icon icon="download" size="18px"></ui-icon>
<span class="label">Download</span>
</span>
</ng-container>
</div>
</div>
</div>
<div class="shelfinfo right" *ngIf="(store.isDownload$ | async) && item.shelfInfos?.length > 0">
{{ item.shelfInfos[0]?.label }}
</div>
<div class="row">
<div class="specs">
<ng-container *ngIf="item.specs?.length > 0">
{{ item.specs[0]?.value }}
</ng-container>
</div>
<div class="right ssc">
<div class="fetching" *ngIf="fetchingAvailabilities$ | async"></div>
<ng-container *ngIf="!(fetchingAvailabilities$ | async)">
<div *ngIf="store.sscText$ | async; let sscText">
{{ sscText }}
</div>
</ng-container>
</div>
</div>
<div class="shelfinfo right" *ngIf="!(store.isDownload$ | async) && item.shelfInfos?.length > 0">
{{ item.shelfInfos[0]?.label }}
</div>
<div class="recessions" *ngIf="item.reviews?.length > 0">
<ui-stars [rating]="store.reviewRating$ | async"></ui-stars>
<button class="cta-recessions" (click)="showReviews()">{{ item.reviews.length }} Rezensionen</button>
</div>
</div>
</div>
<hr />
<div class="product-description">
<div class="info">
{{ item.texts[0].value }}
</div>
<div *ngIf="expand" class="expanded">
<span *ngFor="let text of item.texts | slice: 1">
<h3 class="header">{{ text.label }}</h3>
{{ text.value }}
</span>
</div>
<div class="right">
<button *ngIf="!expand" class="btn-expand" (click)="expand = true">
Mehr
<ui-icon icon="arrow"></ui-icon>
</button>
</div>
<button *ngIf="expand" class="btn-collapse" (click)="expand = false">
<ui-icon icon="arrow" rotate="180deg"></ui-icon>
Weniger
</button>
</div>
<div class="product-actions">
<button *ngIf="!(store.isDownload$ | async)" class="cta-availabilities" (click)="showAvailabilities()">
weitere Verfügbarkeiten
</button>
<button class="cta-continue" (click)="showPurchasingModal()" [disabled]="fetchingAvailabilities$ | async">In den Warenkorb</button>
</div>
<div class="product-formats" *ngIf="item.family?.length > 0">
<span class="label">Auch verfügbar als</span>
<ui-slider [scrollDistance]="200">
<a class="product-family" *ngFor="let format of item.family" [routerLink]="['/product', 'details', 'ean', format.product.ean]">
<span class="format-detail">
<img [src]="'/assets/images/OF_Icon_' + format.product?.format + '.svg'" alt="format icon" />
{{ format.product?.formatDetail }}
<span class="price">{{ format.catalogAvailability?.price?.value?.value | currency: '€' }}</span>
</span>
</a>
</ui-slider>
</div>
</ng-container>
</div>
<button class="product-recommendations" (click)="showRecommendations = true">
<span class="label">Empfehlungen</span>
<img src="assets/images/recommendation_tag.png" alt="recommendation icon" />
</button>
<div class="recommendations-overlay" @slideYAnimation *ngIf="showRecommendations">
<button class="product-button" (click)="showRecommendations = false">{{ (store.item$ | async)?.product?.name }}</button>
<page-article-recommendations
[product]="(store.item$ | async)?.product"
(close)="showRecommendations = false"
></page-article-recommendations>
</div>

View File

@@ -0,0 +1,251 @@
:host {
@apply flex flex-col;
}
.product-card {
@apply flex flex-col bg-white w-full rounded-card shadow-card;
height: 100%;
min-height: calc(100vh - 345px);
.product-details {
@apply flex flex-row p-5 mb-4;
.product-image {
@apply mr-5;
.image-button {
@apply border-none outline-none bg-transparent;
ui-icon {
@apply relative text-dark-cerulean inline-block;
bottom: 35px;
left: 65px;
}
}
img {
@apply rounded-xl shadow-card;
box-shadow: 0 0 18px 0 #b8b3b7;
max-height: 315px;
max-width: 195px;
}
}
.product-info {
@apply w-full;
.title {
@apply text-3xl font-bold mb-6;
}
.format,
.ssc,
.quantity {
@apply font-bold text-lg;
}
.stock {
min-height: 44px;
}
.quantity {
@apply flex justify-end text-ucla-blue mt-4;
ui-icon {
@apply mr-1;
}
}
.format {
@apply flex items-center;
.format-icon {
@apply flex mr-2;
height: 18px;
}
}
.ssc {
@apply flex justify-end my-2;
}
.price {
@apply font-bold text-xl;
}
.shelfinfo {
@apply text-ucla-blue;
}
.recessions {
@apply flex items-center mt-4;
}
.fetching {
@apply w-52 h-px-20;
background-color: #e6eff9;
animation: load 0.75s linear infinite;
}
.small {
@apply w-16;
}
.medium {
@apply w-40;
}
.availability-icons {
@apply flex flex-row justify-end text-dark-cerulean;
ui-icon {
@apply mx-1;
}
.truck {
@apply -mb-px-5 -mt-px-5;
}
.truck_b2b {
@apply -mb-px-10 -mt-px-10;
}
.label {
@apply font-bold;
}
.download-icon {
@apply flex flex-row items-center;
ui-icon {
@apply inline mt-1;
}
}
}
.cta-print {
@apply bg-transparent text-brand font-bold text-xl outline-none border-none;
}
.cta-recessions {
@apply text-regular text-dark-cerulean font-bold bg-transparent border-none outline-none;
}
}
.row {
@apply grid items-end;
grid-template-columns: auto auto;
}
}
.right {
@apply text-right self-start;
}
hr {
@apply bg-glitter h-1;
}
.product-description {
@apply flex flex-col flex-grow justify-center px-5 py-5;
.info {
@apply whitespace-pre-line;
}
.expanded {
@apply whitespace-pre-line;
.header {
@apply text-regular font-bold;
}
}
.btn-expand,
.btn-collapse {
@apply border-none bg-transparent outline-none text-regular text-ucla-blue font-bold mt-2 p-0;
ui-icon {
@apply inline mx-1 align-middle;
}
}
.btn-collapse {
@apply text-left;
}
}
.product-actions {
@apply text-right px-5 pb-20;
.cta-availabilities {
@apply text-brand border-none border-brand bg-white font-bold text-lg px-4 py-2 rounded-full;
}
.cta-continue {
@apply text-white bg-brand font-bold text-lg px-4 py-2 rounded-full border-none ml-4 no-underline;
&:disabled {
@apply bg-inactive-branch;
}
}
}
.product-formats {
@apply flex flex-row whitespace-nowrap items-center px-5 h-px-40 mb-16 pb-8;
.label {
@apply mr-2;
}
ui-slider {
width: 750px;
::ng-deep button {
height: auto;
}
}
.product-family {
@apply mr-4 text-active-customer font-bold no-underline mt-4;
.format-detail {
@apply flex items-center;
img {
@apply mr-2;
}
}
.price {
@apply ml-1;
}
}
}
}
.product-recommendations {
@apply absolute border-none outline-none bottom-0 left-0 right-0 flex items-center px-5 h-16 bg-white w-full;
box-shadow: #dce2e9 0px -2px 18px 0px;
.label {
@apply uppercase text-active-customer font-bold text-small;
}
img {
@apply absolute right-5 bottom-5 h-12;
}
}
.recommendations-overlay {
@apply absolute w-full bottom-0 rounded-t-card;
.product-button {
@apply flex flex-row justify-center items-center w-full text-xl bg-white text-ucla-blue font-bold border-none outline-none bg-transparent rounded-t-card;
box-shadow: 0 -2px 24px 0 #dce2e9;
height: 60px;
}
}
.autor {
@apply text-active-customer font-bold no-underline;
}

View File

@@ -0,0 +1,203 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ApplicationService } from '@core/application';
import { DomainPrinterService } from '@domain/printer';
import { PrintModalComponent, PrintModalData } from '@modal/printer';
import { AvailabilityDTO } from '@swagger/checkout';
import { UiModalService } from '@ui/modal';
import { ModalReviewsComponent } from '@modal/reviews';
import { PurchasingOptionsModalComponent, PurchasingOptionsModalData } from 'apps/page/checkout/src/lib/modals/purchasing-options-modal';
import { PurchasingOptions } from 'apps/page/checkout/src/lib/modals/purchasing-options-modal/purchasing-options-modal.store';
import { combineLatest, Subscription } from 'rxjs';
import { filter, first, map } from 'rxjs/operators';
import { ArticleDetailsStore } from './article-details.store';
import { ModalImagesComponent } from 'apps/modal/images/src/public-api';
import { ProductImageService } from 'apps/cdn/product-image/src/public-api';
import { ModalAvailabilitiesComponent } from '@modal/availabilities';
import { slideYAnimation } from './slide.animation';
import { BreadcrumbService } from '@core/breadcrumb';
@Component({
selector: 'page-article-details',
templateUrl: 'article-details.component.html',
styleUrls: ['article-details.component.scss'],
changeDetection: ChangeDetectionStrategy.Default,
providers: [ArticleDetailsStore],
animations: [slideYAnimation],
})
export class ArticleDetailsComponent implements OnInit, OnDestroy {
private readonly subscriptions = new Subscription();
expand: boolean;
showRecommendations: boolean;
fetchingAvailabilities$ = combineLatest([
this.store.fetchingDeliveryAvailability$,
this.store.fetchingDeliveryB2BAvailability$,
this.store.fetchingDeliveryDigAvailability$,
this.store.fetchingDownloadAvailability$,
this.store.fetchingPickUpAvailability$,
this.store.fetchingTakeAwayAvailability$,
]).pipe(map((values) => values.some((v) => v)));
showDeliveryTruck$ = combineLatest([
this.store.isDeliveryAvailabilityAvailable$,
this.store.isDeliveryDigAvailabilityAvailable$,
this.store.isDeliveryB2BAvailabilityAvailable$,
]).pipe(map(([delivery, digDelivery, b2bDelivery]) => delivery && digDelivery && b2bDelivery));
showDeliveryB2BTruck$ = combineLatest([
this.store.isPickUpAvailabilityAvailable$,
this.store.isDeliveryDigAvailabilityAvailable$,
this.store.isDeliveryB2BAvailabilityAvailable$,
]).pipe(map(([pickup, digDelivery, b2bDelivery]) => pickup && b2bDelivery && !digDelivery));
contributors$ = this.store.item$.pipe(map((item) => item.product.contributors.split(';').map((m) => m.trim())));
constructor(
private applicationService: ApplicationService,
private activatedRoute: ActivatedRoute,
public readonly store: ArticleDetailsStore,
private domainPrinterService: DomainPrinterService,
private uiModal: UiModalService,
private productImageService: ProductImageService,
private application: ApplicationService,
private breadcrumb: BreadcrumbService
) {}
ngOnInit() {
const id$ = this.activatedRoute.params.pipe(
map((params) => Number(params?.id) || undefined),
filter((f) => !!f)
);
const ean$ = this.activatedRoute.params.pipe(
map((params) => params?.ean || undefined),
filter((f) => !!f)
);
this.subscriptions.add(this.store.loadItemById(id$));
this.subscriptions.add(this.store.loadItemByEan(ean$));
this.subscriptions.add(
this.store.item$.pipe(filter((item) => !!item)).subscribe((item) =>
this.breadcrumb.addBreadcrumbIfNotExists({
key: this.application.activatedProcessId,
name: item.product?.name,
path: `/product/details/${item.id}`,
params: this.activatedRoute.snapshot.queryParams,
tags: ['catalog', 'details', `${item.id}`],
})
)
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
async print() {
const item = await this.store.item$.pipe(first()).toPromise();
this.uiModal.open({
content: PrintModalComponent,
data: {
printerType: 'Label',
print: (printer) => this.domainPrinterService.printProduct({ item, printer }).toPromise(),
} as PrintModalData,
});
}
async showReviews() {
const item = await this.store.item$.pipe(first()).toPromise();
this.uiModal.open({
content: ModalReviewsComponent,
data: item.reviews,
});
}
async showAvailabilities() {
const item = await this.store.item$.pipe(first()).toPromise();
this.uiModal.open({
content: ModalAvailabilitiesComponent,
data: {
item,
},
});
}
async showImages() {
const item = await this.store.item$.pipe(first()).toPromise();
const images = item.images || [];
images.unshift({
url: this.productImageService.getImageUrl({
imageId: item.imageId,
width: 400,
height: 655,
}),
thumbUrl: this.productImageService.getImageUrl({
imageId: item.imageId,
width: 43,
height: 69,
}),
});
this.uiModal.open({
content: ModalImagesComponent,
data: {
title: item.product.name,
images,
},
});
}
async showPurchasingModal() {
let availableOptions: PurchasingOptions[] = [];
const availabilities: { [key: string]: AvailabilityDTO } = {};
const takeNow = await this.store.isTakeAwayAvailabilityAvailable$.pipe(first()).toPromise();
if (takeNow) {
availableOptions.push('take-away');
availabilities['take-away'] = await this.store.takeAwayAvailability$.pipe(first()).toPromise();
}
const download = await this.store.isDownloadAvailabilityAvailable$.pipe(first()).toPromise();
if (download) {
availableOptions.push('download');
availabilities['download'] = await this.store.downloadAvailability$.pipe(first()).toPromise();
}
const pickup = await this.store.isPickUpAvailabilityAvailable$.pipe(first()).toPromise();
if (pickup) {
availableOptions.push('pick-up');
availableOptions.push('b2b-delivery');
availabilities['pick-up'] = await this.store.pickUpAvailability$.pipe(first()).toPromise();
if (await this.store.isDeliveryB2BAvailabilityAvailable$.pipe(first()).toPromise()) {
availabilities['b2b-delivery'] = await this.store.deliveryB2BAvailability$.pipe(first()).toPromise();
}
}
const digDelivery = await this.store.isDeliveryDigAvailabilityAvailable$.pipe(first()).toPromise();
if (digDelivery) {
availableOptions.push('dig-delivery');
availabilities['dig-delivery'] = await this.store.deliveryDigAvailability$.pipe(first()).toPromise();
}
if (availableOptions.includes('dig-delivery') && availableOptions.includes('b2b-delivery')) {
availableOptions.push('delivery');
availabilities['delivery'] = await this.store.deliveryAvailability$.pipe(first()).toPromise();
availableOptions = availableOptions.filter((option) => !(option === 'dig-delivery' || option === 'b2b-delivery'));
}
const branch = await this.store.branch$.pipe(first()).toPromise();
this.uiModal.open({
content: PurchasingOptionsModalComponent,
data: {
availableOptions,
item: await this.store.item$.pipe(first()).toPromise(),
branchNo: branch?.branchNumber,
processId: this.applicationService.activatedProcessId,
availabilities,
} as PurchasingOptionsModalData,
});
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArticleDetailsComponent } from './article-details.component';
import { ProductImageModule } from 'apps/cdn/product-image/src/public-api';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
import { UiStarsModule } from '@ui/stars';
import { UiSliderModule } from '@ui/slider';
import { ArticleRecommendationsComponent } from './recommendations/article-recommendations.component';
@NgModule({
imports: [CommonModule, ProductImageModule, UiIconModule, RouterModule, UiStarsModule, UiSliderModule],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
})
export class ArticleDetailsModule {}

View File

@@ -0,0 +1,270 @@
import { Injectable } from '@angular/core';
import { DomainAvailabilityService, ItemData } from '@domain/availability';
import { DomainCatalogService } from '@domain/catalog';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { ItemDTO, ResponseArgsOfItemDTO } from '@swagger/cat';
import { combineLatest, Observable, of } from 'rxjs';
import { catchError, filter, map, shareReplay, switchMap, tap } from 'rxjs/operators';
export interface ArticleDetailsState {
fetchingItem?: boolean;
fetchingItemError?: string;
item?: ItemDTO;
fetchingPromotionPoints?: boolean;
fetchingPromotionPointsError?: string;
fetchingTakeAwayAvailability?: boolean;
fetchingTakeAwayAvailabilityError?: string;
fetchingPickUpAvailability?: boolean;
fetchingPickUpAvailabilityError?: string;
fetchingDownloadAvailability?: boolean;
fetchingDownloadAvailabilityError?: string;
fetchingDeliveryAvailability?: boolean;
fetchingDeliveryAvailabilityError?: string;
fetchingDeliveryDigAvailability?: boolean;
fetchingDeliveryDigAvailabilityError?: string;
fetchingDeliveryB2BAvailability?: boolean;
fetchingDeliveryB2BAvailabilityError?: string;
}
@Injectable()
export class ArticleDetailsStore extends ComponentStore<ArticleDetailsState> {
//#region Artikel
readonly fetchingItem$ = this.select((s) => s.fetchingItem);
readonly item$ = this.select((s) => s.item);
readonly itemData$ = this.select(this.item$, (item) =>
!!item ? ({ ean: item?.product?.ean, itemId: item?.id, price: item?.catalogAvailability?.price } as ItemData) : undefined
);
//#endregion
readonly isDownload$ = this.select(this.item$, (item) => !!(item?.product?.format === 'EB' || item?.product?.format === 'DL'));
readonly branch$ = this.domainAvailabilityService.getCurrentBranch();
readonly reviewRating$ = this.item$.pipe(
filter((i) => !!i),
map((i) => (i.reviews?.length > 0 ? i.reviews.map((r) => r.rating).reduce((total, num) => total + num) / i.reviews.length : 0))
);
readonly recommendations$ = this.item$.pipe(
filter((item) => !!item),
switchMap((item) => this.domainCatalogService.getRecommendations({ digId: item.ids['dig'] }).pipe(map((res) => res.result)))
);
//#region Lesepunkte
readonly fetchingPromotionPoints$ = this.select((s) => s.fetchingPromotionPoints);
readonly promotionPoints$ = this.item$.pipe(
tap(() => this.patchState({ fetchingPromotionPoints: true, fetchingPromotionPointsError: undefined })),
switchMap((item) =>
!!item
? this.domainCatalogService
.getPromotionPoints({
items: [{ id: item.id, quantity: 1, price: item.catalogAvailability.price.value.value }],
})
.pipe(
map((res) => res.result[item.id]),
catchError((err) => {
console.error('getPromotionPoints failed.', err);
this.patchState({ fetchingPromotionPointsError: 'Fehler beim laden der Lesepukte.' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingPromotionPoints: false })),
shareReplay()
);
//#endregion
//#region Filialverfügbarkeit
readonly fetchingTakeAwayAvailability$ = this.select((s) => s.fetchingTakeAwayAvailability);
readonly takeAwayAvailability$ = combineLatest([this.itemData$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingTakeAwayAvailability: true, fetchingTakeAwayAvailabilityError: undefined })),
switchMap(([item, isDownload]) =>
!!item && !isDownload
? this.domainAvailabilityService.getTakeAwayAvailability({ item, quantity: 1 }).pipe(
catchError((err) => {
console.error('getTakeAwayAvailability failed.', err);
this.patchState({ fetchingTakeAwayAvailabilityError: 'Fehler beim laden der Filialverfügbarkeit.' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingTakeAwayAvailability: false })),
shareReplay()
);
readonly isTakeAwayAvailabilityAvailable$ = this.select(this.takeAwayAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
//#region Abholung
readonly fetchingPickUpAvailability$ = this.select((s) => s.fetchingPickUpAvailability);
readonly pickUpAvailability$ = combineLatest([this.itemData$, this.branch$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingPickUpAvailability: true, fetchingPickUpAvailabilityError: undefined })),
switchMap(([item, branch, isDownload]) =>
!!item && !!branch && !isDownload
? this.domainAvailabilityService.getPickUpAvailability({ item, branch, quantity: 1 }).pipe(
catchError((err) => {
console.error('getPickUpAvailability failed.', err);
this.patchState({ fetchingPickUpAvailabilityError: 'Fehler beim laden der Abholung.' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingPickUpAvailability: false })),
shareReplay()
);
readonly isPickUpAvailabilityAvailable$ = this.select(this.pickUpAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
//#region Download
readonly fetchingDownloadAvailability$ = this.select((s) => s.fetchingDownloadAvailability);
readonly downloadAvailability$ = combineLatest([this.itemData$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingDownloadAvailability: true, fetchingDownloadAvailabilityError: undefined })),
switchMap(([item, isDownload]) =>
!!item && !!isDownload
? this.domainAvailabilityService.getDownloadAvailability({ item }).pipe(
catchError((err) => {
console.error('getDownloadAvailability failed', err);
this.patchState({ fetchingDownloadAvailabilityError: 'Fehler beim Laden des Downloads' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingDownloadAvailability: false })),
shareReplay()
);
readonly isDownloadAvailabilityAvailable$ = this.select(this.downloadAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
//#region Versandverfügbarkeit
readonly fetchingDeliveryAvailability$ = this.select((s) => s.fetchingDeliveryAvailability);
readonly deliveryAvailability$ = combineLatest([this.itemData$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingDeliveryAvailability: true, fetchingDeliveryAvailabilityError: undefined })),
switchMap(([item, isDownload]) =>
!!item && !isDownload
? this.domainAvailabilityService.getDeliveryAvailability({ item, quantity: 1 }).pipe(
catchError((err) => {
console.error('getDeliveryAvailability failed', err);
this.patchState({ fetchingDeliveryAvailabilityError: 'Fehler beim Laden der Versandbestellung' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingDeliveryAvailability: false })),
shareReplay()
);
readonly isDeliveryAvailabilityAvailable$ = this.select(this.deliveryAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
//#region DIG Versandverfügbarkeit
readonly fetchingDeliveryDigAvailability$ = this.select((s) => s.fetchingDeliveryDigAvailability);
readonly deliveryDigAvailability$ = combineLatest([this.itemData$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingDeliveryDigAvailability: true, fetchingDeliveryDigAvailabilityError: undefined })),
switchMap(([item, isDownload]) =>
!!item && !isDownload
? this.domainAvailabilityService.getDigDeliveryAvailability({ item, quantity: 1 }).pipe(
catchError((err) => {
console.error('getDigDeliveryAvailability failed', err);
this.patchState({ fetchingDeliveryDigAvailabilityError: 'Fehler beim Laden der DIG-Versandbestellung' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingDeliveryDigAvailability: false })),
shareReplay()
);
readonly isDeliveryDigAvailabilityAvailable$ = this.select(this.deliveryDigAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
//#region B2B Versandverfügbarkeit
readonly fetchingDeliveryB2BAvailability$ = this.select((s) => s.fetchingDeliveryB2BAvailability);
readonly deliveryB2BAvailability$ = combineLatest([this.itemData$, this.isDownload$]).pipe(
tap(() => this.patchState({ fetchingDeliveryB2BAvailability: true, fetchingDeliveryB2BAvailabilityError: undefined })),
switchMap(([item, isDownload]) =>
!!item && !isDownload
? this.domainAvailabilityService.getB2bDeliveryAvailability({ item, quantity: 1 }).pipe(
catchError((err) => {
console.error('getB2BDeliveryAvailability failed', err);
this.patchState({ fetchingDeliveryB2BAvailabilityError: 'Fehler beim Laden der B2B-Versandbestellung' });
return of(undefined);
})
)
: of(undefined)
),
tap(() => this.patchState({ fetchingDeliveryB2BAvailability: false })),
shareReplay()
);
readonly isDeliveryB2BAvailabilityAvailable$ = this.select(this.deliveryB2BAvailability$, (availability) =>
this.domainAvailabilityService.isAvailable({ availability })
);
//#endregion
readonly sscText$ = combineLatest([
this.isDownload$,
this.pickUpAvailability$,
this.deliveryDigAvailability$,
this.downloadAvailability$,
]).pipe(
map(([isDownload, pickupAvailability, deliveryDigAvailability, downloadAvailability]) => {
const availability = isDownload ? downloadAvailability : pickupAvailability || deliveryDigAvailability;
return availability ? `${availability.ssc} - ${availability.sscText}` : '';
})
);
constructor(
private readonly domainCatalogService: DomainCatalogService,
private readonly domainAvailabilityService: DomainAvailabilityService
) {
super({});
}
readonly loadItemById = this.effect((id$: Observable<number>) =>
id$.pipe(
filter((id) => !!id),
tap(() => this.patchState({ fetchingItem: true, item: undefined })),
switchMap((id) => this.domainCatalogService.getDetailsById({ id })),
tapResponse<ResponseArgsOfItemDTO>(
(response) => this.patchState({ item: response.result, fetchingItem: false }),
(err) => {
console.log('loadItemById failed', err);
this.patchState({ item: undefined, fetchingItem: false });
}
)
)
);
readonly loadItemByEan = this.effect((ean$: Observable<string>) =>
ean$.pipe(
filter((ean) => !!ean),
tap(() => this.patchState({ fetchingItem: true, item: undefined })),
switchMap((ean) => this.domainCatalogService.getDetailsByEan({ ean })),
tapResponse<ResponseArgsOfItemDTO>(
(response) => this.patchState({ item: response.result, fetchingItem: false }),
(err) => {
console.log('loadItemByEan failed', err);
this.patchState({ item: undefined, fetchingItem: false });
}
)
)
);
}

View File

@@ -0,0 +1,25 @@
<ng-container *ngIf="store.item$ | async; let item">
<h1>Empfehlungen für Sie</h1>
<p>Neben dem Titel "{{ item.product?.name }}" gibt es noch andere Artikel, die Sie interessieren könnten.</p>
<div class="articles">
<span class="label">
<ui-icon icon="recommendation" size="20px"></ui-icon>
Artikel
</span>
<ui-slider [scrollDistance]="210">
<a
class="article"
*ngFor="let recommendation of store.recommendations$ | async"
[routerLink]="['/product', 'details', 'ean', recommendation.product.ean]"
(click)="close.emit()"
>
<img [src]="recommendation.images[0]?.url" alt="product-image" />
<span class="format">{{ recommendation.product?.formatDetail }}</span>
<span class="price">{{ recommendation.catalogAvailability?.price?.value?.value | currency: ' ' }} EUR</span>
</a>
</ui-slider>
</div>
</ng-container>

View File

@@ -0,0 +1,48 @@
:host {
@apply flex flex-col bg-white;
box-shadow: 0px -2px 24px 0px #dce2e9;
height: calc(100vh - 342px);
}
h1 {
@apply text-center mt-12;
font-size: 26px;
}
p {
@apply text-center mx-12 mt-2;
font-size: 22px;
}
.articles {
@apply mt-10;
.label {
@apply flex flex-row items-center uppercase text-dark-cerulean font-bold text-sm;
margin-left: 3.25rem;
ui-icon {
@apply mr-2;
}
}
.article {
@apply flex flex-col mr-7 mt-4 no-underline text-black;
img {
@apply rounded-xl;
height: 310px;
width: 190px;
box-shadow: 0 0 15px #949393;
}
.format {
@apply text-regular mt-2;
}
.price {
@apply font-bold;
font-size: 20px;
}
}
}

View File

@@ -0,0 +1,14 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { ArticleDetailsStore } from '../article-details.store';
@Component({
selector: 'page-article-recommendations',
templateUrl: 'article-recommendations.component.html',
styleUrls: ['article-recommendations.component.scss'],
})
export class ArticleRecommendationsComponent {
@Output()
close = new EventEmitter<void>();
constructor(public readonly store: ArticleDetailsStore) {}
}

View File

@@ -0,0 +1,53 @@
import { animate, keyframes, style, transition, trigger } from '@angular/animations';
export const slideYAnimation = trigger('slideYAnimation', [
transition(':enter', [
style({
transform: 'translateY(100%)',
marginLeft: 'auto',
opacity: '0',
height: '100%',
}),
animate(
430,
keyframes([
style({
opacity: '1',
transform: 'translateY(65%)',
offset: 0.35,
}),
style({
transform: 'translateY(0)',
height: '100%',
opacity: '1',
offset: 1,
}),
])
),
]),
transition(':leave', [
style({
transform: 'translateY(0)',
height: '100%',
opacity: '1',
marginLeft: 'auto',
}),
animate(
430,
keyframes([
style({
opacity: '1',
transform: 'translateY(65%)',
offset: 0.65,
}),
style({
width: '0',
height: '100%',
transform: 'translateY(100%)',
opacity: '0',
offset: 1,
}),
])
),
]),
]);

View File

@@ -0,0 +1,28 @@
<button class="filter" [class.active]="anyFiltersActive$ | async" (click)="filterActive$.next(true)">
<ui-icon size="20px" icon="filter_alit"></ui-icon>
<span class="label">Filter</span>
</button>
<ng-container *ngIf="showMainContent$ | async">
<router-outlet></router-outlet>
</ng-container>
<div class="filter-overlay" [class.active]="filterActive$ | async">
<div class="filter-content">
<div class="filter-close-right">
<button class="filter-close" (click)="filterActive$.next(false)">
<ui-icon size="20px" icon="close"></ui-icon>
</button>
</div>
<h2 class="filter-header">
Filter
</h2>
<page-article-search-filter
(lastSelectedFilterCategory)="lastSelectedFilterCategory = $event"
[updateFilterCategory]="lastSelectedFilterCategory"
(exitFilter)="filterActive$.next(false)"
*ngIf="filterActive$ | async"
>
</page-article-search-filter>
</div>
</div>

View File

@@ -0,0 +1,50 @@
:host {
@apply flex flex-col w-full box-content absolute;
}
.filter {
@apply absolute font-sans flex items-center font-bold bg-wild-blue-yonder border-0 text-regular py-px-8 px-px-15 rounded-filter justify-center;
right: 0;
top: -52px;
min-width: 106px;
.label {
@apply ml-px-5;
}
&.active {
@apply bg-dark-cerulean text-white ml-px-5;
}
}
.filter-content {
@apply max-w-content mx-auto mt-px-25 px-px-15;
.filter-close-right {
@apply pr-px-10 text-right;
}
.filter-header {
@apply text-center text-page-heading mt-0;
}
button.filter-close {
@apply border-0 bg-transparent text-ucla-blue;
}
}
.filter-overlay {
@apply fixed bg-glitter z-fixed;
transform: translatex(100%);
transition: transform 0.5s ease-out;
top: 135px;
right: 0;
bottom: 0;
left: 0;
&.active {
transform: translatex(0%);
}
}

View File

@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Filter } from '@ui/filter';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { delay, map, switchMap } from 'rxjs/operators';
import { ArticleSearchStore } from './article-search.store';
@Component({
selector: 'page-article-search',
templateUrl: 'article-search.component.html',
styleUrls: ['article-search.component.scss'],
providers: [ArticleSearchStore],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchComponent implements OnInit {
filterActive$ = new BehaviorSubject<boolean>(false);
showMainContent$ = this.getShowMainContent();
lastSelectedFilterCategory: Filter;
anyFiltersActive$: Observable<boolean>;
readonly filters$ = this.articleSearchStore.selectFilter$;
constructor(private articleSearchStore: ArticleSearchStore) {}
ngOnInit() {
this.articleSearchStore.loadInitialFilters();
this.articleSearchStore.searchHistory();
this.anyFiltersActive$ = this.filters$.pipe(map((filters) => this.anyFilterSet(filters)));
}
anyFilterSet(filters: Filter[]): boolean {
let anySelected = false;
for (const filter of filters) {
const selected = filter.options?.filter((o) => o.selected);
if (selected?.length > 0) {
anySelected = true;
}
}
return anySelected;
}
getShowMainContent(animationDelayInMs: number = 500): Observable<boolean> {
return this.filterActive$.pipe(
switchMap((filterActive) => {
const onExitMainContent = filterActive;
if (onExitMainContent) {
return of(!filterActive).pipe(delay(animationDelayInMs));
}
return of(!filterActive);
})
);
}
}

View File

@@ -0,0 +1,57 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { UiCommonModule } from '@ui/common';
import { UiFilterModule } from '@ui/filter';
import { UiIconModule } from '@ui/icon';
import { UiSearchboxModule } from '@ui/searchbox';
import { ArticleSearchComponent } from './article-search.component';
import { ArticleSearchFilterComponent } from './search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './search-main/search-main.component';
import { ArticleSearchboxComponent } from './containers/article-searchbox/article-searchbox.component';
import { InfoboxComponent } from './containers/article-searchbox/infobox/infobox.component';
import { ArticleSearchResultsComponent } from './search-results/search-results.component';
import { OrderByFilterComponent } from './search-results/order-by-filter/order-by-filter.component';
import { StockInfosPipe } from './search-results/order-by-filter/stick-infos.pipe';
import { DomainCatalogModule } from '@domain/catalog';
import { UiCheckboxModule } from '@ui/checkbox';
import { UiFormControlModule } from '@ui/form-control';
import { UiInputModule } from '@ui/input';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { SearchResultItemComponent } from './search-results/search-result-item.component';
@NgModule({
imports: [
CommonModule,
RouterModule,
FormsModule,
ReactiveFormsModule,
UiSearchboxModule,
UiCommonModule,
UiIconModule,
UiFilterModule,
UiSearchboxModule,
ReactiveFormsModule,
DomainCatalogModule,
ScrollingModule,
UiCheckboxModule,
UiFormControlModule,
UiInputModule,
],
exports: [ArticleSearchComponent, ArticleSearchMainComponent, ArticleSearchFilterComponent, ArticleSearchboxComponent, InfoboxComponent],
declarations: [
ArticleSearchComponent,
ArticleSearchMainComponent,
ArticleSearchFilterComponent,
ArticleSearchboxComponent,
InfoboxComponent,
ArticleSearchResultsComponent,
OrderByFilterComponent,
StockInfosPipe,
SearchResultItemComponent,
],
providers: [],
})
export class ArticleSearchModule {}

View File

@@ -0,0 +1,471 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { StringDictionary } from '@cmf/core';
import { DomainCatalogService } from '@domain/catalog';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import {
AutocompleteDTO,
AutocompleteTokenDTO,
InputDTO,
InputGroupDTO,
InputType,
ItemDTO,
ListResponseArgsOfAutocompleteDTO,
ListResponseArgsOfItemDTO,
OptionDTO,
OrderByDTO,
QueryTokenDTO,
} from '@swagger/cat';
import { Filter, FilterType, SelectFilter, SelectFilterGroup, SelectFilterOption } from '@ui/filter';
import { Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged, exhaustMap, map, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import { cloneDeep } from 'lodash';
import { ScrollPositionService } from 'apps/ui/common/src/lib/scroll-position/scroll-position.service';
export interface ArticleSearchState {
searchState: 'fetching' | 'empty' | '';
initialFilter: Filter[];
filter: Filter[];
primaryFilter: Filter[];
items: ItemDTO[];
recentQueries: { input: string; filter: string; primaryFilter: string }[];
query: { query: string; history?: boolean };
autocomplete: AutocompleteDTO[];
orderBy: OrderByDTO;
hits: number;
}
@Injectable()
export class ArticleSearchStore extends ComponentStore<ArticleSearchState> {
constructor(
private domainCatalogService: DomainCatalogService,
private router: Router,
private route: ActivatedRoute,
private scrollPositionService: ScrollPositionService
) {
super({
searchState: '',
initialFilter: [],
filter: [],
primaryFilter: [],
items: [],
recentQueries: [],
query: { query: '', history: false },
autocomplete: [],
orderBy: undefined,
hits: 0,
});
}
readonly selectSearchState$: Observable<'fetching' | 'empty' | ''> = this.select((s) => s.searchState);
readonly selectQuery$: Observable<{ query: string; history?: boolean }> = this.select((s) => s.query);
readonly selectRecentQueries$: Observable<{ input: string; filter: string; primaryFilter: string }[]> = this.select(
(s) => s.recentQueries
);
readonly selectAutocomplete$: Observable<AutocompleteDTO[]> = this.select((s) => s.autocomplete);
readonly selectInitialFilter$: Observable<Filter[]> = this.select((s) => s.initialFilter);
readonly selectFilter$: Observable<Filter[]> = this.select((s) => s.filter);
readonly selectPrimaryFilter$: Observable<Filter[]> = this.select((s) => s.primaryFilter);
readonly results$ = this.select((s) => s.items);
readonly orderBy$ = this.select((s) => s.orderBy);
readonly hits$ = this.select((s) => s.hits);
readonly parseActivatedRouteParamsToFilterAndQuery = this.effect((activatedRoute$: Observable<ActivatedRoute>) =>
activatedRoute$.pipe(
withLatestFrom(this.selectFilter$, this.selectPrimaryFilter$, this.selectQuery$),
map(([route, filter, primaryFilter, query]) => {
// If Filter and Query undefined, return
if (filter.length === undefined && primaryFilter === undefined && query.query === undefined) {
return;
}
const filterParams: string = route.snapshot.queryParams.filter;
const primaryFilterParams: string = route.snapshot.queryParams.primaryFilter;
const queryParams: string = route.snapshot.queryParams.query;
const orderByParams: string = route.snapshot.queryParams.orderBy;
const descParams: boolean = Boolean(route.snapshot.queryParams.desc);
const scrollPos: number = Number(route.snapshot.queryParams.scrollPos);
if (orderByParams) {
this.patchState({
orderBy: { by: orderByParams, desc: descParams },
});
}
// If ID in FilterParams found, set selected FilterOption to true
if (!!filterParams) {
this.setSelectedFilterBasedOnIdString(
decodeURI(filterParams),
filter.concat(primaryFilter.filter((pf) => pf.target === 'filter')),
false
);
}
// If ID in PrimaryFilterParams found, set selected PrimaryFilterOption to true
if (!!primaryFilterParams) {
this.setSelectedFilterBasedOnIdString(
decodeURI(primaryFilterParams),
primaryFilter.filter((pf) => pf.target === 'input'),
false
);
}
if (!!queryParams) {
this.setQuery({ query: decodeURI(queryParams) });
}
if (!!scrollPos) {
this.scrollPositionService.scrollPosition = scrollPos;
}
})
)
);
readonly loadInitialFilters = this.effect(($: Observable<void>) =>
$.pipe(
switchMap((_) => this.domainCatalogService.getFilters()),
tapResponse(
(response: InputGroupDTO[]) => {
const mappedInputGroup = this.mapInputArraysToFilterGroupArrayInsideInputGroup(response);
const initialFilter = this.concatNestedArrays(mappedInputGroup.filter((filters) => filters.group === 'filter'));
const initialprimaryFilter = this.concatNestedArrays(
mappedInputGroup.filter((filters) => filters.group === 'main' || filters.group === 'input_selector')
);
this.setFilter({ filter: initialFilter });
this.setFilterChip({ primaryFilter: initialprimaryFilter });
this.patchState({ initialFilter });
},
(error) => {
console.error(error);
this.patchState({ filter: [], initialFilter: [], primaryFilter: [] });
}
),
tap(() => this.parseActivatedRouteParamsToFilterAndQuery(this.route))
)
);
readonly search = this.effect((empty$: Observable<{ empty?: boolean; reload?: boolean }>) =>
empty$.pipe(
tap(() => this.setSearchState({ searchState: 'fetching' })),
withLatestFrom(
this.selectQuery$,
this.results$,
this.orderBy$,
this.selectFilter$,
this.selectPrimaryFilter$,
this.selectRecentQueries$
),
tap(([{ empty }]) => (empty ? this.scrollPositionService.resetService() : '')),
exhaustMap(([{ empty, reload }, query, results, orderBy, filter, primaryFilter]) =>
this.domainCatalogService
.search({
queryToken: {
input: this.isPrimaryFilterSelected(primaryFilter.filter((pf) => pf.target === 'input'))
? this.mapQueryPrimaryFilterToStringDictonary(
primaryFilter.filter((f) => f.target === 'input'),
query.query
)
: { qs: query.query },
orderBy: orderBy ? [orderBy] : undefined,
returnStockData: false,
skip:
empty || reload ? 0 : !!this.scrollPositionService.data ? results.length + this.scrollPositionService.skip : results.length,
take: reload ? results.length : 25,
filter: this.mapFilterArrayToStringDictionary(filter.concat(primaryFilter.filter((pf) => pf.target === 'filter'))),
friendlyName: query.history ? query.query : undefined,
},
})
.pipe(
tapResponse(
(response: ListResponseArgsOfItemDTO) => {
const items = empty || reload ? response.result : [...results, ...response.result];
this.scrollPositionService.skip = response.skip;
this.patchState({ hits: response.hits });
this.patchState({ items });
if (empty) {
if (items.length === 0) {
this.setSearchState({ searchState: 'empty' });
} else {
this.setSearchState({ searchState: '' });
this.searchHistory();
if (items.length === 1) {
const item = items[0];
this.router.navigate(['/product/details', item.id], {
queryParams: {
query: query.query,
filter: this.mapSelectedFilterToIdString(filter.concat(primaryFilter.filter((pf) => pf.target === 'filter'))),
primaryFilter: this.mapSelectedFilterToIdString(primaryFilter.filter((pf) => pf.target === 'input')),
},
});
} else if (items.length > 1) {
this.router.navigate(['/product/search/results'], {
queryParams: {
query: query.query,
filter: this.mapSelectedFilterToIdString(filter.concat(primaryFilter.filter((pf) => pf.target === 'filter'))),
primaryFilter: this.mapSelectedFilterToIdString(primaryFilter.filter((pf) => pf.target === 'input')),
},
});
}
}
} else {
this.setSearchState({ searchState: '' });
}
},
(error) => {
console.error(error);
this.setSearchState({ searchState: '' });
this.patchState({ items: [] });
}
)
)
)
)
);
readonly searchAutocomplete = this.effect(($: Observable<void>) =>
$.pipe(
withLatestFrom(this.selectQuery$, this.selectFilter$, this.selectPrimaryFilter$),
debounceTime(200),
distinctUntilChanged(),
switchMap(([_, query, filter, primaryFilter]) =>
this.domainCatalogService.searchComplete({
queryToken: <AutocompleteTokenDTO>{
filter: this.mapFilterArrayToStringDictionary(filter) || {},
input: query.query,
take: 5,
type: this.mapSelectedFilterToIdString(primaryFilter) || 'qs',
},
})
),
tapResponse(
(response: ListResponseArgsOfAutocompleteDTO) => {
const autocomplete = response.result;
this.patchState({ autocomplete });
},
(error) => {
console.error(error);
this.patchState({ autocomplete: [] });
}
)
)
);
readonly searchHistory = this.effect(($: Observable<void>) =>
$.pipe(
switchMap((_) => this.domainCatalogService.getSearchHistory({ take: 20 })),
tapResponse(
(response: QueryTokenDTO[]) => {
const recentQueries: { input: string; filter: string; primaryFilter: string }[] = [];
const responseWithInputNotEmpty = response.filter((t) => Object.values(t.input)[0] !== '');
const removedInputDuplicatesSet = new Set(response.map((queryToken) => Object.values(queryToken.input)[0]));
const removedInputDuplicatesArray = Array.from(removedInputDuplicatesSet).filter((string) => string !== '');
// Map response QueryToken to one without duplicates, no empty input fields and return the first 5 of them
let modifiedQueryToken = [];
modifiedQueryToken = responseWithInputNotEmpty
.map((queryToken) => {
if (removedInputDuplicatesArray.find((input) => Object.values(queryToken.input)[0] === input)) {
removedInputDuplicatesArray.shift();
return queryToken;
}
})
.filter((t) => t !== undefined)
.slice(0, 5);
// Map modified tokens to recentQueries Object
modifiedQueryToken.forEach((t: QueryTokenDTO) => {
const filterString = t?.filter !== undefined && Object.keys(t?.filter).length > 0 ? Object.values(t?.filter).join(';') : '';
const primaryFilterString = t?.input !== undefined && Object.keys(t?.input).length > 0 ? Object.keys(t?.input).join(';') : '';
recentQueries.push({ input: Object.values(t?.input)[0], filter: filterString, primaryFilter: primaryFilterString });
});
this.patchState({ recentQueries });
},
(error) => {
console.error(error);
this.patchState({ recentQueries: [] });
}
)
)
);
setSearchState({ searchState }: { searchState: 'fetching' | 'empty' | '' }) {
this.patchState({ searchState });
}
setQuery({ query, history = false }: { query: string; history?: boolean }) {
this.patchState({ query: { query, history } });
this.queryParamsChanges();
}
setFilter({ filter }: { filter: Filter[] }) {
this.patchState({ filter });
this.queryParamsChanges();
}
setFilterChip({ primaryFilter }: { primaryFilter: Filter[] }) {
this.patchState({ primaryFilter });
this.queryParamsChanges();
}
queryParamsChanges() {
const query = this.get((s) => s.query.query);
const filter = this.get((s) => s.filter);
const orderBy = this.get((s) => s.orderBy);
const primaryFilter = this.get((s) => s.primaryFilter);
this.router.navigate([], {
queryParams: {
query,
filter: this.mapSelectedFilterToIdString(filter.concat(primaryFilter.filter((pf) => pf.target === 'filter'))),
primaryFilter: this.mapSelectedFilterToIdString(primaryFilter.filter((pf) => pf.target === 'input')),
orderBy: orderBy?.by,
desc: orderBy?.desc,
scrollPos: this.scrollPositionService.scrollPosition,
},
});
}
setOrderBy({ orderBy }: { orderBy: OrderByDTO }) {
this.patchState({ orderBy });
this.queryParamsChanges();
}
setTaskCalendarArticleSearchQuery({ query }: { query: string }) {
this.patchState({ query: { query } });
this.search({ empty: true });
}
private isPrimaryFilterSelected(source: Filter[]): boolean {
for (const pf of source) {
if (!!pf.options.find((o) => o.selected)) {
return true;
}
}
return false;
}
private mapQueryPrimaryFilterToStringDictonary(source: Filter[], query: string): StringDictionary<string> {
const dict: StringDictionary<string> = {};
for (const pf of source) {
const selected = pf.options.filter((o) => o.selected);
if (selected.length > 0) {
dict[pf.key] = query;
}
}
return dict;
}
private mapInputArraysToFilterGroupArrayInsideInputGroup(source: InputGroupDTO[]): SelectFilterGroup[] {
const mappedArr = source.map((inputGroup) => {
return {
group: inputGroup.group || undefined,
input: inputGroup.input.map((input) => this.fromInputDto(input)),
label: inputGroup.label || undefined,
} as SelectFilterGroup;
});
return mappedArr;
}
private concatNestedArrays(source: SelectFilterGroup[]): SelectFilter[] {
const arr: SelectFilter[] = [];
source.forEach((filter) => arr.push(...filter.input));
return arr;
}
private fromInputDto(input: InputDTO): SelectFilter {
return {
key: input.key,
name: input.label,
max: input.options && input.options.max,
type: this.inputTypeToType(input.type),
target: input.target,
options: input.options && input.options.values.map((v) => this.fromOptionDto(v)),
} as SelectFilter;
}
private fromOptionDto(option: OptionDTO): SelectFilterOption {
return {
name: option.label,
id: option.key || option.value,
selected: option.selected || false,
initial_selected_state: option.selected || false,
expanded: false,
options: option.values && option.values.map((v) => this.fromOptionDto(v)),
} as SelectFilterOption;
}
private inputTypeToType(type: InputType): FilterType {
switch (type) {
case 1:
return 'text';
case 2:
return 'select';
case 4:
return 'checkbox';
case 8 || 16:
return 'date';
case 32 || 64:
return 'number';
default:
return 'select';
}
}
private mapFilterArrayToStringDictionary(source: Filter[]): StringDictionary<string> {
const dict: StringDictionary<string> = {};
for (const filter of source) {
const selected = filter.options.filter((o) => o.selected);
if (selected.length > 0) {
dict[filter.key] = selected.map((o) => o.id).join(';');
}
}
return dict;
}
private mapSelectedFilterToIdString(source: Filter[]): string {
const dict: StringDictionary<string> = {};
for (const filter of source) {
const selected = filter.options.filter((o) => o.selected);
if (selected.length > 0) {
dict[filter.key] = selected.map((o) => o.id).join(';');
}
}
return [...Object.values(dict)].join(';');
}
public setSelectedFilterBasedOnIdString(sourceString: string, sourceFilter: Filter[], deepClone: boolean = true): Filter[] {
let filterCpy: Filter[];
if (deepClone) {
filterCpy = cloneDeep(sourceFilter);
} else {
filterCpy = sourceFilter;
}
if (sourceString.length === 0) {
return filterCpy;
}
const sourceStringArr = sourceString.split(';');
sourceStringArr.forEach((filterId) => {
for (const f of filterCpy) {
f.options.forEach((o) => {
if (o.id === filterId) {
o.selected = true;
}
});
}
});
if (!deepClone) {
return;
}
return filterCpy;
}
}

View File

@@ -0,0 +1,66 @@
<div class="wrapper">
<div class="primary-filter-container" *ngIf="filterChips$ | async; let filterChips">
<ui-checkbox
[class.checked]="chip.options[0].selected"
[showCheckbox]="false"
[ngModel]="chip.options[0].selected"
(ngModelChange)="checkFilterChip($event, chip, filterChips)"
[class.filter]="isFilter$ | async"
*ngFor="let chip of filterChips"
>
{{ chip.name }}
</ui-checkbox>
</div>
<div [class.searchbox-wrapper]="isFilter$ | async">
<ui-searchbox>
<input
tabindex="1"
#input
[ngModel]="query$ | async"
(ngModelChange)="updateQuery($event)"
(inputChange)="autocompleteTrigger($event)"
type="text"
uiSearchboxInput
placeholder="Titel, Autor, Verlag, Schlagwort, ..."
(keydown.enter)="startSearch(); autocomplete.close()"
uiIsInViewport
(viewportEntered)="focus($event)"
/>
<ui-searchbox-warning *ngIf="isEmpty$ | async">
<button class="no-results-button" (click)="resetSearchState()">
Keine Suchergebnisse
</button>
</ui-searchbox-warning>
<button tabindex="3" *ngIf="query$ | async" type="reset" uiSearchboxClearButton (click)="reset(); input.focus()">
<ui-icon icon="close" size="22px"></ui-icon>
</button>
<button
tabindex="2"
[class.scan]="isMobile && !input?.value?.length"
[class.hide]="isFetching$ | async"
type="submit"
uiSearchboxSearchButton
(click)="startSearch()"
[disabled]="isFetching$ | async"
>
<ui-icon class="spin" *ngIf="isFetching$ | async" icon="spinner" size="32px"></ui-icon>
<ng-container *ngIf="!(isFetching$ | async)">
<ui-icon *ngIf="isMobile && !input?.value?.length" icon="scan" size="32px"></ui-icon>
<ui-icon *ngIf="!isMobile || !!input?.value?.length" icon="search" size="24px"></ui-icon>
</ng-container>
</button>
<ui-searchbox-autocomplete uiClickOutside (clicked)="autocomplete.close()" #autocomplete>
<button
uiSearchboxAutocompleteOption
[value]="result?.display"
*ngFor="let result of autocompleteResult$ | async"
(click)="startSearch(); autocomplete.close()"
>
{{ result?.display }}
</button>
</ui-searchbox-autocomplete>
</ui-searchbox>
<page-searchbox-infobox *ngIf="isFilter$ | async"></page-searchbox-infobox>
</div>
</div>

View File

@@ -0,0 +1,83 @@
.checked {
@apply text-dark-cerulean border border-solid border-inactive-customer;
padding-top: 15px;
padding-bottom: 15px;
}
ui-checkbox {
@apply mx-px-10 px-5 py-4 text-inactive-customer font-bold text-sm whitespace-nowrap;
border-radius: 27px;
border: 1px solid white;
background-color: #e9f0f8;
}
ui-checkbox.filter {
@apply bg-white;
}
.wrapper {
@apply flex flex-col;
.primary-filter-container {
@apply flex flex-row justify-center mb-8;
}
page-filter-chip {
@apply mb-1;
}
}
.searchbox-wrapper {
@apply flex flex-row justify-center;
ui-searchbox {
@apply ml-0 mr-4;
}
}
ui-searchbox {
@apply max-w-lg mx-auto w-full;
input {
caret-color: #f70400;
}
.no-results-button {
@apply border-none outline-none font-bold right-0 px-4 text-warning whitespace-nowrap bg-white;
font-size: 21px;
}
[uiSearchboxSearchButton] {
@apply bg-white text-brand;
&:not(.scan) {
@apply pr-px-25;
}
&.scan {
@apply bg-brand text-white;
}
&.hide {
@apply bg-white;
}
.spin {
@apply bg-white text-ucla-blue;
}
}
[uiSearchboxClearButton] {
@apply text-inactive-customer;
}
}
.spin {
@apply animate-spin;
}
@media (min-width: 1025px) {
ui-checkbox {
@apply text-base;
}
}

View File

@@ -0,0 +1,174 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
OnDestroy,
} from '@angular/core';
import { EnvironmentService } from '@core/environment';
import { AutocompleteDTO } from '@swagger/cat';
import { Filter } from '@ui/filter';
import { UiSearchboxAutocompleteComponent } from '@ui/searchbox';
import { BehaviorSubject, Observable } from 'rxjs';
import { delay, first, map, shareReplay, tap, filter } from 'rxjs/operators';
import { ArticleSearchStore } from '../../article-search.store';
import { NativeContainerService } from 'native-container';
import { Subscription } from 'rxjs';
@Component({
selector: 'page-article-searchbox',
templateUrl: 'article-searchbox.component.html',
styleUrls: ['article-searchbox.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchboxComponent implements OnInit, OnDestroy {
@ViewChild(UiSearchboxAutocompleteComponent, {
read: UiSearchboxAutocompleteComponent,
static: false,
})
autocomplete: UiSearchboxAutocompleteComponent;
autocompleteResult$: Observable<AutocompleteDTO[]>;
readonly filterChips$ = this.articleSearchStore.selectPrimaryFilter$;
readonly query$ = this.articleSearchStore.selectQuery$.pipe(
map((query) => query.query),
shareReplay()
);
readonly isHistory$ = this.articleSearchStore.selectQuery$.pipe(map((query) => query.history));
readonly searchState$ = this.articleSearchStore.selectSearchState$;
readonly autocomplete$ = this.articleSearchStore.selectAutocomplete$;
isMobile: boolean;
scanSubscription: Subscription;
@Output() closeFilterOverlay = new EventEmitter<void>();
/* @internal */
mode$ = new BehaviorSubject<'filter' | 'main'>('main');
@Input()
get mode() {
return this.mode$.value;
}
set mode(value) {
if (this.mode !== value) {
this.mode$.next(value);
}
}
isFilter$ = this.mode$.pipe(
map((type) => type === 'filter'),
shareReplay()
);
isMain$ = this.mode$.pipe(
map((type) => type === 'main'),
shareReplay()
);
isFetching$ = this.searchState$.pipe(
map((searchState) => searchState === 'fetching'),
shareReplay()
);
isEmpty$ = this.searchState$.pipe(
map((searchState) => searchState === 'empty'),
shareReplay()
);
constructor(
private environmentService: EnvironmentService,
private cdr: ChangeDetectorRef,
private articleSearchStore: ArticleSearchStore,
private nativeContainer: NativeContainerService
) {}
ngOnInit() {
this.detectDevice();
this.initAutocomplete();
}
ngOnDestroy() {
if (!!this.scanSubscription) {
this.scanSubscription.unsubscribe();
}
}
async startSearch() {
if (await this.environmentService.isMobile()) {
return this.scan();
}
this.articleSearchStore.search({ empty: true });
this.closeFilterOverlay.emit();
}
scan() {
this.scanSubscription = this.nativeContainer
.openScanner('scanCustomer')
.pipe(filter((message) => message.status !== 'IN_PROGRESS'))
.subscribe((result) => {
this.articleSearchStore.setQuery({ query: result?.data });
});
}
reset() {
this.articleSearchStore.setSearchState({ searchState: '' });
this.articleSearchStore.setQuery({ query: '' });
}
resetSearchState() {
this.articleSearchStore.setSearchState({ searchState: '' });
}
checkFilterChip(checked: boolean = false, changedFilter: Filter, primaryFilter: Filter[]) {
changedFilter.options[0].selected = checked;
this.articleSearchStore.setFilterChip({ primaryFilter });
this.cdr.markForCheck();
}
async updateQuery(query: string) {
const isHistoryCall = await this.isHistory$.pipe(first()).toPromise();
if (!isHistoryCall) {
this.articleSearchStore.setQuery({ query });
}
}
autocompleteTrigger(query: string) {
if (query.length >= 3) {
this.articleSearchStore.searchAutocomplete();
}
}
initAutocomplete() {
this.autocompleteResult$ = this.autocomplete$.pipe(
tap((results) => {
if (this.autocomplete) {
if (results.length > 0) {
this.autocomplete.open();
} else {
this.autocomplete.close();
}
}
}),
delay(1),
tap(() => this.cdr.detectChanges())
);
}
async detectDevice() {
this.isMobile = await this.environmentService.isMobile();
this.cdr.detectChanges();
}
focus(input: HTMLInputElement): void {
setTimeout(() => {
input.focus();
this.cdr.detectChanges();
}, 200);
}
}

View File

@@ -0,0 +1,15 @@
<div (click)="isOpen = false" [class.backdrop]="isOpen"></div>
<button class="info-tooltip-button" (click)="isOpen = !isOpen" type="button">
i
</button>
<div *ngIf="isOpen" (click)="isOpen = !isOpen" class="info-tooltip-box">
<div class="info-tooltip">
<p>
Um Begriffe auszuschließen, geben Sie diese <br />
mit einem ! oder ~ davor in das Suchfeld ein. <br />
Um exakt einen Begriff zu suchen, wählen <br />
Sie &bdquo;Exakt&ldquo; aus.
</p>
</div>
<div class="info-tooltip-triangle"></div>
</div>

View File

@@ -0,0 +1,38 @@
:host {
@apply flex self-center;
}
.backdrop {
@apply fixed w-screen h-screen z-tooltip left-0 top-0;
}
.info-tooltip-button {
@apply border-font-customer bg-white rounded-md text-base font-bold;
border-style: outset;
width: 31px;
height: 31px;
}
.info-tooltip-box {
@apply absolute z-modal;
.info-tooltip {
@apply relative bg-dark-cerulean rounded-card p-4;
width: 375px;
left: -325px;
top: -146px;
p {
@apply m-0 text-white font-bold text-base;
}
}
.info-tooltip-triangle {
@apply relative bg-dark-cerulean;
transform: rotate(45deg);
width: 20px;
height: 20px;
top: -159px;
left: 6px;
}
}

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
@Component({
selector: 'page-searchbox-infobox',
templateUrl: 'infobox.component.html',
styleUrls: ['infobox.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfoboxComponent implements OnInit {
isOpen = false;
constructor() {}
ngOnInit() {}
}

View File

@@ -0,0 +1,23 @@
<page-article-searchbox mode="filter" (closeFilterOverlay)="closeOverlay()"></page-article-searchbox>
<ng-container *ngIf="filters$ | async; let selectedFilters">
<ng-container *ngIf="initialFilters$ | async; let initialFilters">
<ui-selected-filter-options [value]="selectedFilters" [initialFilter]="initialFilters" (valueChange)="updateFilter($event)">
</ui-selected-filter-options>
<ui-filter-group
[value]="selectedFilters"
(activeChange)="updateCategory($event)"
[active]="updateFilterCategory$ | async"
(valueChange)="updateFilter($event)"
></ui-filter-group>
</ng-container>
</ng-container>
<div class="sticky-cta-wrapper">
<button class="apply-filter" (click)="applyFilters()">
<span>
Filter anwenden
</span>
</button>
</div>

View File

@@ -0,0 +1,34 @@
:host {
@apply flex flex-col box-border w-full;
}
.sticky-cta-wrapper {
@apply fixed text-center inset-x-0 bottom-0;
bottom: 30px;
}
button.apply-filter {
@apply border-none bg-brand text-white rounded-full py-cta-y-l px-cta-x-l text-cta-l font-bold;
min-width: 201px;
}
button.apply-filter:disabled {
@apply bg-inactive-customer;
}
button.apply-filter.loading {
padding-top: 15px;
padding-bottom: 17px;
}
ui-selected-filter-options {
@apply my-px-8;
}
ui-icon {
@apply inline-flex;
}
.spin {
@apply animate-spin;
}

View File

@@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Filter } from '@ui/filter';
import { BehaviorSubject } from 'rxjs';
import { first, map, shareReplay } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search.store';
@Component({
selector: 'page-article-search-filter',
templateUrl: 'search-filter.component.html',
styleUrls: ['search-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
@Output() exitFilter = new EventEmitter<void>();
@Output() lastSelectedFilterCategory = new EventEmitter<Filter>();
readonly filters$ = this.articleSearchStore.selectFilter$;
readonly initialFilters$ = this.articleSearchStore.selectInitialFilter$;
/* @internal */
updateFilterCategory$ = new BehaviorSubject<Filter>(undefined);
@Input()
get updateFilterCategory(): Filter {
return this.updateFilterCategory$.value;
}
set updateFilterCategory(value) {
this.updateFilterCategory$.next(value);
}
filterChanges: Filter[];
constructor(private cdr: ChangeDetectorRef, private articleSearchStore: ArticleSearchStore) {}
async ngOnInit() {
this.filterChanges = await this.filters$.pipe(first()).toPromise();
}
ngOnDestroy() {
const filter = this.filterChanges;
this.articleSearchStore.setFilter({ filter });
}
closeOverlay() {
this.exitFilter.emit();
}
async applyFilters() {
this.filterChanges = await this.filters$.pipe(first()).toPromise();
this.articleSearchStore.search({ empty: true });
this.closeOverlay();
}
updateCategory(filter: Filter) {
this.lastSelectedFilterCategory.emit(filter);
this.cdr.markForCheck();
}
updateFilter(filter: Filter[]) {
this.articleSearchStore.setFilter({ filter });
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,18 @@
<div class="card-search-article">
<h1 class="title">Artikelsuche</h1>
<p class="info">
Welchen Artikel suchen Sie?
</p>
<page-article-searchbox mode="main"></page-article-searchbox>
<div class="recent-searches-wrapper">
<h3 class="recent-searches-header">Deine letzten Suchanfragen</h3>
<ul>
<li class="recent-searches-items" *ngFor="let recentQuery of recentQueries$ | async">
<button (click)="setQueryHistory(recentQuery)">
<ui-icon icon="search" size="15px"></ui-icon>
<p>{{ recentQuery.input }}</p>
</button>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,53 @@
:host {
@apply flex flex-col box-border;
}
.title {
@apply text-page-heading font-bold;
}
.info {
@apply text-2xl mt-1 mb-px-30;
}
.card-search-article {
@apply bg-white rounded p-4 text-center;
box-shadow: 0 -2px 24px 0 #dce2e9;
}
.card-search-article {
height: calc(100vh - 380px);
}
.recent-searches-wrapper {
@apply flex flex-col mx-auto items-start py-6;
width: 50%;
.recent-searches-header {
@apply text-sm font-bold;
}
ul {
@apply flex flex-col justify-start overflow-scroll items-start m-0 p-0;
max-height: 200px;
.recent-searches-items {
@apply list-none mb-px-15;
button {
@apply flex flex-row items-center outline-none border-none bg-white text-base m-0 p-0;
ui-icon {
@apply flex w-px-35 h-px-35 justify-center items-center mr-3 rounded-full;
background-color: #e6eff9;
}
p {
@apply m-0 p-0 whitespace-nowrap overflow-hidden overflow-ellipsis;
max-width: 400px;
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { first, tap } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search.store';
import { ScrollPositionService } from 'apps/ui/common/src/lib/scroll-position/scroll-position.service';
@Component({
selector: 'page-article-search-main',
templateUrl: 'search-main.component.html',
styleUrls: ['search-main.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchMainComponent implements OnInit {
readonly recentQueries$ = this.articleSearchStore.selectRecentQueries$.pipe(tap((r) => console.log(r)));
readonly filter$ = this.articleSearchStore.selectFilter$;
readonly primaryFilter$ = this.articleSearchStore.selectPrimaryFilter$;
constructor(
private articleSearchStore: ArticleSearchStore,
private route: ActivatedRoute,
private scrollPositionService: ScrollPositionService
) {}
ngOnInit() {
this.scrollPositionService.resetService();
const query = this.route.snapshot.queryParams.taskCalendarSearch;
if (!!query) {
this.articleSearchStore.setTaskCalendarArticleSearchQuery({ query });
} else {
this.articleSearchStore.parseActivatedRouteParamsToFilterAndQuery(this.route);
}
}
async setQueryHistory(recentQuery: { input: string; filter: string; primaryFilter: string }) {
const filter = await this.filter$.pipe(first()).toPromise();
const primaryFilter = await this.primaryFilter$.pipe(first()).toPromise();
this.articleSearchStore.setSelectedFilterBasedOnIdString(
recentQuery.filter,
filter.concat(primaryFilter.filter((pf) => pf.target === 'filter')),
false
);
if (recentQuery.primaryFilter !== 'qs') {
this.articleSearchStore.setSelectedFilterBasedOnIdString(
recentQuery.primaryFilter,
primaryFilter.filter((pf) => pf.target === 'input'),
false
);
}
this.articleSearchStore.setQuery({ query: recentQuery.input, history: true });
}
}

View File

@@ -0,0 +1,9 @@
<div class="order-by-filter-text">Sortieren nach:</div>
<div class="order-by-filter-button-wrapper">
<button class="order-by-filter-button" type="button" *ngFor="let by of orderBy$ | async" (click)="setActive(by.next)">
<span>
{{ by.by }}
</span>
<ui-icon [class.asc]="by.active && !by.desc" [class.desc]="by.active && by.desc" icon="arrow" size="14px"></ui-icon>
</button>
</div>

View File

@@ -0,0 +1,33 @@
:host {
@apply box-border flex flex-col;
}
.order-by-filter-text {
@apply text-center;
}
.order-by-filter-button-wrapper {
@apply flex flex-row items-center justify-center;
}
.order-by-filter-button {
@apply bg-transparent outline-none border-none text-regular font-bold text-active-customer flex flex-row justify-center items-center m-2;
}
ui-icon {
@apply hidden transform ml-2 bg-active-customer rounded-full p-1;
transition: 250ms all ease-in-out;
}
ui-icon.asc,
ui-icon.desc {
@apply flex bg-active-customer rounded-full visible text-white;
}
ui-icon.asc {
@apply -rotate-90;
}
ui-icon.desc {
@apply rotate-90;
}

View File

@@ -0,0 +1,57 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { DomainCatalogService } from '@domain/catalog';
import { OrderByDTO } from '@swagger/cat';
import { ScrollPositionService } from 'apps/ui/common/src/lib/scroll-position/scroll-position.service';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { ArticleSearchStore } from '../../article-search.store';
@Component({
selector: 'page-order-by-filter',
templateUrl: 'order-by-filter.component.html',
styleUrls: ['order-by-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderByFilterComponent {
active$ = this.store.orderBy$;
orderBy$ = combineLatest([
this.domainCatalogService.getOrderBy().pipe(map((bys) => bys.map((b) => ({ ...b, desc: !!b.desc })))),
this.active$,
]).pipe(
map(([orderBy, activeOrderBy]) => {
const bys = orderBy.map((o) => o.by).reduce((agg, by) => (!agg.includes(by) ? [...agg, by] : agg), []);
const orderBys: (OrderByDTO & { active: boolean; next?: OrderByDTO })[] = [];
for (const by of bys) {
const asc = orderBy.find((ob) => ob.by === by && !ob.desc);
const desc = orderBy.find((ob) => ob.by === by && !!ob.desc);
const ascActive = asc && asc.by === activeOrderBy?.by && !activeOrderBy?.desc;
const descActive = desc && desc.by === activeOrderBy?.by && activeOrderBy?.desc;
if (!!desc && descActive) {
orderBys.push({ ...desc, active: true, next: asc });
} else if (!!asc && ascActive) {
orderBys.push({ ...asc, active: true });
} else if (!!asc || !!desc) {
orderBys.push({ ...desc, active: false, next: desc || asc });
}
}
return orderBys;
})
);
constructor(
private domainCatalogService: DomainCatalogService,
private store: ArticleSearchStore,
private scrollPositionService: ScrollPositionService
) {}
setActive(orderBy: OrderByDTO) {
this.scrollPositionService.resetService();
this.store.setOrderBy({ orderBy });
this.store.search({ reload: true });
}
}

View File

@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
import { StockInfoDTO } from '@swagger/cat';
@Pipe({
name: 'stockInfos',
})
export class StockInfosPipe implements PipeTransform {
transform(infos: StockInfoDTO[], ...args: any[]): number {
return infos?.reduce((sum, si) => sum + si.inStock, 0) || 0;
}
}

View File

@@ -0,0 +1,45 @@
<a class="product-list-result-content" [routerLink]="['/product', 'details', item?.id]" [queryParams]="activatedRoute.snapshot.queryParams">
<div class="item-thumbnail">
<img loading="lazy" *ngIf="item?.product?.ean | thumbnailUrl; let thumbnailUrl" [src]="thumbnailUrl" [alt]="item?.product?.title" />
</div>
<div class="item-contributors">
<a
*ngFor="let contributor of contributors"
[routerLink]="['/product/search']"
[queryParams]="{ query: contributor, primaryFilter: 'author' }"
>
{{ contributor }}
</a>
</div>
<div
class="item-title"
[class.xl]="item?.product?.name.length >= 50"
[class.lg]="item?.product?.name.length >= 60"
[class.md]="item?.product?.name.length >= 70"
[class.sm]="item?.product?.name.length >= 80"
[class.xs]="item?.product?.name.length >= 90"
>
{{ item?.product?.name }}
</div>
<div class="item-price">
{{ item?.catalogAvailability?.price?.value?.value | currency: 'EUR':'code' }}
</div>
<div class="item-stock"><ui-icon icon="home" size="1em"></ui-icon> {{ item?.stockInfos | stockInfos }} x</div>
<div class="item-ssc">{{ item?.catalogAvailability?.ssc }} - {{ item?.catalogAvailability?.sscText }}</div>
<div class="item-format">
<img loading="lazy" src="assets/images/Icon_{{ item?.product?.format }}.svg" alt="item?.product?.formatDetail" />
{{ item?.product?.formatDetail }}
</div>
<div class="item-misc">
{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }} <br />
{{ item?.product?.volume }} <span *ngIf="item?.product?.volume && item?.product?.publicationDate">|</span>
{{ item?.product?.publicationDate | date: 'MMMM yyyy' }}
</div>
</a>

View File

@@ -0,0 +1,89 @@
.product-list-result-content {
@apply text-black no-underline grid;
grid-template-columns: auto 1fr 230px;
grid-template-rows: auto;
grid-template-areas:
'item-thumbnail item-contributors item-contributors'
'item-thumbnail item-title item-price'
'item-thumbnail item-format item-stock'
'item-thumbnail item-misc item-ssc';
}
.item-thumbnail {
grid-area: item-thumbnail;
width: 70px;
@apply mr-8;
img {
max-width: 100%;
max-height: 100%;
@apply rounded-card shadow-cta;
}
}
.item-contributors {
grid-area: item-contributors;
height: 22px;
a {
@apply text-active-customer font-bold no-underline;
}
}
.item-title {
grid-area: item-title;
@apply font-bold text-2xl;
height: 64px;
max-height: 64px;
}
.item-title.xl {
@apply font-bold text-xl;
}
.item-title.lg {
@apply font-bold text-lg;
}
.item-title.md {
@apply font-bold text-base;
}
.item-title.sm {
@apply font-bold text-sm;
}
.item-title.xs {
@apply font-bold text-xs;
}
.item-price {
grid-area: item-price;
@apply font-bold text-xl text-right;
}
.item-format {
grid-area: item-format;
@apply flex flex-row items-center font-bold text-lg;
img {
@apply mr-2;
}
}
.item-stock {
grid-area: item-stock;
@apply flex flex-row justify-end items-baseline font-bold text-lg;
ui-icon {
@apply text-active-customer mr-2;
}
}
.item-misc {
grid-area: item-misc;
}
.item-ssc {
grid-area: item-ssc;
@apply font-bold text-right;
}

View File

@@ -0,0 +1,20 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ItemDTO } from '@swagger/cat';
@Component({
selector: 'search-result-item',
templateUrl: 'search-result-item.component.html',
styleUrls: ['search-result-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchResultItemComponent {
@Input()
item: ItemDTO;
get contributors() {
return this.item?.product?.contributors?.split(';').map((val) => val.trim());
}
constructor(public activatedRoute: ActivatedRoute) {}
}

View File

@@ -0,0 +1,16 @@
<page-order-by-filter></page-order-by-filter>
<cdk-virtual-scroll-viewport
#scrollContainer
class="product-list scroll-bar"
[itemSize]="155 + 16 * 2 + 8"
minBufferPx="1200"
maxBufferPx="1200"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<div class="product-list-result" *cdkVirtualFor="let item of results$ | async" cdkVirtualForTrackBy="trackByItemId">
<search-result-item [item]="item"></search-result-item>
</div>
<div class="product-list-result" *ngIf="fetching$ | async">
<div class="fetch-item-animation"></div>
</div>
</cdk-virtual-scroll-viewport>

View File

@@ -0,0 +1,19 @@
:host {
@apply box-border grid;
max-height: calc(100vh - 284px);
height: 100vh;
grid-template-rows: auto 1fr;
}
.product-list {
@apply m-0 p-0 mt-2;
}
.product-list-result {
@apply list-none bg-white rounded-card p-4 mb-2;
height: 155px;
max-height: 155px;
}
.fetch-item-animation {
}

View File

@@ -0,0 +1,103 @@
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { ItemDTO } from '@swagger/cat';
import { ScrollPositionService } from 'apps/ui/common/src/lib/scroll-position/scroll-position.service';
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { first, map, withLatestFrom } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search.store';
@Component({
selector: 'page-search-results',
templateUrl: 'search-results.component.html',
styleUrls: ['search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('scrollContainer', { static: false }) scrollContainer: CdkVirtualScrollViewport;
loading$ = new BehaviorSubject<boolean>(false);
results$ = this.store.results$.pipe(
map((items) => {
if (!!this.scrollPositionService.data) {
return [...(this.scrollPositionService.data as ItemDTO[]), ...items];
}
return items;
})
);
fetching$ = this.store.selectSearchState$.pipe(map((state) => state === 'fetching'));
private readonly subscriptions = new Subscription();
trackByItemId = (item: ItemDTO) => item.id;
constructor(
private store: ArticleSearchStore,
private route: ActivatedRoute,
private scrollPositionService: ScrollPositionService,
private router: Router,
private application: ApplicationService,
private breadcrumb: BreadcrumbService
) {}
ngOnInit() {
this.store.parseActivatedRouteParamsToFilterAndQuery(this.route);
this.store.search({ reload: true });
// Safe current scroll position if navigating to Customer Details
this.subscriptions.add(
this.router.events.pipe(withLatestFrom(this.results$)).subscribe(([events, items]) => {
if (events instanceof NavigationStart) {
if (events?.url.startsWith(`/product/details/`)) {
// Safe Scroll Position
this.scrollPositionService.scrollPosition = this.scrollContainer.measureScrollOffset('top');
// Safe prefetched data
if (!!this.scrollPositionService.data) {
const oldResult = this.scrollPositionService.data;
const newResult = items;
this.scrollPositionService.data = items;
this.scrollPositionService.data.result = [...oldResult, ...newResult];
} else {
this.scrollPositionService.data = items;
}
}
}
})
);
this.subscriptions.add(
combineLatest([this.store.selectQuery$, this.store.hits$]).subscribe(([query, hits]) => {
this.breadcrumb.addBreadcrumbIfNotExists({
key: this.application.activatedProcessId,
name: `${query.query} (${hits} Ergebnisse)`,
path: this.router.url.split('?')[0],
params: {
...this.route.snapshot.queryParams,
scrollPos: this.scrollPositionService.scrollPosition,
},
tags: ['catalog', 'results'],
});
})
);
}
ngAfterViewInit() {
// Start scrolling to last remembered position
this.scrollPositionService.scrollToLastRememberedPositionVirtualScroll(this.scrollContainer);
}
ngOnDestroy() {
this.loading$.complete();
this.subscriptions.unsubscribe();
}
async scrolledIndexChange(index: number) {
const results = await this.results$.pipe(first()).toPromise();
if (index >= results.length - 20 && results.length - 20 > 0) {
this.store.search({ empty: false });
}
}
}

View File

@@ -0,0 +1,44 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { PageCatalogResolver } from './page-catalog.resolver';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
children: [
{
path: 'search',
component: ArticleSearchComponent,
children: [
{ path: '', component: ArticleSearchMainComponent, resolve: [PageCatalogResolver] },
{ path: 'results', component: ArticleSearchResultsComponent, resolve: [PageCatalogResolver] },
],
},
{
path: 'details/ean/:ean',
component: ArticleDetailsComponent,
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
},
{
path: '',
pathMatch: 'full',
redirectTo: 'search',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}

View File

@@ -0,0 +1,5 @@
<shell-breadcrumb [key]="application.activatedProcessId$ | async" [includesTags]="['catalog']"></shell-breadcrumb>
<div class="content-container">
<router-outlet></router-outlet>
</div>

View File

@@ -0,0 +1,9 @@
shell-breadcrumb {
margin-top: -5px;
@apply mb-px-10 pb-px-10;
}
.content-container {
max-height: calc(100vh - 267px);
overflow: scroll;
}

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { ScrollPositionService } from 'apps/ui/common/src/lib/scroll-position/scroll-position.service';
@Component({
selector: 'page-catalog',
templateUrl: 'page-catalog.component.html',
styleUrls: ['page-catalog.component.scss'],
providers: [ScrollPositionService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PageCatalogComponent implements OnInit {
constructor(public application: ApplicationService, private breadcrumb: BreadcrumbService) {}
ngOnInit(): void {}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ShellBreadcrumbModule } from '@shell/breadcrumb';
import { ArticleDetailsModule } from './article-details/article-details.module';
import { ArticleSearchModule } from './article-search/article-search.module';
import { PageCatalogRoutingModule } from './page-catalog-routing.module';
import { PageCatalogComponent } from './page-catalog.component';
@NgModule({
imports: [CommonModule, PageCatalogRoutingModule, ShellBreadcrumbModule, ArticleSearchModule, ArticleDetailsModule],
exports: [],
declarations: [PageCatalogComponent],
})
export class PageCatalogModule {}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
@Injectable({ providedIn: 'root' })
export class PageCatalogResolver implements Resolve<any> {
constructor(private breadcrumb: BreadcrumbService, private application: ApplicationService) {}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
if (state.url.includes('?')) {
const url = state.url.substring(0, state.url.indexOf('?'));
if (url === '/product/search') {
this.setBreadcrumb(route);
}
} else if (state.url === '/product/search') {
this.setBreadcrumb(route);
}
}
async setBreadcrumb(route: ActivatedRouteSnapshot) {
const crumb = await this.breadcrumb.addBreadcrumbIfNotExists({
key: this.application.activatedProcessId,
name: 'Artikelsuche',
path: '/product',
params: route.queryParams,
tags: ['catalog'],
});
this.breadcrumb.removeBreadcrumbsAfter(crumb.id);
}
}

View File

@@ -0,0 +1,4 @@
/*
* Public API Surface of catalog
*/
export * from './lib/page-catalog.module';

View File

@@ -0,0 +1,24 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

Some files were not shown because too many files have changed in this diff Show More