diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 2a66560d..35240d88 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -138,6 +138,11 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { roles: ['admin'] } }, + { + path: 'browse', + loadComponent: () => import('./pages/browse/browse.component').then(c => c.BrowseComponent), + }, + { path: 'landing-page', children: [{ diff --git a/src/app/data/availableFilters.ts b/src/app/data/availableFilters.ts index 2ab7f4ec..852f932c 100644 --- a/src/app/data/availableFilters.ts +++ b/src/app/data/availableFilters.ts @@ -1,17 +1,29 @@ export type Filter = { name: string + label?: string + clientSide?: boolean children?: Filter[] } -const availableFilters: Filter[] = [ +export const availableFilters: Filter[] = [ { name: 'compliance_profile', + label: 'Compliance level', children: [ { name: 'Baseline' }, { name: 'Professional' }, { name: 'Professional+' }, ], }, + { + name: 'procurement_type', + label: 'Procurement type', + clientSide: true, + children: [ + { name: 'Ready to Buy' }, + { name: 'Request Quote' }, + ], + }, ] export default availableFilters diff --git a/src/app/data/categoryIcons.ts b/src/app/data/categoryIcons.ts new file mode 100644 index 00000000..36d62725 --- /dev/null +++ b/src/app/data/categoryIcons.ts @@ -0,0 +1,29 @@ +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { + faBolt, + faBrain, + faCode, + faLayerGroup, + faPlug, + faServer, + faShieldHalved, + faUserTie +} from '@fortawesome/pro-solid-svg-icons'; + +export const DEFAULT_CATEGORY_ICON: IconDefinition = faLayerGroup; + +export const CATEGORY_ICONS: Record = { + Security: faShieldHalved, + Infrastructure: faServer, + Productivity: faBolt, + DevOps: faCode, + Professional: faUserTie, + Specific: faLayerGroup, + 'Data and AI': faBrain, + Integration: faPlug, +}; + +export function iconForCategory(name: string | null | undefined): IconDefinition { + if (!name) return DEFAULT_CATEGORY_ICON; + return CATEGORY_ICONS[name] ?? DEFAULT_CATEGORY_ICON; +} diff --git a/src/app/pages/browse/browse.component.css b/src/app/pages/browse/browse.component.css new file mode 100644 index 00000000..9809f3b1 --- /dev/null +++ b/src/app/pages/browse/browse.component.css @@ -0,0 +1,194 @@ +.browse-wrap { + position: relative; + width: 100%; + isolation: isolate; +} + +.browse-bg { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: #f3f6ff; +} + +.browse-content { + position: relative; + z-index: 1; +} + +.browse-ellipse { + position: absolute; + width: 720px; + height: 720px; + border-radius: 9999px; + transform: rotate(180deg); + opacity: 1; + background: radial-gradient(circle at 35% 35%, + rgba(182, 202, 236, 0.95) 0%, + rgba(182, 202, 236, 0.65) 38%, + rgba(182, 202, 236, 0) 72%); + filter: blur(10px); +} + +.browse-e1 { + left: -220px; + top: -190px; +} + +.browse-e2 { + right: -260px; + top: 60px; +} + +.browse-e3 { + left: 50%; + top: -240px; + transform: translateX(-50%) rotate(180deg); +} + +.browse-e4 { + left: 140px; + bottom: -360px; +} + +.browse-blur { + position: absolute; + inset: 0; + background: linear-gradient(226.59deg, + rgba(255, 242, 242, 0.18) 25.7%, + rgba(255, 250, 250, 0.28) 94.73%); + backdrop-filter: blur(90px); + -webkit-backdrop-filter: blur(90px); + transform: rotate(180deg); +} + +@media (max-width: 1024px) { + .browse-ellipse { + width: 620px; + height: 620px; + filter: blur(9px); + } + + .browse-e2 { + right: -300px; + } +} + +.popular-card { + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.popular-card:hover { + border-color: #3d71cc; + box-shadow: 0 6px 20px rgba(45, 88, 167, 0.12); +} + +.popular-grid[data-direction="init"], +.popular-grid[data-direction="next"] { + animation: popularSlideInRight 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.popular-grid[data-direction="prev"] { + animation: popularSlideInLeft 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes popularSlideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes popularSlideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +.popular-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 9999px; + background: #ffffff; + color: #0b1220; + box-shadow: 0 4px 16px rgba(45, 88, 167, 0.2); + transition: color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; +} + +.popular-arrow:hover:not(:disabled) { + color: #1f4fbf; + box-shadow: 0 4px 18px rgba(45, 88, 167, 0.32); +} + +.popular-arrow:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.popular-arrow-left { + left: 0; +} + +.popular-arrow-right { + right: 0; +} + +.popular-dot { + width: 10px; + height: 10px; + border-radius: 9999px; + background: #d8dade; + transition: background 0.2s ease, width 0.2s ease; +} + +.popular-dot:hover { + background: #b8babf; +} + +.popular-dot-active { + background: #3d71cc; + width: 28px; +} + +@media (max-width: 640px) { + .popular-arrow { + display: none; + } +} + +@media (max-width: 640px) { + .browse-ellipse { + width: 520px; + height: 520px; + filter: blur(8px); + } + + .browse-e1 { + left: -280px; + top: -230px; + } + + .browse-e2 { + right: -320px; + top: 10px; + } + + .browse-e4 { + left: 40px; + bottom: -380px; + } +} diff --git a/src/app/pages/browse/browse.component.html b/src/app/pages/browse/browse.component.html new file mode 100644 index 00000000..5036a39f --- /dev/null +++ b/src/app/pages/browse/browse.component.html @@ -0,0 +1,172 @@ +
+ + +
+ +
+

+ {{ 'BROWSE.heroTitle1' | translate }} + {{ 'BROWSE.heroAccent1' | translate }} + {{ 'BROWSE.heroTitle2' | translate }} + {{ 'BROWSE.heroAccent2' | translate }} +

+ +
+
+ + +
+
+
+ +
+
+

+ {{ 'BROWSE.categoriesTitle' | translate }} +

+

+ {{ 'BROWSE.categoriesSubtitle' | translate }} +

+
+ + @if (loading) { +
+ + + + +
+ } @else { +
+ @for (category of categories; track category.id) { + + } + + +
+ } +
+ + @if (popularLoading || popularOffers.length > 0) { +
+
+

+ {{ 'BROWSE.popularTitle' | translate }} +

+

+ {{ 'BROWSE.popularSubtitle' | translate }} +

+
+ + @if (popularLoading) { +
+ + + + +
+ } @else { +
+ @if (popularPageCount > 1) { + + } + +
+ @for (page of [popularPage]; track page) { + + } +
+ + @if (popularPageCount > 1) { + + } +
+ + @if (popularPageCount > 1) { +
+ @for (_ of popularPages; track $index) { + + } +
+ } + } +
+ } + +
+
diff --git a/src/app/pages/browse/browse.component.ts b/src/app/pages/browse/browse.component.ts new file mode 100644 index 00000000..eabdb6d2 --- /dev/null +++ b/src/app/pages/browse/browse.component.ts @@ -0,0 +1,197 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faArrowRight, faChevronLeft, faChevronRight } from '@fortawesome/pro-solid-svg-icons'; +import { ApiServiceService } from 'src/app/services/product-service.service'; +import { LocalStorageService } from 'src/app/services/local-storage.service'; +import { Category } from 'src/app/models/interfaces'; +import { iconForCategory } from 'src/app/data/categoryIcons'; + +interface PopularOffer { + id: string; + name: string; + description: string; + imageUrl: string; +} + +@Component({ + selector: 'app-browse', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule, FontAwesomeModule], + templateUrl: './browse.component.html', + styleUrl: './browse.component.css', +}) +export class BrowseComponent implements OnInit { + categories: Category[] = []; + searchQuery = ''; + loading = true; + faArrowRight = faArrowRight; + faChevronLeft = faChevronLeft; + faChevronRight = faChevronRight; + iconForCategory = iconForCategory; + + private readonly POPULAR_PER_PAGE = 3; + private readonly POPULAR_TOTAL = 9; + private popularCandidates: PopularOffer[] = []; + popularLoading = true; + popularPage = 0; + popularDirection: 'init' | 'next' | 'prev' = 'init'; + + get popularOffers(): PopularOffer[] { + return this.popularCandidates.slice(0, this.POPULAR_TOTAL); + } + + + get popularPages(): PopularOffer[][] { + const pages: PopularOffer[][] = []; + for (let i = 0; i < this.popularOffers.length; i += this.POPULAR_PER_PAGE) { + pages.push(this.popularOffers.slice(i, i + this.POPULAR_PER_PAGE)); + } + return pages; + } + + get popularPageCount(): number { + return this.popularPages.length; + } + + constructor( + private api: ApiServiceService, + private router: Router, + private localStorage: LocalStorageService, + ) {} + + ngOnInit(): void { + this.loadCategories(); + this.loadPopularOffers(); + } + + private async loadCategories(): Promise { + try { + const roots = await this.api.getDefaultCategories(); + const domeRoot = (Array.isArray(roots) ? roots : []).find( + (c: any) => c?.name === 'DOME Categories', + ); + if (domeRoot?.id) { + const children = await this.api.getCategoriesByParentId(domeRoot.id); + this.categories = Array.isArray(children) ? children : []; + } else { + this.categories = []; + } + } catch (e) { + console.error('Error loading categories:', e); + } finally { + this.loading = false; + } + } + + private async loadPopularOffers(): Promise { + try { + const offsets = [0, 6, 12, 18, 24, 30, 36, 42]; + const batches = await Promise.all( + offsets.map(o => this.api.getProducts(o, undefined).catch(() => [])), + ); + + const seen = new Set(); + const pool: any[] = []; + for (const batch of batches) { + if (!Array.isArray(batch)) continue; + for (const o of batch) { + const id = o?.id; + if (id && !seen.has(id)) { + seen.add(id); + pool.push(o); + } + } + } + + const detailed = await this.api.getProductsDetails(pool); + + const candidates: PopularOffer[] = detailed + .map(offer => { + const attachments = (offer as any)?.attachment ?? []; + const profile = attachments.find((a: any) => a?.name === 'Profile Picture'); + const picture = profile ?? attachments.find((a: any) => { + const type = (a?.attachmentType ?? '').toLowerCase(); + return type === 'picture' || type.startsWith('image/'); + }); + return { + id: (offer as any).id, + name: (offer as any).name ?? '', + description: (offer as any).description ?? '', + imageUrl: picture?.url ?? '', + }; + }) + .filter(o => !!o.id && !!o.imageUrl); + + const validated = await Promise.all( + candidates.map(async o => ((await this.imageLoads(o.imageUrl)) ? o : null)), + ); + const valid = validated.filter((o): o is PopularOffer => o !== null); + for (let i = valid.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [valid[i], valid[j]] = [valid[j], valid[i]]; + } + this.popularCandidates = valid; + } catch (e) { + console.error('Error loading popular offers:', e); + this.popularCandidates = []; + } finally { + this.popularLoading = false; + } + } + + private imageLoads(url: string): Promise { + return new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0); + img.onerror = () => resolve(false); + img.src = url; + }); + } + + nextPopularPage(): void { + if (this.popularPageCount === 0) return; + this.popularDirection = 'next'; + this.popularPage = (this.popularPage + 1) % this.popularPageCount; + } + + prevPopularPage(): void { + if (this.popularPageCount === 0) return; + this.popularDirection = 'prev'; + this.popularPage = (this.popularPage - 1 + this.popularPageCount) % this.popularPageCount; + } + + setPopularPage(index: number): void { + if (index >= 0 && index < this.popularPageCount && index !== this.popularPage) { + this.popularDirection = index > this.popularPage ? 'next' : 'prev'; + this.popularPage = index; + } + } + + goToOffer(offer: PopularOffer): void { + this.router.navigate(['/search', offer.id]); + } + + onSearch(event: Event) { + event.preventDefault(); + if (this.searchQuery.trim()) { + this.router.navigate(['/search', { keywords: this.searchQuery.trim() }]); + } else { + this.router.navigate(['/search']); + } + } + + onCategoryClick(category: Category) { + localStorage.removeItem('selected_categories'); + this.localStorage.addCategoryFilter(category); + this.router.navigate(['/search']); + } + + onShowAll() { + localStorage.removeItem('selected_categories'); + this.router.navigate(['/search']); + } +} diff --git a/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.html b/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.html index 547df90a..2fcf2258 100644 --- a/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.html +++ b/src/app/pages/dashboard/dashboard-ecosystem/dashboard-ecosystem.component.html @@ -78,12 +78,12 @@
- {{ 'DASHBOARD.ecosystem._ctaCustomers' | translate }} - {{ 'DASHBOARD.ecosystem._ctaProviders' | translate }} 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 694ab41a..98e61f5a 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, input } from "@angular/core"; +import { RouterLink } from "@angular/router"; import { TranslateModule } from '@ngx-translate/core'; type Milestone = { @@ -11,7 +12,7 @@ type Milestone = { selector: "app-dashboard-ecosystem", standalone: true, templateUrl: "./dashboard-ecosystem.component.html", - imports: [TranslateModule] + imports: [TranslateModule, RouterLink] }) export class DashboardEcosystemComponent { 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 60d1d8db..54b1c484 100644 --- a/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html +++ b/src/app/pages/dashboard/dashboard-services/dashboard-services.component.html @@ -18,23 +18,28 @@ + @if (category.children) { + @if (category.children.length != 0) { + + } + } + } @empty { + + } +
+} diff --git a/src/app/shared/categories-filter/categories-filter.component.ts b/src/app/shared/categories-filter/categories-filter.component.ts index 60433a02..44f0f629 100644 --- a/src/app/shared/categories-filter/categories-filter.component.ts +++ b/src/app/shared/categories-filter/categories-filter.component.ts @@ -1,4 +1,4 @@ -import {Component, EventEmitter, OnInit, Output, ChangeDetectorRef, Input, OnDestroy} from '@angular/core'; +import {Component, EventEmitter, OnInit, Output, ChangeDetectorRef, Input, OnDestroy, OnChanges, SimpleChanges} from '@angular/core'; import {Category} from "../../models/interfaces"; import {Subject} from "rxjs"; import {LocalStorageService} from "../../services/local-storage.service"; @@ -10,13 +10,14 @@ import {faCircle} from "@fortawesome/pro-regular-svg-icons"; import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import availableFilters, { type Filter } from '../../data/availableFilters'; +import { iconForCategory } from '../../data/categoryIcons'; @Component({ selector: 'bae-categories-filter', templateUrl: './categories-filter.component.html', styleUrl: './categories-filter.component.css' }) -export class CategoriesFilterComponent implements OnInit, OnDestroy { +export class CategoriesFilterComponent implements OnInit, OnDestroy, OnChanges { classListFirst = 'flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-b-0 border-gray-200 rounded-t-xl focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-tertiary-100 gap-3'; classListLast = 'flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-gray-200 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-tertiary-100 gap-3'; @@ -35,6 +36,9 @@ export class CategoriesFilterComponent implements OnInit, OnDestroy { cs: Category[] = []; @Output() selectedCategories = new EventEmitter(); @Input() catalogId: any = undefined; + @Input() showTitle: boolean = true; + @Input() simpleMode: boolean = false; + @Input() selectedRootId: string | null | undefined = undefined; // AI Search facets aiSearchEnabled = environment.AI_SEARCH_ENABLED; @@ -42,8 +46,24 @@ export class CategoriesFilterComponent implements OnInit, OnDestroy { dynamicAiCategories: Category[] = []; configuredAiCategories: Category[] = []; + get allFilterItems(): Category[] { + const result: Category[] = []; + const collect = (cats: Category[]) => { + for (const cat of cats) { + if (cat.children && cat.children.length > 0) { + collect(cat.children); + } else { + result.push(cat); + } + } + }; + collect(this.categories); + return result; + } + protected readonly faCircleCheck = faCircleCheck; protected readonly faCircle = faCircle; + protected readonly iconForCategory = iconForCategory; private destroy$ = new Subject(); @@ -90,7 +110,7 @@ export class CategoriesFilterComponent implements OnInit, OnDestroy { this.checkedCategories.push(this.selected[i].id) } - if (this.aiSearchEnabled) { + if (this.aiSearchEnabled && !this.selectedRootId) { await this.loadCatalogCategories(); this.dynamicAiCategories = this.convertDynamicCategoriesToAiFilterCategories(this.categories); this.configuredAiCategories = this.convertFiltersToCategories(availableFilters); @@ -444,6 +464,13 @@ export class CategoriesFilterComponent implements OnInit, OnDestroy { private async loadCatalogCategories(): Promise { this.categories = []; + + if (this.selectedRootId) { + const children = await this.api.getCategoriesByParentId(this.selectedRootId).catch(() => []); + this.categories = Array.isArray(children) ? children : []; + return; + } + const hasCatalogId = this.catalogId !== undefined && this.catalogId !== null && String(this.catalogId).trim() !== ''; if (hasCatalogId) { @@ -476,6 +503,15 @@ export class CategoriesFilterComponent implements OnInit, OnDestroy { } } + async ngOnChanges(changes: SimpleChanges) { + const rootChange = changes['selectedRootId']; + if (rootChange && !rootChange.firstChange) { + await this.loadCatalogCategories(); + this.cdr.detectChanges(); + initFlowbite(); + } + } + private async loadCategorySubtree(parent: any): Promise { const children = await this.api.getCategoriesByParentId(parent.id).catch(() => []); const childList = Array.isArray(children) ? children : []; diff --git a/src/app/themes/dome.theme.ts b/src/app/themes/dome.theme.ts index c5848d04..2ce56ffc 100644 --- a/src/app/themes/dome.theme.ts +++ b/src/app/themes/dome.theme.ts @@ -14,19 +14,9 @@ const domeHeaderLinks: NavLink[] = [ isRouterLink: true }, { - id: 'dropdown-marketplace', label: 'HEADER._marketplaceH', - children: [{ - label: 'HEADER._browse_serv', - url: '/search', - isRouterLink: true, - }, - { - label: 'HEADER._catalogs', - url: '/catalogues', - isRouterLink: true - } - ] + url: '/browse', + isRouterLink: true }, { label: 'HEADER._resources', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index dd2b091b..76b9f18e 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1656,6 +1656,19 @@ "forCustomers": "For Customers", "forProviders": "For Providers" }, + "BROWSE": { + "heroTitle1": "Discover ", + "heroAccent1": "trusted European", + "heroTitle2": " cloud, edge & AI ", + "heroAccent2": "services", + "searchPlaceholder": "Search Cloud Services, Data Services, Providers...", + "categoriesTitle": "What are you looking for?", + "categoriesSubtitle": "Explore trusted digital services by category", + "showAll": "Show all", + "popularTitle": "Most popular services", + "popularSubtitle": "Discover services tailored to your needs", + "viewService": "View service" + }, "HEADER": { "contactUs": "Contact us", "_browse_serv": "Browse services", diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index dd2b091b..76b9f18e 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -1656,6 +1656,19 @@ "forCustomers": "For Customers", "forProviders": "For Providers" }, + "BROWSE": { + "heroTitle1": "Discover ", + "heroAccent1": "trusted European", + "heroTitle2": " cloud, edge & AI ", + "heroAccent2": "services", + "searchPlaceholder": "Search Cloud Services, Data Services, Providers...", + "categoriesTitle": "What are you looking for?", + "categoriesSubtitle": "Explore trusted digital services by category", + "showAll": "Show all", + "popularTitle": "Most popular services", + "popularSubtitle": "Discover services tailored to your needs", + "viewService": "View service" + }, "HEADER": { "contactUs": "Contact us", "_browse_serv": "Browse services", diff --git a/src/assets/images/bg_1_shadow.png b/src/assets/images/bg_1_shadow.png deleted file mode 100644 index 0cc28f8a..00000000 Binary files a/src/assets/images/bg_1_shadow.png and /dev/null differ