Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions content/pages/dsif-catalogue.json
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."
}
}
9 changes: 8 additions & 1 deletion content/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"name": "Faucet",
"link": "/faucet"
},

{
"name": "DSIF Catalogue",
"link": "/dsif-catalogue"
},
{
"name": "Log",
"link": "https://explorer.pontus-x.eu/"
Expand Down Expand Up @@ -214,6 +217,10 @@
"name": "Catalogue",
"link": "/search?sort=nft.created&sortOrder=desc"
},
{
"name": "DSIF Catalogue",
"link": "/dsif-catalogue"
},
{
"name": "Profile",
"link": "/profile"
Expand Down
26 changes: 26 additions & 0 deletions src/@types/VisionsTrust.d.ts
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
}
153 changes: 153 additions & 0 deletions src/@utils/visionsTrust.ts
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'
Copy link

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.js like all other endpoint configs.


export interface FetchServiceOfferingsParams {
page?: number
limit?: number
search?: string
Copy link

Choose a reason for hiding this comment

The 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
Copy link

Choose a reason for hiding this comment

The 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(
Copy link

Choose a reason for hiding this comment

The 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(
Copy link

Choose a reason for hiding this comment

The 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. resolveProviderIdToName

offerings: VisionsTrustServiceOffering[]
): Promise<ResolvedServiceOffering[]> {
const providerCache = new Map<string, VisionsTrustProvider>()
Copy link

Choose a reason for hiding this comment

The 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))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: reuse 50 from the constant, see above

}
}
62 changes: 62 additions & 0 deletions src/components/DSIFCatalogue/ServiceOfferingCard.tsx
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>
)
}
104 changes: 104 additions & 0 deletions src/components/DSIFCatalogue/index.tsx
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
Copy link

Choose a reason for hiding this comment

The 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}
/>
)}
</>
)
}
Loading
Loading