-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Prometheus-X Catalogue #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "title": "DSIF Catalogue", | ||
| "description": "Browse service offerings from the VisionsTrust DSIF Catalogue", | ||
| "content": { | ||
| "header": "DSIF Catalogue", | ||
| "subheader": "Discover and explore service offerings", | ||
| "emptyState": "No service offerings found matching your criteria." | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| export interface VisionsTrustProvider { | ||
| _id: string | ||
| name: string | ||
| legalName?: string | ||
| } | ||
|
|
||
| export interface VisionsTrustServiceOffering { | ||
| _id: string | ||
| name: string | ||
| description: string | ||
| providedBy: string | ||
| archived: boolean | ||
| visible: boolean | ||
| pricing?: number | ||
| currency?: string | ||
| } | ||
|
|
||
| export interface ResolvedServiceOffering { | ||
| _id: string | ||
| name: string | ||
| description: string | ||
| provider: VisionsTrustProvider | ||
| link: string | ||
| pricing?: number | ||
| currency?: string | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { | ||
| VisionsTrustProvider, | ||
| VisionsTrustServiceOffering, | ||
| ResolvedServiceOffering | ||
| } from 'src/@types/VisionsTrust' | ||
|
|
||
| const VISIONS_TRUST_API_BASE = 'https://api.visionstrust.com/v1' | ||
|
|
||
| export interface FetchServiceOfferingsParams { | ||
| page?: number | ||
| limit?: number | ||
| search?: string | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thought: It seems search is never used. |
||
| } | ||
|
|
||
| export interface ServiceOfferingsResponse { | ||
| result: VisionsTrustServiceOffering[] | ||
| count: number | ||
| page: number | ||
| pages: number | null | ||
| } | ||
|
|
||
| /** | ||
| * Fetch service offerings from Visions Trust API with pagination | ||
| */ | ||
| export async function fetchServiceOfferings( | ||
| params: FetchServiceOfferingsParams = {} | ||
| ): Promise<ServiceOfferingsResponse> { | ||
| const { page = 1, limit = 50, search = '' } = params | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. todo: put 50 into a constant |
||
|
|
||
| try { | ||
| const queryParams = new URLSearchParams({ | ||
| page: page.toString(), | ||
| limit: limit.toString(), | ||
| search | ||
| }) | ||
|
|
||
| const response = await fetch( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: we use axios in this repo. |
||
| `${VISIONS_TRUST_API_BASE}/serviceofferings?${queryParams}`, | ||
| { headers: { 'Content-Type': 'application/json' } } | ||
| ) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch service offerings: ${response.status}`) | ||
| } | ||
|
|
||
| const responseData = await response.json() | ||
| // API returns data in response.data with pagination info | ||
| return { | ||
| result: responseData?.data?.result || [], | ||
| count: responseData?.data?.count || 0, | ||
| page: responseData?.data?.page || 0, | ||
| pages: responseData?.data?.pages | ||
| } | ||
| } catch (error) { | ||
| console.error('Error fetching service offerings:', error) | ||
| return { result: [], count: 0, page: 0, pages: null } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetch a single provider by ID from Visions Trust API | ||
| */ | ||
| export async function fetchProvider( | ||
| providerId: string | ||
| ): Promise<VisionsTrustProvider | null> { | ||
| try { | ||
| const response = await fetch( | ||
| `${VISIONS_TRUST_API_BASE}/catalog/participants/${providerId}`, | ||
| { headers: { 'Content-Type': 'application/json' } } | ||
| ) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error( | ||
| `Failed to fetch provider ${providerId}: ${response.status}` | ||
| ) | ||
| } | ||
|
|
||
| const data = await response.json() | ||
| return data | ||
| } catch (error) { | ||
| console.error(`Error fetching provider ${providerId}:`, error) | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Filter service offerings based on criteria | ||
| */ | ||
| export function filterServiceOfferings( | ||
| offerings: VisionsTrustServiceOffering[] | ||
| ): VisionsTrustServiceOffering[] { | ||
| return offerings.filter( | ||
| (offering) => offering.archived === false && offering.visible === true | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Resolve service offerings with provider details | ||
| */ | ||
| export async function resolveServiceOfferings( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: The name should reflect that this function is resolving the provider id and not the service offering. e.g. |
||
| offerings: VisionsTrustServiceOffering[] | ||
| ): Promise<ResolvedServiceOffering[]> { | ||
| const providerCache = new Map<string, VisionsTrustProvider>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thought: It's unfortunate that the provider id needs to be resolved to the name via extra calls. To reduce the refetching save the map higher up in the component or in a global state to prevent refetching during page changes. |
||
|
|
||
| const resolved = await Promise.all( | ||
| offerings.map(async (offering) => { | ||
| let provider = providerCache.get(offering.providedBy) | ||
|
|
||
| if (!provider) { | ||
| provider = await fetchProvider(offering.providedBy) | ||
| if (provider) { | ||
| providerCache.set(offering.providedBy, provider) | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| _id: offering._id, | ||
| name: offering.name, | ||
| description: offering.description, | ||
| provider: provider || { | ||
| _id: offering.providedBy, | ||
| name: 'Unknown Provider' | ||
| }, | ||
| link: `https://visionstrust.com/catalog/offers/${offering._id}`, | ||
| pricing: offering.pricing, | ||
| currency: offering.currency | ||
| } | ||
| }) | ||
| ) | ||
|
|
||
| return resolved | ||
| } | ||
|
|
||
| /** | ||
| * Fetch and resolve filtered service offerings for a specific page | ||
| */ | ||
| export async function fetchDSIFCatalogueData( | ||
| params: FetchServiceOfferingsParams = {} | ||
| ): Promise<{ | ||
| offerings: ResolvedServiceOffering[] | ||
| totalCount: number | ||
| totalPages: number | ||
| }> { | ||
| const response = await fetchServiceOfferings(params) | ||
| const filteredOfferings = filterServiceOfferings(response.result) | ||
| const resolvedOfferings = await resolveServiceOfferings(filteredOfferings) | ||
|
|
||
| return { | ||
| offerings: resolvedOfferings, | ||
| totalCount: response.count, | ||
| totalPages: Math.ceil(response.count / (params.limit || 50)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. todo: reuse 50 from the constant, see above |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { ReactElement } from 'react' | ||
| import Dotdotdot from 'react-dotdotdot' | ||
| import { ResolvedServiceOffering } from 'src/@types/VisionsTrust' | ||
| import Publisher from '@shared/Publisher' | ||
| import Price from '@shared/Price' | ||
| import styles from '@shared/AssetTeaser/index.module.css' | ||
|
|
||
| interface ServiceOfferingCardProps { | ||
| offering: ResolvedServiceOffering | ||
| } | ||
|
|
||
| export default function ServiceOfferingCard({ | ||
| offering | ||
| }: ServiceOfferingCardProps): ReactElement { | ||
| const PROMETHEUS_X_NETWORK = 'Prometheus-X' | ||
| const hasPrice = offering.pricing !== undefined && offering.pricing !== null | ||
|
|
||
| return ( | ||
| <article className={`${styles.teaser} ${styles.dataset}`}> | ||
| <a | ||
| href={offering.link} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className={styles.link} | ||
| > | ||
| <header className={styles.header}> | ||
| <Dotdotdot tagName="h1" clamp={3} className={styles.title}> | ||
| {offering.name.slice(0, 200)} | ||
| </Dotdotdot> | ||
| <Publisher | ||
| account="" | ||
| minimal | ||
| showName | ||
| verifiedServiceProviderName={offering.provider.legalName} | ||
| /> | ||
| </header> | ||
| <div className={styles.content}> | ||
| <Dotdotdot tagName="p" clamp={3}> | ||
| {offering.description} | ||
| </Dotdotdot> | ||
| </div> | ||
| <div className={styles.price}> | ||
| {hasPrice ? ( | ||
| <Price | ||
| price={{ | ||
| value: offering.pricing, | ||
| tokenSymbol: offering.currency || 'EUR' | ||
| }} | ||
| size="small" | ||
| /> | ||
| ) : ( | ||
| <strong>No pricing schema available</strong> | ||
| )} | ||
| </div> | ||
| <footer className={styles.footer}> | ||
| <div className={styles.stats}></div> | ||
| <span className={styles.networkName}>{PROMETHEUS_X_NETWORK}</span> | ||
| </footer> | ||
| </a> | ||
| </article> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { ReactElement, useEffect, useState, useCallback } from 'react' | ||
| import { ResolvedServiceOffering } from 'src/@types/VisionsTrust' | ||
| import { fetchDSIFCatalogueData } from '@utils/visionsTrust' | ||
| import ServiceOfferingCard from './ServiceOfferingCard' | ||
| import Pagination from '@shared/Pagination' | ||
| import Loader from '@shared/atoms/Loader' | ||
| import styles from '@shared/AssetList/index.module.css' | ||
|
|
||
| const ITEMS_PER_PAGE = 25 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: why is it 25 here and 50 in the other functions? Reuse the constant. |
||
| const ERROR_MESSAGE = | ||
| 'Failed to load service offerings. Please try again later.' | ||
| const EMPTY_MESSAGE = 'No service offerings found matching your criteria.' | ||
|
|
||
| function useDSIFCatalogue(page: number, itemsPerPage: number) { | ||
| const [offerings, setOfferings] = useState<ResolvedServiceOffering[]>([]) | ||
| const [loading, setLoading] = useState<boolean>(true) | ||
| const [error, setError] = useState<string | null>(null) | ||
| const [totalPages, setTotalPages] = useState<number>(0) | ||
|
|
||
| useEffect(() => { | ||
| let isMounted = true | ||
|
|
||
| async function loadData() { | ||
| try { | ||
| setLoading(true) | ||
| setError(null) | ||
| const data = await fetchDSIFCatalogueData({ | ||
| page: page + 1, // API is 1-indexed, we use 0-indexed | ||
| limit: itemsPerPage | ||
| }) | ||
|
|
||
| if (isMounted) { | ||
| setOfferings(data.offerings) | ||
| setTotalPages(data.totalPages) | ||
| } | ||
| } catch (err) { | ||
| console.error('Error loading DSIF Catalogue data:', err) | ||
| if (isMounted) { | ||
| setError(ERROR_MESSAGE) | ||
| } | ||
| } finally { | ||
| if (isMounted) { | ||
| setLoading(false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| loadData() | ||
|
|
||
| return () => { | ||
| isMounted = false | ||
| } | ||
| }, [page, itemsPerPage]) | ||
|
|
||
| return { offerings, loading, error, totalPages } | ||
| } | ||
|
|
||
| function usePagination() { | ||
| const [currentPage, setCurrentPage] = useState<number>(0) | ||
|
|
||
| const handlePageChange = useCallback((selected: number) => { | ||
| setCurrentPage(selected) | ||
| window.scrollTo({ top: 0, behavior: 'smooth' }) | ||
| }, []) | ||
|
|
||
| return { | ||
| currentPage, | ||
| handlePageChange | ||
| } | ||
| } | ||
|
|
||
| export default function DSIFCatalogue(): ReactElement { | ||
| const { currentPage, handlePageChange } = usePagination() | ||
| const { offerings, loading, error, totalPages } = useDSIFCatalogue( | ||
| currentPage, | ||
| ITEMS_PER_PAGE | ||
| ) | ||
|
|
||
| if (loading) return <Loader /> | ||
|
|
||
| if (error) return <div className={styles.empty}>{error}</div> | ||
|
|
||
| if (offerings.length === 0) { | ||
| return <div className={styles.empty}>{EMPTY_MESSAGE}</div> | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <div className={styles.assetList}> | ||
| {offerings.map((offering) => ( | ||
| <ServiceOfferingCard key={offering._id} offering={offering} /> | ||
| ))} | ||
| </div> | ||
|
|
||
| {totalPages > 1 && ( | ||
| <Pagination | ||
| totalPages={totalPages} | ||
| currentPage={currentPage + 1} | ||
| onChangePage={handlePageChange} | ||
| /> | ||
| )} | ||
| </> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
todo: This can be put into
app.config.jslike all other endpoint configs.