diff --git a/cypress/e2e/catalog.cy.ts b/cypress/e2e/catalog.cy.ts index d7abc9ac..c3b82d26 100644 --- a/cypress/e2e/catalog.cy.ts +++ b/cypress/e2e/catalog.cy.ts @@ -21,13 +21,13 @@ describe('/my-offerings',{ }} let calls = 0 const response = [[], [], [newCatalog] , []] - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/catalog?*'}, (res) => { + cy.intercept({method: 'GET', url: '**/catalog/catalog?*'}, (res) => { res.reply({ statusCode: 200, body: response[calls++] }); }).as('catalog') - cy.intercept({method: 'POST', url: 'http://proxy.docker:8004/catalog/catalog'}, {statusCode: 201, body: newCatalog}).as('catalogPOST') + cy.intercept({method: 'POST', url: '**/catalog/catalog'}, {statusCode: 201, body: newCatalog}).as('catalogPOST') cy.visit('/my-offerings') cy.wait('@catalog') @@ -62,13 +62,13 @@ describe('/my-offerings',{ }} let calls = 0 const response = [[], [], [getCatalog] , []] - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/catalog?*'}, (res) => { + cy.intercept({method: 'GET', url: '**/catalog/catalog?*'}, (res) => { res.reply({ statusCode: 200, body: response[calls++] }); }).as('catalog') - cy.intercept({method: 'POST', url: 'http://proxy.docker:8004/catalog/catalog'}, {statusCode: 201, body: newCatalog}).as('catalogPOST') + cy.intercept({method: 'POST', url: '**/catalog/catalog'}, {statusCode: 201, body: newCatalog}).as('catalogPOST') cy.visit('/my-offerings') cy.wait('@catalog') diff --git a/cypress/e2e/offering.cy.ts b/cypress/e2e/offering.cy.ts index 77ea66dd..c7888b91 100644 --- a/cypress/e2e/offering.cy.ts +++ b/cypress/e2e/offering.cy.ts @@ -41,21 +41,11 @@ describe('/my-offerings',{ cy.getBySel('newOffering').click() - step1(productOffering) - - step2(0, length = 1) - - step3(0, length = 1) - - step4(0, length = 1) - - step5('description test') - - step6() - - step7() - - step8() + fillGeneralInfo(productOffering, productSpec, newCatalog) + fillCategory(category_dft) + fillTermsAndConditions('description test') + selectFreePricing() + fillProcurementAndSubmit() cy.wait('@offPOST').then((interception) => { const payload = interception.request.body @@ -63,12 +53,7 @@ describe('/my-offerings',{ expect(payload).to.have.property('description', productOffering.description).and.be.a('string') expect(payload).to.have.property('lifecycleStatus', productOffering.lifecycleStatus).and.be.a('string') }); - cy.getBySel('offers').should('have.length', 1) - cy.getBySel('offers').find('td').eq(0).should('contain.text', productOffering.name) - cy.getBySel('offers').find('td').eq(1).should('contain.text', productOffering.lifecycleStatus) - cy.getBySel('offers').find('td').eq(2).should('contain.text', 'Bundle') - cy.getBySel('offers').find('td').eq(3).should('include.text', 'Friday, 16/05/25, 11:28') - cy.getBySel('offers').find('td').eq(4).find('button').should('have.length', 1) + assertCreatedOfferRow(productOffering.name) }) it('should create a offering with recurring price plan correctly', () => { @@ -103,21 +88,11 @@ describe('/my-offerings',{ cy.getBySel('newOffering').click() - step1(productOffering) - - step2(0, length = 1) - - step3(0, length = 1) - - step4(0, length = 1) - - step5('description test') - - step6(true, pricePlan, priceComponent) - - step7() - - step8() + fillGeneralInfo(productOffering, productSpec, newCatalog) + fillCategory(category_dft) + fillTermsAndConditions('description test') + addOnlinePaidPricePlan(pricePlan, priceComponent) + fillProcurementAndSubmit() cy.wait('@offPricePOST').then((interception) => { @@ -142,12 +117,7 @@ describe('/my-offerings',{ expect(payload).to.have.property('description', productOffering.description).and.be.a('string') expect(payload).to.have.property('lifecycleStatus', productOffering.lifecycleStatus).and.be.a('string') }); - cy.getBySel('offers').should('have.length', 1) - cy.getBySel('offers').find('td').eq(0).should('contain.text', productOffering.name) - cy.getBySel('offers').find('td').eq(1).should('contain.text', productOffering.lifecycleStatus) - cy.getBySel('offers').find('td').eq(2).should('contain.text', 'Bundle') - cy.getBySel('offers').find('td').eq(3).should('include.text', 'Friday, 16/05/25, 11:28') - cy.getBySel('offers').find('td').eq(4).find('button').should('have.length', 1) + assertCreatedOfferRow(productOffering.name) }) it('should create a offering with per usage price plan correctly', () => { @@ -211,21 +181,11 @@ describe('/my-offerings',{ cy.getBySel('newOffering').click() - step1(productOffering) - - step2(0, length = 1) - - step3(0, length = 1) - - step4(0, length = 1) - - step5('description test') - - step6(true, pricePlan, priceComponent) - - step7() - - step8() + fillGeneralInfo(productOffering, productSpec, newCatalog) + fillCategory(category_dft) + fillTermsAndConditions('description test') + addOnlinePaidPricePlan(pricePlan, priceComponent) + fillProcurementAndSubmit() cy.wait('@offPricePOST').then((interception) => { const payload = interception.request.body @@ -250,12 +210,7 @@ describe('/my-offerings',{ expect(payload).to.have.property('description', productOffering.description).and.be.a('string') expect(payload).to.have.property('lifecycleStatus', productOffering.lifecycleStatus).and.be.a('string') }); - cy.getBySel('offers').should('have.length', 1) - cy.getBySel('offers').find('td').eq(0).should('contain.text', productOffering.name) - cy.getBySel('offers').find('td').eq(1).should('contain.text', productOffering.lifecycleStatus) - cy.getBySel('offers').find('td').eq(2).should('contain.text', 'Bundle') - cy.getBySel('offers').find('td').eq(3).should('include.text', 'Friday, 16/05/25, 11:28') - cy.getBySel('offers').find('td').eq(4).find('button').should('have.length', 1) + assertCreatedOfferRow(productOffering.name) }) it('should create a offering with recurring-prepaid price plan correctly', () => { @@ -290,21 +245,11 @@ describe('/my-offerings',{ cy.getBySel('newOffering').click() - step1(productOffering) - - step2(0, length = 1) - - step3(0, length = 1) - - step4(0, length = 1) - - step5('description test') - - step6(true, pricePlan, priceComponent) - - step7() - - step8() + fillGeneralInfo(productOffering, productSpec, newCatalog) + fillCategory(category_dft) + fillTermsAndConditions('description test') + addOnlinePaidPricePlan(pricePlan, priceComponent) + fillProcurementAndSubmit() cy.wait('@offPricePOST').then((interception) => { const payload = interception.request.body @@ -329,12 +274,7 @@ describe('/my-offerings',{ expect(payload).to.have.property('description', productOffering.description).and.be.a('string') expect(payload).to.have.property('lifecycleStatus', productOffering.lifecycleStatus).and.be.a('string') }); - cy.getBySel('offers').should('have.length', 1) - cy.getBySel('offers').find('td').eq(0).should('contain.text', productOffering.name) - cy.getBySel('offers').find('td').eq(1).should('contain.text', productOffering.lifecycleStatus) - cy.getBySel('offers').find('td').eq(2).should('contain.text', 'Bundle') - cy.getBySel('offers').find('td').eq(3).should('include.text', 'Friday, 16/05/25, 11:28') - cy.getBySel('offers').find('td').eq(4).find('button').should('have.length', 1) + assertCreatedOfferRow(productOffering.name) }) }) @@ -342,134 +282,142 @@ describe('/my-offerings',{ const interceptors = (productSpec:any, productOfferingPOST:any, newCatalog:any, defaultCatalog: any, defaultCategory:any, offPricePOST:any) => { - const specResponse = [[productSpec], []] - const sr = [[newCatalog],[],[newCatalog], []] - let specCall = 0 - let scall = 0 let offeringCreated = false + let priceCreateCalls = 0 - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/catalog?*'}, (res)=>{ + cy.intercept({method: 'GET', url: '**/catalog/catalog?*'}, (res)=>{ res.reply({ statusCode: 200, - body: sr[scall++] + body: [newCatalog] }) }).as('catalogs') - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/productOffering?*'}, (res)=>{ + cy.intercept({method: 'GET', url: '**/catalog/productOffering?*'}, (res)=>{ res.reply({ statusCode: 200, body: offeringCreated ? [productOfferingPOST] : [] }) }).as('productOff') - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/productSpecification?*'}, (res)=>{ + cy.intercept({method: 'GET', url: '**/catalog/productSpecification?*'}, (res)=>{ res.reply({ statusCode: 200, - body: specResponse[specCall++] + body: [productSpec] }) }).as('productSpec') + cy.intercept({method: 'GET', url: '**/catalog/productSpecification/urn:ngsi-ld:product-specification:*'}, { + statusCode: 200, + body: productSpec + }).as('productSpecDetail') - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/catalog/urn:ngsi-ld:catalog:32828e1d-4652-4f4c-b13e-327450ce83c6'}, (res)=>{ + cy.intercept({method: 'GET', url: '**/catalog/catalog/urn:ngsi-ld:catalog:32828e1d-4652-4f4c-b13e-327450ce83c6'}, (res)=>{ res.reply({ statusCode: 200, body: defaultCatalog }) }).as('defaultCatalog') - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/category/urn:ngsi-ld:category:26435cca-2707-4c89-8f0c-79464573c9e2'}, (res)=>{ + cy.intercept({method: 'GET', url: '**/catalog/category/urn:ngsi-ld:category:26435cca-2707-4c89-8f0c-79464573c9e2'}, (res)=>{ res.reply({ statusCode: 200, body: defaultCategory }) }).as('defaultCategory') + cy.intercept({method: 'GET', url: '**/catalog/category?*'}, (res)=>{ + res.reply({ + statusCode: 200, + body: res.url.includes('parentId=') ? [] : [defaultCategory] + }) + }).as('categories') - cy.intercept({method: 'POST', url: `http://proxy.docker:8004/catalog/catalog/${newCatalog.id}/productOffering`}, (req)=>{ + cy.intercept({method: 'POST', url: `**/catalog/catalog/${newCatalog.id}/productOffering`}, (req)=>{ offeringCreated = true req.reply({statusCode: 201, body: productOfferingPOST}) }).as('offPOST') if (offPricePOST){ - cy.intercept({method: 'GET', url: 'http://proxy.docker:8004//usage/usageSpecification?*'}, (res)=>{ - res.reply({ - statusCode: 200, - body: offPricePOST.usage - }) - }).as('usageGET') - cy.intercept({method: 'POST', url: 'http://proxy.docker:8004/catalog//productOfferingPrice'}, {statusCode: 201, body: offPricePOST}).as('offPricePOST') + cy.intercept({method: 'GET', url: '**/usage/usageSpecification?*'}, (res)=>{ + res.reply({ + statusCode: 200, + body: offPricePOST.usage + }) + }).as('usageGET') + cy.intercept({method: 'POST', url: '**/catalog/productOfferingPrice'}, (req) => { + const id = `urn:ngsi-ld:product-offering-price:${priceCreateCalls++}` + req.reply({ + statusCode: 201, + body: { + ...req.body, + id, + href: id + } + }) + }).as('offPricePOST') } } -const step1 = (productOffering: any) => { - +const fillGeneralInfo = (productOffering: any, productSpec: any, catalog: any) => { + cy.wait('@productSpec') + cy.wait('@catalogs') cy.getBySel('offerName').type(productOffering.name) - cy.getBySel('offerVersion').should('have.value', productOffering.version) - cy.getBySel('textArea').type(productOffering.description) + cy.get('#prodSpecSelect').select(productSpec.id) + cy.wait('@productSpecDetail') + cy.getBySel('offerCatalogSelect').select(catalog.id) + cy.getBySel('offerOverview').type(productOffering.description) cy.getBySel('offerNext').click() } -const step2 = (pos: number, length: number) => { - cy.wait('@productSpec') - cy.getBySel('prodSpecs').should('have.length', length) - cy.getBySel('prodSpecs').eq(pos).click() - cy.getBySel('offerNext').click() +const assertCreatedOfferRow = (name: string) => { + cy.getBySel('offerRow').should('have.length', 1) + cy.getBySel('offerTitle').should('contain.text', name) + cy.getBySel('offerStatus').should('contain.text', 'Not completed') + cy.getBySel('offerActions').find('button').should('have.length.at.least', 1) } -const step3 = (pos: number, length: number) => { - cy.wait('@catalogs') - cy.getBySel('catalogList').should('have.length', length) - cy.getBySel('catalogList').eq(pos).click() +const fillCategory = (category: any) => { + cy.get('#rootCategorySelect').find('option').should('contain.text', category.name) + cy.get('#rootCategorySelect').select(category.id) cy.getBySel('offerNext').click() } -const step4 = (pos: number, length: number) => { - cy.wait('@defaultCatalog') - cy.wait('@defaultCategory') - cy.getBySel('categoryList').should('have.length', length) - cy.getBySel('categoryList').eq(pos).click() +const fillTermsAndConditions = (description: string) => { + cy.getBySel('tcText').find('[data-cy="textArea"]').type(description) cy.getBySel('offerNext').click() } -const step5 = (description: string) => { - //cy.getBySel('treatment').type(treatment) - cy.getBySel('textArea').type(description) +const selectFreePricing = () => { + cy.contains('button.plan-card', 'Free').click() cy.getBySel('offerNext').click() } -const step6 = (online: boolean=false, pricePlan:any = null, priceComponent:PriceComponent = null) => { - if(online){ - cy.getBySel('pricePlanType').select('paid') +const addOnlinePaidPricePlan = (pricePlan:any, priceComponent:PriceComponent) => { + cy.contains('button.plan-card', 'Online paid price').click() + cy.getBySel('addPricePlan').first().click() + cy.contains('button.plan-card', 'Flex plan').click() + cy.getBySel('selectPlanTypeContinue').click() + + cy.getBySel('paidName').type(pricePlan.name) + cy.getBySel('paidDescription').find('[data-cy="textArea"]').type(pricePlan.description) + cy.getBySel('addPriceComponent').click() + cy.getBySel('pcName').type(priceComponent.name) + cy.getBySel('pcDescription').type(priceComponent.description) + cy.getBySel('pcBasePrice').type(String(priceComponent.price)) + cy.getBySel('pcPriceType').click() + cy.get(`[data-cy="pcPriceType-${priceComponent.type}"]`).click() + if (priceComponent.recurringType){ + cy.get(`[data-cy="pcRecurringPeriod-${priceComponent.recurringType}"]`).check() } - if(pricePlan){ - cy.getBySel('newPricePlan').click() - cy.getBySel('pricePlanName').type(pricePlan.name) - cy.getBySel('textArea').type(pricePlan.description) - cy.getBySel('savePricePlan').should('have.attr', 'disabled') - if(priceComponent){ - cy.getBySel('newPriceComponent').click() - cy.getBySel('priceComponentName').type(priceComponent.name) - cy.getBySel('priceComponentDescription').find('[data-cy="textArea"]').type(priceComponent.description) - cy.getBySel('price').type(String(priceComponent.price)) - cy.getBySel('priceType').select(priceComponent.type) - if (priceComponent.recurringType){ - cy.getBySel('recurringType').select(priceComponent.recurringType) - } - else if (priceComponent.usageInput){ - cy.wait('@usageGET') - cy.getBySel('usageInput').select(priceComponent.usageInput[0]) - cy.getBySel('usageMetric').select(priceComponent.usageInput[1]) - } - cy.getBySel('savePriceComponent').click() - } - cy.getBySel('savePricePlan').click() + else if (priceComponent.usageInput){ + cy.wait('@usageGET') + cy.getBySel('pcUsageSpec').select(priceComponent.usageInput[0]) + cy.getBySel('pcMetric').select(priceComponent.usageInput[1]) } + cy.getBySel('pcSave').click() + cy.getBySel('ppSave').should('not.be.disabled').click() cy.getBySel('offerNext').click() } -const step7 = () => { - cy.getBySel('offerNext').click() -} - -const step8 = () => { +const fillProcurementAndSubmit = () => { cy.getBySel('offerFinish').click() } diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index 572ad92c..a552f464 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -232,11 +232,15 @@ export const local_items = { export const checkHeaderPreLogin = () => { // Mocks cy.intercept( {method:'GET', url: '**/stats*'}, init_stat).as('stats') - //cy.intercept( {method: 'GET', url: 'http://proxy.docker:8004/catalog/productOffering?*'}, product_offering).as('productOffering') cy.intercept( {method: 'GET', url: '**/config*'}, init_config).as('config') //cy.intercept('GET', '**/catalog/category/urn:ngsi-ld:category:*', category_dft).as('category'); cy.intercept({method: 'GET', url: '**/catalog/catalog*'}, default_catalog).as('catalog') - cy.intercept( {method: 'GET', url: '**/catalog/category*'}, category_dft).as('category') + cy.intercept( {method: 'GET', url: '**/catalog/category*'}, (req) => { + req.reply({ + statusCode: 200, + body: req.url.includes('/catalog/category/') ? category_dft : [category_dft] + }) + }).as('category') // Verify mocks are called 1 time cy.visit('/', {onBeforeLoad(win) { win.localStorage.setItem('color-theme', 'dark'); diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4f133f14..4ae7dd6d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -19,7 +19,6 @@ import { ProductDetailsComponent } from "./pages/product-details/product-details import { ProductInvDetailComponent } from './pages/product-inventory/inventory-items/product-inv-detail/product-inv-detail.component'; import { ProductInventoryComponent } from './pages/product-inventory/product-inventory.component'; import { ProductOrdersComponent } from './pages/product-orders/product-orders.component'; -import { SearchCatalogComponent } from "./pages/search-catalog/search-catalog.component"; import { SearchComponent } from "./pages/search/search.component"; import { SellerOfferingsComponent } from "./pages/seller-offerings/seller-offerings.component"; import { ShoppingCartComponent } from "./pages/shopping-cart/shopping-cart.component"; @@ -46,11 +45,13 @@ const routes: Routes = [ }, { path: 'org-details/:id', - component: OrganizationDetailsComponent + component: OrganizationDetailsComponent, + data: { mode: 'organization' } }, { path: 'search/catalogue/:id', - component: SearchCatalogComponent + component: OrganizationDetailsComponent, + data: { mode: 'catalog' } }, { path: 'catalogues', diff --git a/src/app/app.component.css b/src/app/app.component.css index 75243035..c43086a7 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -8,3 +8,9 @@ .app-content > main { flex: 1 0 auto; } + +.app-content--workspace > main { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} diff --git a/src/app/app.component.html b/src/app/app.component.html index 82642676..da413b38 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,12 +1,14 @@ -
+
- + @if (!isWorkspaceRoute) { + + }
@if (providerThemeName === "DOME") { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 51382324..3760af49 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -26,6 +26,8 @@ export class AppComponent implements OnInit { providerThemeName=environment.providerThemeName; isProduction:boolean = environment.isProduction; showHeaderAndFooter = false; + isWorkspaceRoute = false; + private workspaceRoutes = ['/my-offerings']; constructor(private translate: TranslateService, private localStorage: LocalStorageService, @@ -90,13 +92,15 @@ export class AppComponent implements OnInit { this.refreshApi.startInterval(((aux.expire - moment().unix())-4)*1000, aux); initFlowbite(); } + this.isWorkspaceRoute = this.workspaceRoutes.some(r => this.router.url.startsWith(r)); this.router.events .pipe( filter( (event): event is NavigationEnd => event instanceof NavigationEnd, ), ) - .subscribe(() => { + .subscribe((ev: NavigationEnd) => { + this.isWorkspaceRoute = this.workspaceRoutes.some(r => ev.urlAfterRedirects.startsWith(r)); window.scrollTo({ top: 0, behavior: 'smooth' }); // or just window.scrollTo(0, 0); }); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 2257a51d..326fcdbf 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -59,6 +59,9 @@ import { SellerProductSpecComponent } from './pages/seller-offerings/offerings/s import { UpdateProductSpecComponent } from './pages/seller-offerings/offerings/seller-product-spec/update-product-spec/update-product-spec.component'; import { CreateResourceSpecComponent } from './pages/seller-offerings/offerings/seller-resource-spec/create-resource-spec/create-resource-spec.component'; import { SellerResourceSpecComponent } from './pages/seller-offerings/offerings/seller-resource-spec/seller-resource-spec.component'; +import { SellerUsageSpecComponent } from './pages/seller-offerings/offerings/seller-usage-spec/seller-usage-spec.component'; +import { CreateUsageSpecComponent } from './pages/usage-specs/usage-sections/create-usage-spec/create-usage-spec.component'; +import { UpdateUsageSpecComponent } from './pages/usage-specs/usage-sections/update-usage-spec/update-usage-spec.component'; import { UpdateResourceSpecComponent } from './pages/seller-offerings/offerings/seller-resource-spec/update-resource-spec/update-resource-spec.component'; import { CreateServiceSpecComponent } from './pages/seller-offerings/offerings/seller-service-spec/create-service-spec/create-service-spec.component'; import { SellerServiceSpecComponent } from './pages/seller-offerings/offerings/seller-service-spec/seller-service-spec.component'; @@ -134,6 +137,7 @@ import { ConfirmDialogComponent } from './shared/confirm-dialog/confirm-dialog.c SellerProductSpecComponent, SellerServiceSpecComponent, SellerResourceSpecComponent, + SellerUsageSpecComponent, SellerOfferComponent, CreateProductSpecComponent, CreateServiceSpecComponent, @@ -206,6 +210,8 @@ import { ConfirmDialogComponent } from './shared/confirm-dialog/confirm-dialog.c MarkdownTextareaComponent, ProviderRevenueSharingComponent, OperatorRevenueSharingComponent, + CreateUsageSpecComponent, + UpdateUsageSpecComponent, ConfirmDialogComponent ], providers: [ diff --git a/src/app/config/popular-icons.ts b/src/app/config/popular-icons.ts new file mode 100644 index 00000000..065e927a --- /dev/null +++ b/src/app/config/popular-icons.ts @@ -0,0 +1,131 @@ +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { + faBuilding, + faUsers, + faBuildingUser, + faShareNodes, + faClipboardList, + faGears, + faArrowsUpDownLeftRight, + faClock, + faGlobe, + faSliders, + faPlugCirclePlus, + faBrain, + faChartLine, + faDatabase, + faChartPie, + faMagnifyingGlass, + faLightbulb, + faGaugeHigh, + faEye, + faShuffle, + faChartColumn, + faShield, + faBuildingShield, + faUserShield, + faCertificate, + faScaleBalanced, + faLock, + faServer, + faCloud, + faCode, + faLaptopCode, + faHardDrive, + faMobileScreen, + faScrewdriverWrench, + faHeadset, + faComments, + faFileLines, + faGraduationCap, + faBolt, + faStar, + faHandshake, +} from '@fortawesome/free-solid-svg-icons'; + +export interface PopularIcon { + name: string; + icon: IconDefinition; +} + +export interface IconCategory { + labelKey: string; + icons: PopularIcon[]; +} + +export const POPULAR_ICON_CATEGORIES: IconCategory[] = [ + { + labelKey: 'CREATE_PROD_SPEC._icon_cat_business', + icons: [ + { name: 'building', icon: faBuilding }, + { name: 'users', icon: faUsers }, + { name: 'building-user', icon: faBuildingUser }, + { name: 'sitemap', icon: faShareNodes }, + { name: 'clipboard-list', icon: faClipboardList }, + { name: 'gears', icon: faGears }, + { name: 'arrows-move', icon: faArrowsUpDownLeftRight }, + { name: 'clock', icon: faClock }, + { name: 'globe', icon: faGlobe }, + { name: 'sliders', icon: faSliders }, + { name: 'plug-circle-plus', icon: faPlugCirclePlus }, + ], + }, + { + labelKey: 'CREATE_PROD_SPEC._icon_cat_data', + icons: [ + { name: 'brain', icon: faBrain }, + { name: 'chart-line', icon: faChartLine }, + { name: 'database', icon: faDatabase }, + { name: 'chart-pie', icon: faChartPie }, + { name: 'search', icon: faMagnifyingGlass }, + { name: 'lightbulb', icon: faLightbulb }, + { name: 'gauge', icon: faGaugeHigh }, + { name: 'eye', icon: faEye }, + { name: 'shuffle', icon: faShuffle }, + { name: 'chart-bar', icon: faChartColumn }, + ], + }, + { + labelKey: 'CREATE_PROD_SPEC._icon_cat_security', + icons: [ + { name: 'shield', icon: faShield }, + { name: 'building-shield', icon: faBuildingShield }, + { name: 'user-shield', icon: faUserShield }, + { name: 'certificate', icon: faCertificate }, + { name: 'balance', icon: faScaleBalanced }, + { name: 'lock', icon: faLock }, + { name: 'hard-drive', icon: faHardDrive }, + ], + }, + { + labelKey: 'CREATE_PROD_SPEC._icon_cat_tech', + icons: [ + { name: 'cloud', icon: faCloud }, + { name: 'code', icon: faCode }, + { name: 'laptop-code', icon: faLaptopCode }, + { name: 'server', icon: faServer }, + { name: 'mobile', icon: faMobileScreen }, + { name: 'tools', icon: faScrewdriverWrench }, + ], + }, + { + labelKey: 'CREATE_PROD_SPEC._icon_cat_customer', + icons: [ + { name: 'headset', icon: faHeadset }, + { name: 'comments', icon: faComments }, + { name: 'file', icon: faFileLines }, + { name: 'graduation-cap', icon: faGraduationCap }, + { name: 'bolt', icon: faBolt }, + { name: 'star', icon: faStar }, + { name: 'handshake', icon: faHandshake }, + ], + }, +]; + +export const POPULAR_ICONS: PopularIcon[] = POPULAR_ICON_CATEGORIES.flatMap(c => c.icons); + +export function findIconByName(name: string | undefined | null): IconDefinition | null { + if(!name) return null; + const found = POPULAR_ICONS.find(i => i.name === name); + return found ? found.icon : null; +} diff --git a/src/app/data/availableFilters.ts b/src/app/data/availableFilters.ts index d1e21352..78cbdc3a 100644 --- a/src/app/data/availableFilters.ts +++ b/src/app/data/availableFilters.ts @@ -8,6 +8,7 @@ export type Filter = { label?: string source?: 'configured' | 'categoryRoot' rootName?: string + offerFormPlacement?: 'none' | 'generalInfo' | 'categorySection' children?: FilterOption[] } @@ -42,6 +43,7 @@ type RuntimeSearchFiltersConfig = { function cloneFilters(filters: Filter[]): Filter[] { return (filters || []).map(filter => ({ ...filter, + offerFormPlacement: filter.offerFormPlacement, children: filter.children ? filter.children.map(child => ({ name: child.name, @@ -68,11 +70,17 @@ function normalizeFilter(raw: any): Filter | null { } const source = raw.source === 'categoryRoot' ? 'categoryRoot' : 'configured' + const offerFormPlacement = source === 'categoryRoot' && ( + raw.offerFormPlacement === 'generalInfo' || raw.offerFormPlacement === 'categorySection' + ) + ? raw.offerFormPlacement + : 'none' const filter: Filter = { name: raw.name.trim(), label: typeof raw.label === 'string' ? raw.label : undefined, source, rootName: source === 'categoryRoot' && typeof raw.rootName === 'string' ? raw.rootName : undefined, + offerFormPlacement, children: source === 'configured' && Array.isArray(raw.children) ? raw.children.map(normalizeFilterOption).filter((child: FilterOption | null): child is FilterOption => !!child) : undefined, diff --git a/src/app/data/featuresConfig.ts b/src/app/data/featuresConfig.ts index 220523f3..4131c3a6 100644 --- a/src/app/data/featuresConfig.ts +++ b/src/app/data/featuresConfig.ts @@ -6,6 +6,7 @@ export type FeatureFlagKey = | 'tenderingEnabled' | 'dataSpaceEnabled' | 'dspEnabled' + | 'catalogManagementEnabled' | 'launchValidationEnabled' | 'tenderDevButtonsOpenCloseEnabled' | 'aiEnabled'; @@ -16,50 +17,55 @@ export type FeaturesConfig = Partial> & R export interface FeatureFlagDefinition { key: FeatureFlagKey; - label: string; - description: string; + labelKey: string; + descriptionKey: string; } export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ { key: 'quotesEnabled', - label: 'Quotes', - description: 'Enable quote request flows and quote pages.' + labelKey: 'ADMIN.FEATURES._quotes_label', + descriptionKey: 'ADMIN.FEATURES._quotes_description' }, { key: 'purchaseEnabled', - label: 'Purchases', - description: 'Enable shopping cart and checkout actions.' + labelKey: 'ADMIN.FEATURES._purchases_label', + descriptionKey: 'ADMIN.FEATURES._purchases_description' }, { key: 'aiEnabled', - label: 'AI search', - description: 'Enable AI-assisted catalog search.' + labelKey: 'ADMIN.FEATURES._ai_search_label', + descriptionKey: 'ADMIN.FEATURES._ai_search_description' }, { key: 'tenderingEnabled', - label: 'Tendering', - description: 'Enable tendering features.' + labelKey: 'ADMIN.FEATURES._tendering_label', + descriptionKey: 'ADMIN.FEATURES._tendering_description' }, { key: 'dataSpaceEnabled', - label: 'Data space', - description: 'Enable data space fields in organization and offer forms.' + labelKey: 'ADMIN.FEATURES._data_space_label', + descriptionKey: 'ADMIN.FEATURES._data_space_description' }, { key: 'dspEnabled', - label: 'DSP', - description: 'Enable DSP-related data space contract and schema fields.' + labelKey: 'ADMIN.FEATURES._dsp_label', + descriptionKey: 'ADMIN.FEATURES._dsp_description' + }, + { + key: 'catalogManagementEnabled', + labelKey: 'ADMIN.FEATURES._catalog_management_label', + descriptionKey: 'ADMIN.FEATURES._catalog_management_description' }, { key: 'launchValidationEnabled', - label: 'Launch validation', - description: 'Enable launch validation requests for offerings.' + labelKey: 'ADMIN.FEATURES._launch_validation_label', + descriptionKey: 'ADMIN.FEATURES._launch_validation_description' }, { key: 'tenderDevButtonsOpenCloseEnabled', - label: 'Tender dev actions', - description: 'Enable tender open and close development controls.' + labelKey: 'ADMIN.FEATURES._tender_dev_actions_label', + descriptionKey: 'ADMIN.FEATURES._tender_dev_actions_description' } ]; @@ -83,6 +89,7 @@ export function readFeaturesConfig(config: any): FeaturesConfig { result.tenderingEnabled = readBoolean(source, 'tenderingEnabled'); result.dataSpaceEnabled = readBoolean(source, 'dataSpaceEnabled'); result.dspEnabled = readBoolean(source, 'dspEnabled'); + result.catalogManagementEnabled = readBoolean(source, 'catalogManagementEnabled'); result.launchValidationEnabled = readBoolean(source, 'launchValidationEnabled'); result.tenderDevButtonsOpenCloseEnabled = readBoolean(source, 'tenderDevButtonsOpenCloseEnabled'); result.aiEnabled = readBoolean(source, 'aiEnabled'); @@ -99,6 +106,7 @@ export function applyRuntimeFeaturesConfig(config: any): void { environment.TENDER_ENABLED = features.tenderingEnabled ?? false; environment.DATA_SPACE_ENABLED = features.dataSpaceEnabled ?? false; environment.DSP_ENABLED = features.dspEnabled ?? environment.DSP_ENABLED; + environment.CATALOG_MANAGEMENT_ENABLED = features.catalogManagementEnabled ?? environment.CATALOG_MANAGEMENT_ENABLED; environment.LAUNCH_VALIDATION_ENABLED = features.launchValidationEnabled ?? false; environment.TENDER_DEV_BUTTONS_OPEN_CLOSE_ENABLED = features.tenderDevButtonsOpenCloseEnabled ?? false; environment.AI_SEARCH_ENABLED = features.aiEnabled ?? false; diff --git a/src/app/pages/admin/admin.component.html b/src/app/pages/admin/admin.component.html index 66c38fe6..2af6e888 100644 --- a/src/app/pages/admin/admin.component.html +++ b/src/app/pages/admin/admin.component.html @@ -5,7 +5,7 @@

My offerings

- +
- --> +
@if (loading) {
- Loading...
} @else { -
- - @for (cat of catalogs; track cat.id) { - -
-
-
{{cat.name}}
- - -
-
- @for(category of cat.category; track category.id) { - {{category.name}} - } @empty { - {{'CATALOGS._no_cat' | translate}} - } -
-
- -
-
+ @if (viewMode === 'grid') { +
+
+ @for (prov of providers; track prov.id) { +
+
+
- +
+
+
{{ prov.name }}
+

{{ prov.description || ('CATALOGS._no_desc' | translate) }}

+
+
+
} - +
+ } @else { +
+
+ @for (prov of providers; track prov.id) { +
+
+
+
+
+
+
{{ prov.name }}
+

{{ prov.description || ('CATALOGS._no_desc' | translate) }}

+
+
+
+ } +
+
+ } } @if (!loading_more) { @if (page_check) { -
- +
+
} } @else {
-

- -
-
- - -
-
- {{ showingCat?.name }} -
-
- -
- - -
-
- - -
-
- -
- - -
- @for(category of showingCat.category; track category.id) { - - {{ category.name }} - - } @empty { - - {{ 'CATALOGS._no_cat' | translate }} - - } -
-
-

- -} \ No newline at end of file diff --git a/src/app/pages/catalogs/catalogs.component.spec.ts b/src/app/pages/catalogs/catalogs.component.spec.ts index 59cf3a1a..f0f6bd84 100644 --- a/src/app/pages/catalogs/catalogs.component.spec.ts +++ b/src/app/pages/catalogs/catalogs.component.spec.ts @@ -1,49 +1,54 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; +import { ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { CatalogsComponent } from './catalogs.component'; +import { AccountServiceService } from 'src/app/services/account-service.service'; import { ApiServiceService } from 'src/app/services/product-service.service'; -import { PaginationService } from 'src/app/services/pagination.service'; -import { environment } from 'src/environments/environment'; +import { ThemeService } from 'src/app/services/theme.service'; describe('CatalogsComponent', () => { let component: CatalogsComponent; let fixture: ComponentFixture; let apiServiceSpy: jasmine.SpyObj; - let paginationServiceSpy: jasmine.SpyObj; + let accountServiceSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; - - const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + let themeSubject: BehaviorSubject; + const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)); beforeEach(async () => { - apiServiceSpy = jasmine.createSpyObj('ApiServiceService', ['getCatalogs']); - apiServiceSpy.getCatalogs.and.returnValue(Promise.resolve([] as any)); - - paginationServiceSpy = jasmine.createSpyObj('PaginationService', ['getItemsPaginated']); - paginationServiceSpy.getItemsPaginated.and.returnValue( - Promise.resolve({ - page_check: true, - items: [], - nextItems: [], - page: 0, - }), - ); + apiServiceSpy = jasmine.createSpyObj('ApiServiceService', ['getLaunchedCatalogsPage']); + apiServiceSpy.getLaunchedCatalogsPage.and.returnValue(Promise.resolve({ + items: [], + filteredPaginationToken: null + })); - routerSpy = jasmine.createSpyObj('Router', ['navigate']); + accountServiceSpy = jasmine.createSpyObj('AccountServiceService', ['getOrgInfo']); + accountServiceSpy.getOrgInfo.and.returnValue(Promise.resolve({})); + themeSubject = new BehaviorSubject(null); await TestBed.configureTestingModule({ declarations: [CatalogsComponent], - imports: [TranslateModule.forRoot()], + imports: [ReactiveFormsModule, RouterTestingModule, TranslateModule.forRoot()], providers: [ { provide: ApiServiceService, useValue: apiServiceSpy }, - { provide: PaginationService, useValue: paginationServiceSpy }, - { provide: Router, useValue: routerSpy }, + { provide: AccountServiceService, useValue: accountServiceSpy }, + { + provide: ThemeService, + useValue: { + currentTheme$: themeSubject.asObservable() + } + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + routerSpy = TestBed.inject(Router) as jasmine.SpyObj; + spyOn(routerSpy, 'navigate'); fixture = TestBed.createComponent(CatalogsComponent); component = fixture.componentInstance; }); @@ -52,128 +57,128 @@ describe('CatalogsComponent', () => { expect(component).toBeTruthy(); }); - it('ngOnInit should set loading and request catalogs', () => { - spyOn(component, 'getCatalogs'); - spyOn(document, 'querySelector').and.returnValue(null); - - component.ngOnInit(); + it('should request the initial launched catalog page without a continuation token', async () => { + apiServiceSpy.getLaunchedCatalogsPage.and.returnValue( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Catalog 1' }], filteredPaginationToken: null }) + ); - expect(component.loading).toBeTrue(); - expect(component.getCatalogs).toHaveBeenCalledWith(false); - }); + await component.getProviders(false); - it('ngOnInit should register input listener and reset filter when search field is empty', () => { - let inputListener: ((event: Event) => void) | undefined; - const inputMock = { - addEventListener: (_name: string, callback: (event: Event) => void) => { - inputListener = callback; - }, - }; - spyOn(document, 'querySelector').and.returnValue(inputMock as any); - spyOn(component, 'getCatalogs'); - component.searchField.setValue('seed'); - - component.ngOnInit(); - expect(component.getCatalogs).toHaveBeenCalledWith(false); - - component.searchField.setValue(''); - inputListener!(new Event('input')); - - expect(component.filter).toBeUndefined(); - expect(component.getCatalogs).toHaveBeenCalledTimes(2); - expect(component.getCatalogs).toHaveBeenCalledWith(false); + expect(apiServiceSpy.getLaunchedCatalogsPage).toHaveBeenCalledTimes(1); + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(0)).toEqual([0, undefined, component.CATALOG_LIMIT, null]); + expect(component.providers.map(provider => provider.id)).toEqual(['catalog-1']); + expect(component.page_check).toBeFalse(); }); - it('getCatalogs should call pagination service and update state while filtering out default catalog', async () => { - const defaultCatalog = { id: environment.DFT_CATALOG_ID, name: 'Default' } as any; - const customCatalog = { id: 'custom-cat', name: 'Custom' } as any; - - paginationServiceSpy.getItemsPaginated.and.returnValue( - Promise.resolve({ - page_check: false, - items: [defaultCatalog, customCatalog], - nextItems: [{ id: 'next-cat' }], - page: 3, - }), + it('should store the returned continuation token and show load more', async () => { + apiServiceSpy.getLaunchedCatalogsPage.and.returnValues( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Catalog 1' }], filteredPaginationToken: 'token-page-1' }), + Promise.resolve({ items: [{ id: 'catalog-2', name: 'Catalog 2' }], filteredPaginationToken: 'token-page-2' }) ); - component.loading = false; - component.loading_more = true; - - await component.getCatalogs(false); - await flushPromises(); - - const args = paginationServiceSpy.getItemsPaginated.calls.mostRecent().args; - expect(args[0]).toBe(0); - expect(args[1]).toBe(component.CATALOG_LIMIT); - expect(args[2]).toBeFalse(); - expect(args[5]).toEqual({ keywords: undefined }); - expect(typeof args[6]).toBe('function'); - - expect(component.page_check).toBeFalse(); - expect(component.catalogs).toEqual([customCatalog]); - expect(component.nextCatalogs).toEqual([{ id: 'next-cat' } as any]); - expect(component.page).toBe(3); - expect(component.loading).toBeFalse(); - expect(component.loading_more).toBeFalse(); + await component.getProviders(false); + + expect(apiServiceSpy.getLaunchedCatalogsPage).toHaveBeenCalledTimes(2); + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(1)).toEqual([ + component.CATALOG_LIMIT, + undefined, + component.CATALOG_LIMIT, + 'token-page-1' + ]); + expect(component.providers.map(provider => provider.id)).toEqual(['catalog-1']); + expect(component.page_check).toBeTrue(); + expect((component as any).filteredPaginationToken).toBe('token-page-2'); }); - it('filterCatalogs should reset page and call getCatalogs with filter value', () => { - spyOn(component, 'getCatalogs'); - component.page = 8; - component.searchField.setValue('my-catalog'); + it('should send the stored continuation token when loading more catalogs', async () => { + apiServiceSpy.getLaunchedCatalogsPage.and.returnValues( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Catalog 1' }], filteredPaginationToken: 'token-page-1' }), + Promise.resolve({ items: [{ id: 'catalog-2', name: 'Catalog 2' }], filteredPaginationToken: 'token-page-2' }), + Promise.resolve({ items: [{ id: 'catalog-3', name: 'Catalog 3' }], filteredPaginationToken: 'token-page-3' }) + ); - component.filterCatalogs(); + await component.getProviders(false); + await component.next(); - expect(component.filter).toBe('my-catalog'); - expect(component.page).toBe(0); - expect(component.getCatalogs).toHaveBeenCalledWith(false); + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(2)).toEqual([ + component.CATALOG_LIMIT * 2, + undefined, + component.CATALOG_LIMIT, + 'token-page-2' + ]); + expect(component.providers.map(provider => provider.id)).toEqual(['catalog-1', 'catalog-2']); + expect(component.page_check).toBeTrue(); + expect((component as any).filteredPaginationToken).toBe('token-page-3'); }); - it('next should request next page', async () => { - spyOn(component, 'getCatalogs').and.returnValue(Promise.resolve()); + it('should reveal the prefetched page and disable load more when there is no further continuation token', async () => { + apiServiceSpy.getLaunchedCatalogsPage.and.returnValues( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Catalog 1' }], filteredPaginationToken: 'token-page-1' }), + Promise.resolve({ items: [{ id: 'catalog-2', name: 'Catalog 2' }], filteredPaginationToken: null }) + ); + await component.getProviders(false); await component.next(); - expect(component.getCatalogs).toHaveBeenCalledWith(true); + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(1)).toEqual([ + component.CATALOG_LIMIT, + undefined, + component.CATALOG_LIMIT, + 'token-page-1' + ]); + expect(apiServiceSpy.getLaunchedCatalogsPage).toHaveBeenCalledTimes(2); + expect(component.providers.map(provider => provider.id)).toEqual(['catalog-1', 'catalog-2']); + expect(component.page_check).toBeFalse(); + expect((component as any).filteredPaginationToken).toBeNull(); }); - it('goToCatalogSearch should navigate to catalog route', () => { - component.goToCatalogSearch('cat-123'); + it('should clear the continuation token when the search filter changes', async () => { + (component as any).filteredPaginationToken = 'stale-token'; + component.searchField.setValue('cloud'); + apiServiceSpy.getLaunchedCatalogsPage.and.returnValues( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Cloud catalog' }], filteredPaginationToken: null }), + Promise.resolve({ items: [], filteredPaginationToken: null }) + ); - expect(routerSpy.navigate).toHaveBeenCalledWith(['/search/catalogue', 'cat-123']); + component.filterProviders(); + await flushPromises(); + + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(0)).toEqual([0, 'cloud', component.CATALOG_LIMIT, null]); + expect((component as any).filteredPaginationToken).toBeNull(); }); - it('showFullDesc should set visible description and selected catalog', () => { - const cat = { id: 'cat-1', name: 'Catalog 1' }; + it('should reset the token and reload catalogs when sort changes', async () => { + (component as any).filteredPaginationToken = 'stale-token'; + apiServiceSpy.getLaunchedCatalogsPage.and.returnValues( + Promise.resolve({ items: [{ id: 'catalog-1', name: 'Cloud catalog' }], filteredPaginationToken: null }), + Promise.resolve({ items: [], filteredPaginationToken: null }) + ); - component.showFullDesc(cat); + component.selectSort('name_asc', new Event('click')); + await flushPromises(); - expect(component.showDesc).toBeTrue(); - expect(component.showingCat).toBe(cat); + expect(apiServiceSpy.getLaunchedCatalogsPage.calls.argsFor(0)).toEqual([0, undefined, component.CATALOG_LIMIT, null]); + expect(component.sortOption).toBe('name_asc'); + expect((component as any).filteredPaginationToken).toBeNull(); }); - it('onClick should close details and trigger change detection when description is open', () => { - component.showDesc = true; - const cdrSpy = spyOn((component as any).cdr, 'detectChanges'); + it('should navigate to the selected catalog route', () => { + component.goToProvider('cat-123'); - component.onClick(); - - expect(component.showDesc).toBeFalse(); - expect(cdrSpy).toHaveBeenCalled(); + expect(routerSpy.navigate).toHaveBeenCalledWith(['/search/catalogue', 'cat-123']); }); - it('onClick should do nothing when description is closed', () => { - component.showDesc = false; - const cdrSpy = spyOn((component as any).cdr, 'detectChanges'); + it('should close the sort dropdown on document click', () => { + component.showSortDropdown = true; + const detectChangesSpy = spyOn((component as any).cdr, 'detectChanges'); component.onClick(); - expect(component.showDesc).toBeFalse(); - expect(cdrSpy).not.toHaveBeenCalled(); + expect(component.showSortDropdown).toBeFalse(); + expect(detectChangesSpy).toHaveBeenCalled(); }); - it('hasLongWord should detect words over threshold and handle undefined values', () => { + it('should detect long words and handle undefined values', () => { expect(component.hasLongWord('short words only', 20)).toBeFalse(); expect(component.hasLongWord('this_contains_a_reallyreallyreallylongtoken', 10)).toBeTrue(); expect(component.hasLongWord(undefined, 10)).toBeFalse(); diff --git a/src/app/pages/catalogs/catalogs.component.ts b/src/app/pages/catalogs/catalogs.component.ts index e5e6574b..0c3e8fd6 100644 --- a/src/app/pages/catalogs/catalogs.component.ts +++ b/src/app/pages/catalogs/catalogs.component.ts @@ -1,113 +1,299 @@ -import { Component, OnInit, ChangeDetectorRef, HostListener } from '@angular/core'; -import { ApiServiceService } from 'src/app/services/product-service.service'; -import { PaginationService } from 'src/app/services/pagination.service'; -import {faEye} from "@fortawesome/pro-regular-svg-icons"; +import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit, Type } from '@angular/core'; +import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; -import {components} from "../../models/product-catalog"; -type Catalog = components["schemas"]["Catalog"]; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { AccountServiceService } from 'src/app/services/account-service.service'; +import { ApiServiceService } from 'src/app/services/product-service.service'; +import { ThemeService } from 'src/app/services/theme.service'; +import { CatalogsPageConfig } from 'src/app/themes'; import { environment } from 'src/environments/environment'; -import { FormControl } from '@angular/forms'; + +interface ProviderCard { id: string; name: string; description: string; logo: string; } @Component({ selector: 'app-catalogs', templateUrl: './catalogs.component.html', styleUrl: './catalogs.component.css' }) -export class CatalogsComponent implements OnInit{ - catalogs:Catalog[]=[]; - nextCatalogs:Catalog[]=[]; - page:number=0; - CATALOG_LIMIT: number = environment.CATALOG_LIMIT; - loading: boolean = false; - loading_more: boolean = false; - page_check:boolean = true; - filter:any=undefined; +export class CatalogsComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + private catalogs: any[] = []; + private nextCatalogs: any[] = []; + private allProviders: ProviderCard[] = []; + private catalogLogoCache = new Map(); + private ownerLogoCache = new Map(); + private providersRequestSeq = 0; + private filteredPaginationToken: string | null = null; + providers: ProviderCard[] = []; + totalCount = 0; + page = 0; + readonly CATALOG_LIMIT = 12; + loading = false; + loading_more = false; + page_check = true; + filter: string | undefined; searchField = new FormControl(); - protected readonly faEye = faEye; - showDesc:boolean=false; - showingCat:any; - + + viewMode: 'grid' | 'list' = 'grid'; + defaultCatalogLogoUrl = ''; + isDefaultLogo(logo: string | undefined): boolean { return !!logo && logo === this.defaultCatalogLogoUrl; } + sortOption: 'recent' | 'name_asc' | 'name_desc' = 'recent'; + showSortDropdown = false; + sortOptions: { value: 'recent' | 'name_asc' | 'name_desc'; label: string }[] = [ + { value: 'recent', label: 'CATALOGS._sort_recent' }, + { value: 'name_asc', label: 'CATALOGS._sort_name_asc' }, + { value: 'name_desc', label: 'CATALOGS._sort_name_desc' }, + ]; + marketplaceHomeUrl = '/search'; + catalogsHeaderComponent: Type | null = null; + + get sortLabel() { return this.sortOptions.find(o => o.value === this.sortOption)?.label ?? ''; } + constructor( private router: Router, + private accService: AccountServiceService, private api: ApiServiceService, private cdr: ChangeDetectorRef, - private paginationService: PaginationService - ) { - } - - @HostListener('document:click') - onClick() { - if(this.showDesc==true){ - this.showDesc=false; - this.cdr.detectChanges(); - } - } + private themeService: ThemeService + ) { } ngOnInit() { - this.loading=true; - this.getCatalogs(false); - let input = document.querySelector('[type=search]') - if(input!=undefined){ - input.addEventListener('input', e => { - // Easy way to get the value of the element who trigger the current `e` event - console.log(`Input updated`) - if(this.searchField.value==''){ - this.filter=undefined; - this.getCatalogs(false); + this.themeService.currentTheme$ + .pipe(takeUntil(this.destroy$)) + .subscribe(theme => { + this.marketplaceHomeUrl = theme?.links?.marketplaceHomeUrl || '/search'; + this.applyCatalogsTheme(theme?.catalogs); + }); + + this.getProviders(false); + this.searchField.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(v => { + if (!v && this.filter !== undefined) { + this.filter = undefined; + this.getProviders(false); } }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private applyCatalogsTheme(catalogsConfig: CatalogsPageConfig | undefined) { + this.catalogsHeaderComponent = catalogsConfig?.sections?.header || null; + const previousDefaultLogoUrl = this.defaultCatalogLogoUrl; + this.defaultCatalogLogoUrl = catalogsConfig?.cards?.fallbackLogoUrl || ''; + + if (previousDefaultLogoUrl !== this.defaultCatalogLogoUrl) { + this.updateDefaultLogos(previousDefaultLogoUrl, this.defaultCatalogLogoUrl); } + } + + private updateDefaultLogos(previousDefaultLogoUrl: string, nextDefaultLogoUrl: string) { + const replaceDefaultLogo = (card: ProviderCard) => { + if (!card.logo || card.logo === previousDefaultLogoUrl) { + card.logo = nextDefaultLogoUrl; + } + }; + this.allProviders.forEach(replaceDefaultLogo); + this.providers.forEach(replaceDefaultLogo); } - async getCatalogs(next:boolean){ - if(next==false){ - this.loading=true; + async getProviders(next = false) { + if (next && (!this.page_check || this.loading_more)) { + return; } - let options = { - "keywords": this.filter + const requestSeq = ++this.providersRequestSeq; + const catalogsToLoadLogos = next ? [...this.nextCatalogs] : []; + + if (next) { + this.loading_more = true; + } else { + this.loading = true; + this.page = 0; + this.catalogs = []; + this.nextCatalogs = []; + this.allProviders = []; + this.providers = []; + this.page_check = true; + this.clearFilteredPaginationToken(); } + try { - const data = await this.paginationService.getItemsPaginated(this.page,this.CATALOG_LIMIT,next,this.catalogs,this.nextCatalogs, options, - this.api.getCatalogs.bind(this.api)); - this.page_check=data.page_check; - this.catalogs=data.items.filter((catalog:Catalog) => (catalog.id !== environment.DFT_CATALOG_ID) - ); - this.nextCatalogs=data.nextItems; - this.page=data.page; + let loadedCatalogs: any[] = []; + let page = this.page; + let filteredPaginationToken = this.filteredPaginationToken; + + if (next) { + loadedCatalogs = [...this.nextCatalogs]; + const visibleCatalogs = [...this.catalogs, ...loadedCatalogs]; + let bufferedCatalogs: any[] = []; + + if (filteredPaginationToken) { + const prefetched = await this.loadCatalogsPage(page, filteredPaginationToken); + page += this.CATALOG_LIMIT; + filteredPaginationToken = prefetched.filteredPaginationToken ?? null; + if (requestSeq !== this.providersRequestSeq) { + return; + } + bufferedCatalogs = Array.isArray(prefetched.items) ? prefetched.items : []; + } + + this.catalogs = visibleCatalogs; + this.nextCatalogs = bufferedCatalogs; + } else { + page = 0; + filteredPaginationToken = null; + const current = await this.loadCatalogsPage(page, filteredPaginationToken); + page += this.CATALOG_LIMIT; + filteredPaginationToken = current.filteredPaginationToken ?? null; + if (requestSeq !== this.providersRequestSeq) { + return; + } + + loadedCatalogs = Array.isArray(current.items) ? current.items : []; + this.catalogs = loadedCatalogs; + + if (filteredPaginationToken) { + const prefetched = await this.loadCatalogsPage(page, filteredPaginationToken); + page += this.CATALOG_LIMIT; + filteredPaginationToken = prefetched.filteredPaginationToken ?? null; + if (requestSeq !== this.providersRequestSeq) { + return; + } + this.nextCatalogs = Array.isArray(prefetched.items) ? prefetched.items : []; + } + } + + this.page = page; + this.filteredPaginationToken = filteredPaginationToken; + this.page_check = this.nextCatalogs.some(item => item != null); + this.allProviders = this.catalogs.map(c => this.mapCatalog(c)); + this.applyView(); + this.fillOwnerLogos(next ? catalogsToLoadLogos : loadedCatalogs); + } catch (err) { + console.error('Error loading catalogs:', err); } finally { - this.loading=false; - this.loading_more=false; + if (requestSeq === this.providersRequestSeq) { + this.loading = false; + this.loading_more = false; + this.cdr.detectChanges(); + } } } - filterCatalogs(){ - this.filter=this.searchField.value; - this.page=0; - this.getCatalogs(false); + private loadCatalogsPage(page: number, filteredPaginationToken: string | null) { + return this.api.getLaunchedCatalogsPage( + page, + this.filter, + this.CATALOG_LIMIT, + filteredPaginationToken + ); } - goToCatalogSearch(id:any) { - this.router.navigate(['/search/catalogue', id]); + private clearFilteredPaginationToken(): void { + this.filteredPaginationToken = null; + } + + private mapCatalog(c: any): ProviderCard { + const id = c?.id ?? ''; + return { + id, + name: c?.name ?? '', + description: c?.description ?? '', + logo: this.catalogLogoCache.get(id) ?? this.defaultCatalogLogoUrl + }; + } + + private fillOwnerLogos(catalogs: any[]) { + const cardsByOwner = new Map(); + for (const c of catalogs) { + const parties: any[] = c?.relatedParty ?? []; + const owner = parties.find((p: any) => p?.role === environment.SELLER_ROLE) + ?? parties.find((p: any) => p?.id && String(p.id).includes('organization')); + const card = this.allProviders.find(p => p.id === c?.id); + if (!owner?.id || !card || !String(owner.id).includes('organization')) continue; + + const cachedLogo = this.ownerLogoCache.get(owner.id); + if (cachedLogo !== undefined) { + if (cachedLogo) { + card.logo = cachedLogo; + this.catalogLogoCache.set(card.id, cachedLogo); + } + continue; + } + + cardsByOwner.set(owner.id, [...(cardsByOwner.get(owner.id) ?? []), card]); + } + for (const [ownerId, cards] of cardsByOwner) { + this.accService.getOrgInfo(ownerId).then(org => { + const logo = (org?.partyCharacteristic ?? []).find((ch: any) => ch?.name === 'logo')?.value; + this.ownerLogoCache.set(ownerId, logo ?? null); + if (!logo) { + return; + } + for (const card of cards) { + card.logo = logo; + this.catalogLogoCache.set(card.id, logo); + } + this.cdr.detectChanges(); + }).catch(() => { + this.ownerLogoCache.set(ownerId, null); + }); + } } - async next(){ - this.loading_more = true; - await this.getCatalogs(true); + private applyView() { + let list = [...this.allProviders]; + if (this.sortOption === 'name_asc') list.sort((a, b) => a.name.localeCompare(b.name)); + if (this.sortOption === 'name_desc') list.sort((a, b) => b.name.localeCompare(a.name)); + this.totalCount = list.length; + this.providers = list; } - showFullDesc(cat:any){ - this.showDesc=true; - this.showingCat=cat; + filterProviders() { + const value = this.searchField.value?.trim(); + this.filter = value || undefined; + this.getProviders(false); + } + + toggleSortDropdown(e: Event) { + e.stopPropagation(); + this.showSortDropdown = !this.showSortDropdown; + } + + selectSort(v: 'recent' | 'name_asc' | 'name_desc', e: Event) { + e.stopPropagation(); + this.sortOption = v; + this.showSortDropdown = false; + this.getProviders(false); + } + + next(): Promise { + return this.getProviders(true); + } + + goToProvider(id: string) { + this.router.navigate(['/search/catalogue', id]); + } + + @HostListener('document:click') onClick() { + if (this.showSortDropdown) { + this.showSortDropdown = false; + this.cdr.detectChanges(); + } } hasLongWord(str: string | undefined, threshold = 20) { - if(str){ + if (str) { return str.split(/\s+/).some(word => word.length > threshold); } else { return false - } + } } - } diff --git a/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.ts b/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.ts index 0eafc9ae..a25dec23 100644 --- a/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.ts +++ b/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.ts @@ -1,4 +1,5 @@ -import { Component } from "@angular/core"; +import { Component, input } from "@angular/core"; + import { RouterLink } from "@angular/router"; import { TranslateModule } from '@ngx-translate/core'; diff --git a/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html b/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html index c6e16d39..0f684505 100644 --- a/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html +++ b/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html @@ -36,7 +36,7 @@

-

+

{{ getShortDescription(productOffering.description) }}

0 ? images.at(0)?.url : 'https://placehold.co/600x400/svg'; } + + getShortDescription(description: string | undefined): string { + return getShortOfferDescription(description); + } } diff --git a/src/app/pages/dashboard/dashboard.component.html b/src/app/pages/dashboard/dashboard.component.html index 1ddaf85e..1d759bd3 100644 --- a/src/app/pages/dashboard/dashboard.component.html +++ b/src/app/pages/dashboard/dashboard.component.html @@ -51,7 +51,7 @@

+ class="text-md text-wrap line-clamp-2 mb-2 font-medium block items-center px-2.5 py-0.5 rounded-md text-primary-100 dark:text-primary-50 bg-secondary-50 dark:bg-secondary-200 w-fit animate-in"> {{ serv }} } @@ -67,7 +67,7 @@

+ class="text-md text-wrap line-clamp-2 font-medium block items-center px-2.5 py-0.5 rounded-md text-primary-100 dark:text-primary-50 bg-secondary-50 dark:bg-secondary-200 w-fit animate-in"> {{ pub }} } diff --git a/src/app/pages/organization-details/organization-details.component.html b/src/app/pages/organization-details/organization-details.component.html index f196fbb5..2c40ad5c 100644 --- a/src/app/pages/organization-details/organization-details.component.html +++ b/src/app/pages/organization-details/organization-details.component.html @@ -1,116 +1,322 @@ -
- -
- +
+
+
+
+ +
+

{{ orgInfo?.tradingName }}

+ +
+
+
- - -
-
- Organization profile image +
+ + + @if (notFound) { +
+

{{ 'ORGANIZATION._not_found' | translate }}

+

{{ 'ORGANIZATION._not_found_desc' | translate }}

+ {{ 'ORGANIZATION._back_to_providers' | translate }} +
+ } @else { + +
+
+
+
+
+
+

{{ orgInfo?.tradingName }}

+

{{ description || ('ORGANIZATION._no_desc' | translate) }}

+ @if (descTruncated) { + + }
-
-
- -
{{orgInfo?.tradingName}}
- +
+
+ @if (country) { + {{ country }} + + } + + {{ servicesCount }} + {{ 'ORGANIZATION._services' | translate }} + +
+ +
+
+
+
+ +
+
+ @if (hasContact) { +
+

{{ 'ORGANIZATION._support_contact' | translate }}

+
+ + @for (contact of supportContacts; track contact.kind + contact.value + $index) { +
+
+ @if (contact.kind === 'email') { + + } @else if (contact.kind === 'website') { + + } @else if (contact.kind === 'address') { + + } @else { + + }
-
- {{website}} +
+ {{ contact.title || (contact.titleKey | translate) }} + @if (contact.href) { + {{ contact.value }} + } @else { + {{ contact.value }} + } +
+
+ } +
+
+ } + +
+
+

{{ 'ORGANIZATION._key_numbers' | translate }}

+
+
+ {{ servicesCount }} + {{ 'ORGANIZATION._active_services' | translate }} +
+
+
+ + @if (country) { +
+

{{ 'ORGANIZATION._region' | translate }}

+
+ + {{ country }} +
+
+ } +
+
+
+
+ +
+
+ +
+
+
+ +
+ + @if (showCategoryDropdown) { +
+
+ + @for (cat of rootCategories; track cat.id) { + + } +
+ }
- -
-
-

{{ 'ORGANIZATION._details' | translate }}

- -

{{ 'PROFILE._contact_info' | translate }}

-
- - - - - - - - - - @for(medium of orgInfo?.contactMedium; track medium) { - - - - - - } @empty { -
- -
- } - -
- {{ 'PROFILE._medium_type' | translate }} - - {{ 'PROFILE._contact_title' | translate }} -
- {{medium.mediumType}} - - {{medium.characteristic?.contactType || medium.mediumType}} -
+ +
+ @if (activeCategoryName) { + + } + +
+
+ {{ serviceCount }} {{ 'ORGANIZATION._results' | translate }} +
+ + @if (servicesLoading) { +
+ +
+ } @else if (!services.length) { +
{{ 'ORGANIZATION._no_services' | translate }}
+ } @else { +
+ @for (off of services; track off.id) { + + } +
+ @if (!loading_more) { + @if (page_check) { +
+ +
+ } + } @else { +
+ + Loading... +
+ } + } +
+
+ +
+ + @if (showCategoriesOverlay) { +
+
+ +
+
+

{{ 'ORGANIZATION._categories' | translate }}

+ +
+
+ @if (activeCategoryName) { + + } @else { +
{{ 'ORGANIZATION._no_options' | translate }}
+ } +
+
+ + +
+
+ } + + @if (showDescModal) { +
+
+
+

{{ orgInfo?.tradingName }}

+
+

{{ description }}

+
+ } + }
diff --git a/src/app/pages/organization-details/organization-details.component.spec.ts b/src/app/pages/organization-details/organization-details.component.spec.ts index 1b757546..3cfa23d2 100644 --- a/src/app/pages/organization-details/organization-details.component.spec.ts +++ b/src/app/pages/organization-details/organization-details.component.spec.ts @@ -28,6 +28,7 @@ describe('OrganizationDetailsComponent', () => { }); it('should render contact medium title from contactType', () => { + component.notFound = false; component.orgInfo = { contactMedium: [{ mediumType: 'Email', @@ -44,4 +45,52 @@ describe('OrganizationDetailsComponent', () => { expect(text).toContain('Support'); expect(text).toContain('support@example.com'); }); + + it('should group contact mediums by medium type instead of payload position', () => { + component.orgInfo = { + contactMedium: [ + { + mediumType: 'PostalAddress', + characteristic: { + contactType: 'Billing address', + street1: 'Main street 1', + postCode: '28001', + city: 'Madrid', + country: 'Spain' + } + }, + { + mediumType: 'TelephoneNumber', + characteristic: { + contactType: 'Sales phone', + phoneNumber: '+34 900 000 000' + } + }, + { + mediumType: 'Email', + characteristic: { + contactType: 'Support email', + emailAddress: 'support@example.com' + } + }, + { + mediumType: 'Email', + characteristic: { + contactType: 'Sales email', + emailAddress: 'sales@example.com' + } + } + ] + }; + + const contacts = component.supportContacts; + + expect(contacts.map(contact => contact.kind)).toEqual(['email', 'email', 'phone', 'address']); + expect(contacts.map(contact => contact.value)).toEqual([ + 'support@example.com', + 'sales@example.com', + '+34 900 000 000', + 'Main street 1, 28001 Madrid, Spain' + ]); + }); }); diff --git a/src/app/pages/organization-details/organization-details.component.ts b/src/app/pages/organization-details/organization-details.component.ts index 442f53c3..0942056e 100644 --- a/src/app/pages/organization-details/organization-details.component.ts +++ b/src/app/pages/organization-details/organization-details.component.ts @@ -1,24 +1,116 @@ -import { Component, OnInit, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef, ElementRef, HostListener, ViewChild, Renderer2 } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { AccountServiceService } from 'src/app/services/account-service.service'; +import { ApiServiceService } from 'src/app/services/product-service.service'; import {EventMessageService} from "../../services/event-message.service"; import { LocalStorageService } from 'src/app/services/local-storage.service'; import { environment } from 'src/environments/environment'; import { Location } from '@angular/common'; +import { availableFilters, searchCategoriesConfig } from 'src/app/data/availableFilters'; +import { Category } from 'src/app/models/interfaces'; +import { faEarthAmericas } from '@fortawesome/free-solid-svg-icons'; +import { iconForCategory } from 'src/app/data/categoryIcons'; +import { PaginationService } from 'src/app/services/pagination.service'; + +type ToolbarFilter = { + key: string; + label: string; + source: 'configured' | 'categoryRoot'; + rootName?: string; + options: Category[]; + selectedIds: string[]; + open: boolean; + dropTop?: number | null; +}; + +type OrganizationDetailsMode = 'catalog' | 'organization'; +type ContactMediumKind = 'email' | 'phone' | 'address' | 'website'; +const CONTACT_MEDIUM_KIND_ORDER: ContactMediumKind[] = ['email', 'phone', 'address']; + +interface DisplayContactMedium { + kind: ContactMediumKind; + title: string; + titleKey: string; + value: string; + href?: string; +} @Component({ selector: 'app-organization-details', templateUrl: './organization-details.component.html', styleUrl: './organization-details.component.css' }) -export class OrganizationDetailsComponent implements OnInit { +export class OrganizationDetailsComponent implements OnInit, AfterViewInit, OnDestroy { id:any; + mode: OrganizationDetailsMode = 'organization'; + notFound = false; orgInfo:any; - logo:any='https://placehold.co/600x400/svg'; + logo:any=undefined; + readonly DEFAULT_LOGO = 'assets/images/Dome-Marketplace.svg'; + get isDefaultLogo(): boolean { return this.logo === this.DEFAULT_LOGO; } + get contactMediums(): DisplayContactMedium[] { return this.buildContactMediums(this.orgInfo?.contactMedium); } + get supportContacts(): DisplayContactMedium[] { + const contacts = this.contactMediums; + return this.website + ? [...contacts, { kind: 'website', title: '', titleKey: 'ORGANIZATION._site', value: this.website, href: this.website }] + : contacts; + } + get hasContact(): boolean { return this.supportContacts.length > 0; } + get contactCount(): number { return this.supportContacts.length; } description:any=undefined; website:any; + country:any; + email:string=''; + phone:string=''; + address:string=''; + servicesCount:number=0; + protected readonly faEarthAmericas = faEarthAmericas; + protected readonly iconForCategory = iconForCategory; + + serviceSearch = new FormControl(); + serviceKeywords: string | undefined; + toolbarFilters: ToolbarFilter[] = []; + + private allServices: any[] = []; + private nextServices: any[] = []; + services: any[] = []; + serviceCount = 0; + servicesLoading = false; + loading_more = false; + page_check = false; + private servicesPage = 0; + rootCategories: Category[] = []; + activeCategoryName: string | null = null; + activeCategoryId: string | null = null; + showCategoryDropdown = false; + showSummaryBar = false; + @ViewChild('providerHero') providerHero?: ElementRef; + @ViewChild('summaryBar') summaryBar?: ElementRef; + @ViewChild('servicesSection') servicesSection?: ElementRef; + @ViewChild('descPara') descPara?: ElementRef; + descTruncated = false; + showDescModal = false; + showCategoriesOverlay = false; + private overlaySnapshot: Category[] = []; + get selectedCategoryCount(): number { + const raw = this.localStorage.getObject('selected_categories'); + return Array.isArray(raw) ? raw.length : 0; + } + private readonly HEADER_HEIGHT = 88; + serviceSort: 'relevance' | 'name' | 'date' = 'relevance'; + showServiceSort = false; + serviceSortOptions: { value: 'relevance' | 'name' | 'date'; label: string }[] = [ + { value: 'relevance', label: 'ORGANIZATION._sort_relevance' }, + { value: 'name', label: 'ORGANIZATION._sort_name' }, + { value: 'date', label: 'ORGANIZATION._sort_date' }, + ]; + get serviceSortLabel() { return this.serviceSortOptions.find(o => o.value === this.serviceSort)?.label ?? ''; } + private destroy$ = new Subject(); constructor( private cdr: ChangeDetectorRef, @@ -28,44 +120,560 @@ export class OrganizationDetailsComponent implements OnInit { private localStorage: LocalStorageService, private eventMessage: EventMessageService, private accService: AccountServiceService, - private location: Location + private location: Location, + private api: ApiServiceService, + private paginationService: PaginationService, + private renderer: Renderer2 ) { + this.eventMessage.messages$.pipe(takeUntil(this.destroy$)).subscribe(ev => { + if (ev.type === 'FiltersCommitted') { + this.reloadServices(); + this.cdr.detectChanges(); + } + }); } ngOnInit(): void { - this.id = this.route.snapshot.paramMap.get('id'); - console.log('--- Details ID:') - console.log(this.id) - this.accService.getOrgInfo(this.id).then(org => { + this.serviceSearch.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(v => { if (!v) { this.serviceKeywords = undefined; this.reloadServices(); } }); + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(params => { + this.id = params.get('id'); + this.mode = this.route.snapshot.data['mode'] === 'catalog' ? 'catalog' : 'organization'; + this.resetPageState(); + this.loadPage(); + }); + } + + private resetPageState(): void { + this.notFound = false; + this.orgInfo = undefined; + this.logo = undefined; + this.description = undefined; + this.website = undefined; + this.country = undefined; + this.email = ''; + this.phone = ''; + this.address = ''; + this.servicesCount = 0; + this.allServices = []; + this.nextServices = []; + this.services = []; + this.serviceCount = 0; + this.loading_more = false; + this.page_check = false; + this.servicesPage = 0; + this.activeCategoryName = null; + this.activeCategoryId = null; + this.showCategoryDropdown = false; + this.showSummaryBar = false; + this.descTruncated = false; + this.showDescModal = false; + this.showCategoriesOverlay = false; + this.serviceKeywords = undefined; + this.serviceSearch.reset(undefined, { emitEvent: false }); + this.localStorage.setObject('selected_categories', []); + this.initToolbarFilters(); + } + + private loadPage(): void { + if (this.mode === 'catalog') { + this.loadCatalogPage(); + } else { + this.loadOrganizationPage(); + } + } + + private async loadCatalogPage(): Promise { + const catalogId = this.id; + const requestKey = this.currentRequestKey(); + let owner: any; + try { + const catalog = await this.api.getCatalog(catalogId); + if (this.currentRequestKey() !== requestKey) { return; } + const parties: any[] = catalog?.relatedParty ?? []; + owner = parties.find((p: any) => p?.role === environment.SELLER_ROLE) + ?? parties.find((p: any) => p?.id && String(p.id).includes('organization')); + } catch (err) { + console.warn('Catalog unavailable:', err); + } + if (this.currentRequestKey() !== requestKey) { return; } + if (!owner?.id) { + this.notFound = true; + return; + } + await this.loadOwnerInfo(owner.id, requestKey); + if (this.currentRequestKey() !== requestKey) { return; } + await this.loadServicesPage(false, requestKey); + if (this.currentRequestKey() !== requestKey) { return; } + this.loadRootCategories(); + } + + private async loadOrganizationPage(): Promise { + const orgId = this.id; + const requestKey = this.currentRequestKey(); + + if (!orgId) { + this.notFound = true; + return; + } + + await this.loadOwnerInfo(orgId, requestKey); + if (this.currentRequestKey() !== requestKey || this.notFound) { return; } + await this.loadServicesPage(false, requestKey); + if (this.currentRequestKey() !== requestKey) { return; } + this.loadRootCategories(); + } + + private currentRequestKey(): string { + return `${this.mode}:${this.id ?? ''}`; + } + + private loadOwnerInfo(ownerId: string, requestKey: string): Promise { + return this.accService.getOrgInfo(ownerId).then(org => { + if (this.currentRequestKey() !== requestKey) { return; } this.orgInfo=org; console.log(this.orgInfo) - for(let i=0; i { + const el = this.descPara?.nativeElement; + this.descTruncated = !!el && el.scrollHeight > el.clientHeight + 1; + this.cdr.detectChanges(); + }); + }).catch(err => { + if (this.currentRequestKey() !== requestKey) { return; } + console.warn('Organization unavailable:', err); + this.notFound = true; }) } + private buildContactMediums(contactMedium: any[] | undefined): DisplayContactMedium[] { + if (!Array.isArray(contactMedium)) return []; + + const grouped = new Map( + CONTACT_MEDIUM_KIND_ORDER.map(kind => [kind, []]) + ); + + for (const medium of contactMedium) { + const displayMedium = this.toDisplayContactMedium(medium); + if (!displayMedium) { continue; } + grouped.get(displayMedium.kind)?.push(displayMedium); + } + + return CONTACT_MEDIUM_KIND_ORDER.flatMap(kind => grouped.get(kind) ?? []); + } + + private toDisplayContactMedium(medium: any): DisplayContactMedium | null { + const characteristic = medium?.characteristic ?? {}; + const contactType = characteristic?.contactType; + const title = typeof contactType === 'string' ? contactType.trim() : ''; + + if (medium?.mediumType === 'Email') { + const value = characteristic?.emailAddress ?? ''; + return value ? { kind: 'email', title, titleKey: 'ORGANIZATION._contact_mail', value, href: `mailto:${value}` } : null; + } + + if (medium?.mediumType === 'TelephoneNumber' || medium?.mediumType === 'Phone') { + const value = characteristic?.phoneNumber ?? ''; + return value ? { kind: 'phone', title, titleKey: 'ORGANIZATION._phone', value } : null; + } + + if (medium?.mediumType === 'PostalAddress') { + const value = [ + characteristic?.street1, + [characteristic?.postCode, characteristic?.city].filter(Boolean).join(' '), + characteristic?.stateOrProvince, + characteristic?.country + ].filter(Boolean).join(', '); + return value ? { kind: 'address', title, titleKey: 'ORGANIZATION._address', value } : null; + } + + return null; + } + + ngAfterViewInit(): void { + const el = this.summaryBar?.nativeElement; + const nav = document.querySelector('bae-header nav'); + if (el && nav) { + this.renderer.appendChild(nav, el); + } + } + + ngOnDestroy(): void { + const el = this.summaryBar?.nativeElement; + if (el?.parentNode) { + el.parentNode.removeChild(el); + } + const stored = this.localStorage.getObject('selected_categories') as Category[] || []; + for (const f of (Array.isArray(stored) ? stored : [])) { + this.localStorage.removeCategoryFilter(f); + this.eventMessage.emitRemovedFilter(f); + } + this.destroy$.next(); + this.destroy$.complete(); + } + back(){ this.location.back(); } + openDescModal(event: Event){ + event.stopPropagation(); + this.showDescModal = true; + } + + openCategoriesOverlay(event: Event): void { + event.stopPropagation(); + const raw = this.localStorage.getObject('selected_categories'); + this.overlaySnapshot = Array.isArray(raw) ? [...raw] : []; + this.showCategoriesOverlay = true; + } + + clearOverlayCategories(): void { + const raw = this.localStorage.getObject('selected_categories'); + for (const f of (Array.isArray(raw) ? raw : []) as Category[]) { + this.localStorage.removeCategoryFilter(f); + this.eventMessage.emitRemovedFilter(f); + } + this.eventMessage.emitFiltersCommitted(); + } + + applyCategoriesOverlay(): void { + this.showCategoriesOverlay = false; + } + + cancelCategoriesOverlay(): void { + const raw = this.localStorage.getObject('selected_categories'); + const current: Category[] = Array.isArray(raw) ? raw : []; + const snapIds = new Set(this.overlaySnapshot.map(c => c?.id).filter(Boolean)); + const currIds = new Set(current.map(c => c?.id).filter(Boolean)); + for (const f of current) { + if (f?.id && !snapIds.has(f.id)) { this.localStorage.removeCategoryFilter(f); this.eventMessage.emitRemovedFilter(f); } + } + for (const f of this.overlaySnapshot) { + if (f?.id && !currIds.has(f.id)) { this.localStorage.addCategoryFilter(f); this.eventMessage.emitAddedFilter(f); } + } + this.showCategoriesOverlay = false; + this.eventMessage.emitFiltersCommitted(); + } + + closeDescModal(){ + this.showDescModal = false; + } + + browseServices(){ + const target = this.servicesSection?.nativeElement; + if (!target) { return; } + const barHeight = this.summaryBar?.nativeElement?.offsetHeight ?? 0; + const offset = this.HEADER_HEIGHT + barHeight; + const y = target.getBoundingClientRect().top + window.scrollY - offset; + window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' }); + } + + private initToolbarFilters(): void { + this.toolbarFilters = availableFilters.map(filter => ({ + key: filter.name, + label: filter.label ?? filter.name, + source: filter.source ?? 'configured', + rootName: filter.rootName, + options: (filter.source ?? 'configured') === 'configured' + ? (filter.children ?? []).map(child => ({ id: `${filter.name}::${child.name}`, name: child.label ?? child.name })) + : [], + selectedIds: [], + open: false, + })); + } + + toggleToolbarFilterDropdown(filter: ToolbarFilter, event: Event): void { + event.stopPropagation(); + const willOpen = !filter.open; + for (const f of this.toolbarFilters) f.open = false; + filter.dropTop = window.innerWidth < 640 + ? Math.round((event.currentTarget as HTMLElement).getBoundingClientRect().bottom) + : null; + filter.open = willOpen; + } + + filterServices(): void { + this.serviceKeywords = this.serviceSearch.value || undefined; + this.reloadServices(); + } + + clearServiceFilters(): void { + for (const f of this.toolbarFilters) { f.selectedIds = []; f.open = false; } + this.serviceKeywords = undefined; + this.serviceSearch.reset(); + this.reloadServices(); + } + + private async loadRootCategories(): Promise { + try { + const roots = await this.api.getDefaultCategories(); + const list = Array.isArray(roots) ? roots : []; + let primaryCategories: Category[] = []; + if (searchCategoriesConfig.primaryCategoriesMode === 'catalogFirstLevel') { + primaryCategories = list; + } else { + const primaryRoot = list.find((c: any) => c?.name === searchCategoriesConfig.primaryRootName); + if (primaryRoot?.id) { + const children = await this.api.getCategoriesByParentId(primaryRoot.id).catch(() => []); + primaryCategories = Array.isArray(children) ? children : []; + } + } + await Promise.all( + this.toolbarFilters + .filter(f => f.source === 'categoryRoot') + .map(async f => { + const root = list.find((c: any) => c?.name === f.rootName); + const children = root?.id ? await this.api.getCategoriesByParentId(root.id).catch(() => []) : []; + f.options = Array.isArray(children) ? children : []; + }) + ); + this.rootCategories = primaryCategories; + } catch { + this.rootCategories = []; + } + } + + isToolbarOptionSelected(filter: ToolbarFilter, option: Category): boolean { + return !!option?.id && filter.selectedIds.includes(option.id); + } + + toggleToolbarOption(filter: ToolbarFilter, option: Category, event: Event): void { + event.stopPropagation(); + if (!option?.id) return; + const idx = filter.selectedIds.indexOf(option.id); + if (idx > -1) filter.selectedIds.splice(idx, 1); + else filter.selectedIds.push(option.id); + this.reloadServices(); + } + + clearToolbarFilterSelection(filter: ToolbarFilter, event: Event): void { + event.stopPropagation(); + filter.selectedIds = []; + this.reloadServices(); + } + + toggleCategoryDropdown(event: Event): void { + event.stopPropagation(); + this.showCategoryDropdown = !this.showCategoryDropdown; + for (const f of this.toolbarFilters) f.open = false; + this.showServiceSort = false; + } + + async selectCategory(cat: Category | null): Promise { + const raw = this.localStorage.getObject('selected_categories'); + const storedFilters: Category[] = Array.isArray(raw) ? raw : []; + for (const f of storedFilters) { + this.localStorage.removeCategoryFilter(f); + this.eventMessage.emitRemovedFilter(f); + } + if (cat) { + this.activeCategoryName = cat.name ?? null; + this.activeCategoryId = cat.id ?? null; + if (cat.id) { + const children = await this.api.getCategoriesByParentId(cat.id).catch(() => []); + const childList: Category[] = Array.isArray(children) ? children : []; + for (const child of childList) { + if (!child?.id) continue; + this.localStorage.addCategoryFilter(child); + this.eventMessage.emitAddedFilter(child); + } + } + } else { + this.activeCategoryName = null; + this.activeCategoryId = null; + } + this.showCategoryDropdown = false; + this.eventMessage.emitFiltersCommitted(); + } + + get categoryFilterCatalogId(): string | undefined { + return this.mode === 'catalog' ? this.id : undefined; + } + + async next(): Promise { + await this.loadServicesPage(true, this.currentRequestKey()); + } + + private reloadServices(): void { + if (!this.id) { return; } + this.loadServicesPage(false, this.currentRequestKey()); + } + + private async loadServicesPage(next: boolean, requestKey: string): Promise { + if (next && (!this.page_check || this.loading_more)) { return; } + if (next) { + this.loading_more = true; + } else { + this.servicesLoading = true; + this.loading_more = false; + this.page_check = false; + this.allServices = []; + this.nextServices = []; + this.servicesPage = 0; + this.applyServiceView(); + } + + try { + const data = await this.paginationService.getItemsPaginated( + this.servicesPage, + environment.PRODUCT_LIMIT, + next, + this.allServices, + this.nextServices, + this.getServicePaginationOptions(), + this.getServicePaginationHandler() + ); + if (this.currentRequestKey() !== requestKey) { return; } + + this.allServices = await this.api.getProductsDetails(data.items ?? []); + this.nextServices = await this.api.getProductsDetails(data.nextItems ?? []); + this.servicesPage = data.page; + this.page_check = data.page_check; + } catch (err) { + if (this.currentRequestKey() !== requestKey) { return; } + console.warn('Offers unavailable:', err); + if (!next) { + this.allServices = []; + this.nextServices = []; + this.page_check = false; + } + } finally { + if (this.currentRequestKey() !== requestKey) { return; } + this.applyServiceView(); + this.servicesLoading = false; + this.loading_more = false; + this.cdr.detectChanges(); + } + } + + private getSelectedServiceFilters(): Category[] { + const selected: Category[] = []; + const byId = new Set(); + const stored = this.localStorage.getObject('selected_categories') as Category[] || []; + + const add = (cat: Category | undefined) => { + if (!cat?.id || byId.has(cat.id)) { return; } + byId.add(cat.id); + selected.push(cat); + }; + + for (const cat of (Array.isArray(stored) ? stored : [])) { + add(cat); + } + + for (const filter of this.toolbarFilters) { + for (const id of filter.selectedIds) { + const option = filter.options.find(opt => opt.id === id); + add(option); + } + } + + return selected; + } + + private getServicePaginationOptions(): any { + if (this.mode === 'catalog') { + return { + keywords: this.serviceKeywords, + filters: this.getSelectedServiceFilters(), + catalogId: this.id + }; + } + + return { + keywords: this.serviceKeywords, + filters: this.getSelectedServiceFilters(), + partyId: this.id + }; + } + + private getServicePaginationHandler(): (...params: any[]) => Promise { + return this.mode === 'catalog' + ? this.paginationService.getProductsByCatalog.bind(this.paginationService) + : this.api.getLaunchedProductOffersByOwnerAndCategory.bind(this.api); + } + + private applyServiceView(): void { + let list = [...this.allServices]; + const q = (this.serviceKeywords ?? '').toLowerCase(); + if (q) { + list = list.filter(o => (o?.name ?? '').toLowerCase().includes(q) || (o?.description ?? '').toLowerCase().includes(q)); + } + if (this.serviceSort === 'name') { + list.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? '')); + } else if (this.serviceSort === 'date') { + list.sort((a, b) => this.serviceDate(b) - this.serviceDate(a)); + } + this.services = list; + this.serviceCount = list.length; + this.servicesCount = this.allServices.length; + } + + private serviceDate(o: any): number { + const d = o?.validFor?.startDateTime || o?.lastUpdate; + return d ? new Date(d).getTime() : 0; + } + + toggleServiceSort(event: Event): void { + event.stopPropagation(); + this.showServiceSort = !this.showServiceSort; + } + + selectServiceSort(v: 'relevance' | 'name' | 'date', event: Event): void { + event.stopPropagation(); + this.serviceSort = v; + this.showServiceSort = false; + this.applyServiceView(); + } + + @HostListener('window:scroll') onScroll(): void { + const bottom = this.providerHero?.nativeElement.getBoundingClientRect().bottom; + const next = bottom !== undefined && bottom <= this.HEADER_HEIGHT; + if (next !== this.showSummaryBar) { + this.showSummaryBar = next; + this.cdr.detectChanges(); + } + } + + @HostListener('document:click') onDocClick(): void { + let changed = false; + if (this.toolbarFilters.some(f => f.open)) { for (const f of this.toolbarFilters) f.open = false; changed = true; } + if (this.showServiceSort) { this.showServiceSort = false; changed = true; } + if (this.showCategoryDropdown) { this.showCategoryDropdown = false; changed = true; } + if (changed) this.cdr.detectChanges(); + } + hasLongWord(str: string | undefined, threshold = 20) { if(str){ return str.split(/\s+/).some(word => word.length > threshold); } else { return false - } + } } } diff --git a/src/app/pages/product-details/product-details.component.css b/src/app/pages/product-details/product-details.component.css index 539a53ad..dd62930a 100644 --- a/src/app/pages/product-details/product-details.component.css +++ b/src/app/pages/product-details/product-details.component.css @@ -4,4 +4,134 @@ background-size: cover; background-repeat: no-repeat; background-position: center; - } \ No newline at end of file + } + +.kf-card { min-height: 128px; } +.kf-watermark { + position: absolute; + top: -120px; + right: -60px; + width: 351.64px; + height: 312.11px; + border-radius: 9999px; + background-color: rgb(var(--theme-secondary-50) / 0.7); + z-index: 0; + pointer-events: none; +} + +.faq-card { + background: rgb(249 250 251 / 1); + border-radius: 16px; + padding: 24px; + transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease; + border: 1px solid rgb(var(--theme-secondary-50) / 1); +} + +.faq-card--open { + border: 1px solid rgb(229 231 235 / 1); + box-shadow: 0 0 16px rgba(0, 0, 0, 0.08); +} + +.faq-question { + width: 100%; + display: flex; + align-items: center; + gap: 24px; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; +} + +.faq-question__title { + flex: 1; + color: rgb(var(--theme-secondary-500) / 1); + font-weight: 700; + font-size: 18px; + line-height: 24px; +} + +.faq-icon { + width: 40px; + height: 40px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: rgb(var(--theme-secondary-50) / 1); + color: rgb(var(--theme-primary-100) / 1); + flex-shrink: 0; + transition: background-color 0.25s ease, color 0.25s ease, transform 0.25s ease; +} + +.faq-icon svg { + transition: transform 0.25s ease; +} + +.faq-icon--open { + background: rgb(var(--theme-primary-100) / 1); + color: white; +} + +.faq-answer { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 0.35s ease, opacity 0.25s ease, margin-top 0.35s ease; + margin-top: 0; +} + +.faq-answer--open { + grid-template-rows: 1fr; + opacity: 1; + margin-top: 16px; +} + +.faq-answer__inner { + overflow: hidden; +} + +.faq-divider { + height: 1px; + background: rgb(229 231 235 / 1); + margin-bottom: 16px; + transform: scaleX(0.96); + transform-origin: left center; + transition: transform 0.35s ease, opacity 0.25s ease; + opacity: 0; +} + +.faq-answer--open .faq-divider { + transform: scaleX(1); + opacity: 1; +} + +.faq-answer__content { + color: rgb(75 85 99 / 1); + font-size: 18px; + line-height: 30px; + white-space: pre-line; + transform: translateY(-8px); + opacity: 0; + transition: transform 0.35s ease, opacity 0.3s ease; +} + +.faq-answer--open .faq-answer__content { + transform: translateY(0); + opacity: 1; +} + +@media (min-width: 1024px) { + .faq-question__title { + font-size: 18px; + line-height: 24px; + } + + .faq-answer__content { + font-size: 18px; + line-height: 30px; + } +} diff --git a/src/app/pages/product-details/product-details.component.html b/src/app/pages/product-details/product-details.component.html index 63f0e9a5..595825fe 100644 --- a/src/app/pages/product-details/product-details.component.html +++ b/src/app/pages/product-details/product-details.component.html @@ -2,12 +2,12 @@
-
@@ -16,18 +16,18 @@ 'break-all': hasLongWord(productOff?.name, 20), 'break-words': !hasLongWord(productOff?.name, 20) }" - class="text-[32px] font-bold leading-tight text-[#14274A] mb-4">{{productOff?.name}}

+ class="text-[32px] font-bold leading-tight text-secondary-100 mb-4">{{productOff?.name}}
+ }" class="text-[16px] leading-[26px] text-secondary-500" [data]="productOff?.description">
@if (showDescriptionReadMore) { - }
@@ -37,8 +37,8 @@ @if(orgInfo.contactMedium){ @for(contact of orgInfo.contactMedium; track contact){ @if(contact.mediumType === 'Email'){ -
- +
+ {{contact.characteristic?.emailAddress}} @@ -46,7 +46,7 @@ } } } -
+
@@ -57,51 +57,54 @@
@for (cat of categories; track cat.id) { - {{ cat.name }} + {{ cat.name }} }
- -