mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Merged PR 658: Merge into develop
Related work items: #1637, #1710, #1719, #1720
This commit is contained in:
280
angular.json
280
angular.json
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
39
apps/domain/catalog/src/lib/thumbnail-url.pipe.ts
Normal file
39
apps/domain/catalog/src/lib/thumbnail-url.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
apps/domain/catalog/src/lib/thumbnail.service.ts
Normal file
20
apps/domain/catalog/src/lib/thumbnail.service.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
25
apps/modal/availabilities/README.md
Normal file
25
apps/modal/availabilities/README.md
Normal 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).
|
||||
32
apps/modal/availabilities/karma.conf.js
Normal file
32
apps/modal/availabilities/karma.conf.js
Normal 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,
|
||||
});
|
||||
};
|
||||
7
apps/modal/availabilities/ng-package.json
Normal file
7
apps/modal/availabilities/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist/modal/availabilities",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
11
apps/modal/availabilities/package.json
Normal file
11
apps/modal/availabilities/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
14
apps/modal/availabilities/src/lib/availabilities.module.ts
Normal file
14
apps/modal/availabilities/src/lib/availabilities.module.ts
Normal 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 {}
|
||||
5
apps/modal/availabilities/src/public-api.ts
Normal file
5
apps/modal/availabilities/src/public-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Public API Surface of availabilities
|
||||
*/
|
||||
export * from './lib/availabilities.component';
|
||||
export * from './lib/availabilities.module';
|
||||
24
apps/modal/availabilities/src/test.ts
Normal file
24
apps/modal/availabilities/src/test.ts
Normal 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);
|
||||
25
apps/modal/availabilities/tsconfig.lib.json
Normal file
25
apps/modal/availabilities/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
apps/modal/availabilities/tsconfig.lib.prod.json
Normal file
10
apps/modal/availabilities/tsconfig.lib.prod.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
apps/modal/availabilities/tsconfig.spec.json
Normal file
17
apps/modal/availabilities/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
17
apps/modal/availabilities/tslint.json
Normal file
17
apps/modal/availabilities/tslint.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tslint.json",
|
||||
"rules": {
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"lib",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"lib",
|
||||
"kebab-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
apps/modal/images/README.md
Normal file
25
apps/modal/images/README.md
Normal 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).
|
||||
32
apps/modal/images/karma.conf.js
Normal file
32
apps/modal/images/karma.conf.js
Normal 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,
|
||||
});
|
||||
};
|
||||
7
apps/modal/images/ng-package.json
Normal file
7
apps/modal/images/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist/modal/images",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
11
apps/modal/images/package.json
Normal file
11
apps/modal/images/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
apps/modal/images/src/lib/images.component.html
Normal file
25
apps/modal/images/src/lib/images.component.html
Normal 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>
|
||||
54
apps/modal/images/src/lib/images.component.scss
Normal file
54
apps/modal/images/src/lib/images.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
39
apps/modal/images/src/lib/images.component.ts
Normal file
39
apps/modal/images/src/lib/images.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
apps/modal/images/src/lib/images.module.ts
Normal file
11
apps/modal/images/src/lib/images.module.ts
Normal 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 {}
|
||||
5
apps/modal/images/src/public-api.ts
Normal file
5
apps/modal/images/src/public-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Public API Surface of images
|
||||
*/
|
||||
export * from './lib/images.component';
|
||||
export * from './lib/images.module';
|
||||
24
apps/modal/images/src/test.ts
Normal file
24
apps/modal/images/src/test.ts
Normal 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);
|
||||
25
apps/modal/images/tsconfig.lib.json
Normal file
25
apps/modal/images/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
apps/modal/images/tsconfig.lib.prod.json
Normal file
10
apps/modal/images/tsconfig.lib.prod.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
apps/modal/images/tsconfig.spec.json
Normal file
17
apps/modal/images/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
17
apps/modal/images/tslint.json
Normal file
17
apps/modal/images/tslint.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tslint.json",
|
||||
"rules": {
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"lib",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"lib",
|
||||
"kebab-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
apps/modal/reviews/README.md
Normal file
25
apps/modal/reviews/README.md
Normal 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).
|
||||
32
apps/modal/reviews/karma.conf.js
Normal file
32
apps/modal/reviews/karma.conf.js
Normal 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,
|
||||
});
|
||||
};
|
||||
7
apps/modal/reviews/ng-package.json
Normal file
7
apps/modal/reviews/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist/modal/reviews",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
11
apps/modal/reviews/package.json
Normal file
11
apps/modal/reviews/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
apps/modal/reviews/src/lib/reviews.component.html
Normal file
42
apps/modal/reviews/src/lib/reviews.component.html
Normal 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>
|
||||
54
apps/modal/reviews/src/lib/reviews.component.scss
Normal file
54
apps/modal/reviews/src/lib/reviews.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
apps/modal/reviews/src/lib/reviews.component.ts
Normal file
31
apps/modal/reviews/src/lib/reviews.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
apps/modal/reviews/src/lib/reviews.module.ts
Normal file
13
apps/modal/reviews/src/lib/reviews.module.ts
Normal 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 {}
|
||||
5
apps/modal/reviews/src/public-api.ts
Normal file
5
apps/modal/reviews/src/public-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Public API Surface of reviews
|
||||
*/
|
||||
export * from './lib/reviews.component';
|
||||
export * from './lib/reviews.module';
|
||||
24
apps/modal/reviews/src/test.ts
Normal file
24
apps/modal/reviews/src/test.ts
Normal 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);
|
||||
25
apps/modal/reviews/tsconfig.lib.json
Normal file
25
apps/modal/reviews/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
apps/modal/reviews/tsconfig.lib.prod.json
Normal file
10
apps/modal/reviews/tsconfig.lib.prod.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
apps/modal/reviews/tsconfig.spec.json
Normal file
17
apps/modal/reviews/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
17
apps/modal/reviews/tslint.json
Normal file
17
apps/modal/reviews/tslint.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tslint.json",
|
||||
"rules": {
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"ui",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"ui",
|
||||
"kebab-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
apps/page/catalog/README.md
Normal file
25
apps/page/catalog/README.md
Normal 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).
|
||||
32
apps/page/catalog/karma.conf.js
Normal file
32
apps/page/catalog/karma.conf.js
Normal 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,
|
||||
});
|
||||
};
|
||||
7
apps/page/catalog/ng-package.json
Normal file
7
apps/page/catalog/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist/page/catalog",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
11
apps/page/catalog/package.json
Normal file
11
apps/page/catalog/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
53
apps/page/catalog/src/lib/article-details/slide.animation.ts
Normal file
53
apps/page/catalog/src/lib/article-details/slide.animation.ts
Normal 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,
|
||||
}),
|
||||
])
|
||||
),
|
||||
]),
|
||||
]);
|
||||
@@ -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>
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
471
apps/page/catalog/src/lib/article-search/article-search.store.ts
Normal file
471
apps/page/catalog/src/lib/article-search/article-search.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 „Exakt“ aus.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-tooltip-triangle"></div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
44
apps/page/catalog/src/lib/page-catalog-routing.module.ts
Normal file
44
apps/page/catalog/src/lib/page-catalog-routing.module.ts
Normal 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 {}
|
||||
5
apps/page/catalog/src/lib/page-catalog.component.html
Normal file
5
apps/page/catalog/src/lib/page-catalog.component.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<shell-breadcrumb [key]="application.activatedProcessId$ | async" [includesTags]="['catalog']"></shell-breadcrumb>
|
||||
|
||||
<div class="content-container">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
9
apps/page/catalog/src/lib/page-catalog.component.scss
Normal file
9
apps/page/catalog/src/lib/page-catalog.component.scss
Normal 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;
|
||||
}
|
||||
17
apps/page/catalog/src/lib/page-catalog.component.ts
Normal file
17
apps/page/catalog/src/lib/page-catalog.component.ts
Normal 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 {}
|
||||
}
|
||||
14
apps/page/catalog/src/lib/page-catalog.module.ts
Normal file
14
apps/page/catalog/src/lib/page-catalog.module.ts
Normal 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 {}
|
||||
31
apps/page/catalog/src/lib/page-catalog.resolver.ts
Normal file
31
apps/page/catalog/src/lib/page-catalog.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
apps/page/catalog/src/public-api.ts
Normal file
4
apps/page/catalog/src/public-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
* Public API Surface of catalog
|
||||
*/
|
||||
export * from './lib/page-catalog.module';
|
||||
24
apps/page/catalog/src/test.ts
Normal file
24
apps/page/catalog/src/test.ts
Normal 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
Reference in New Issue
Block a user