From 61215ea2b52640191793a95439463eb4145743f3 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Wed, 29 Jan 2025 13:37:49 +0100 Subject: [PATCH] Add asset page and carousel component --- .../src/pages/Dashboard/AssetsPage.tsx | 151 +++++++++++++++- centrifuge-app/src/utils/useLoans.ts | 19 +- fabric/src/components/Carousel/index.tsx | 168 ++++++++++++++++++ fabric/src/components/Checkbox/index.tsx | 34 ++-- fabric/src/index.ts | 1 + 5 files changed, 346 insertions(+), 27 deletions(-) create mode 100644 fabric/src/components/Carousel/index.tsx diff --git a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx index fc03603317..adcf48fccd 100644 --- a/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx +++ b/centrifuge-app/src/pages/Dashboard/AssetsPage.tsx @@ -1,10 +1,149 @@ -import { Pool } from '@centrifuge/centrifuge-js' +import { Loan, Pool } from '@centrifuge/centrifuge-js' +import { useCentrifuge } from '@centrifuge/centrifuge-react' +import { Box, Carousel, Checkbox, Grid, Text, Thumbnail } from '@centrifuge/fabric' +import { useEffect, useRef, useState } from 'react' +import { useTheme } from 'styled-components' +import { Spinner } from '../../../src/components/Spinner' import { useLoans } from '../../../src/utils/useLoans' +import { usePoolMetadata } from '../../../src/utils/usePools' + +type TransformedLoan = { + poolUri: string | undefined + poolName: string | undefined + id: string + loans: Loan[] +} + +export const PoolCard = ({ + poolUri, + poolName, + id, + children, + active, + onClick, +}: { + poolUri: string | undefined + poolName: string | undefined + id: string + children: React.ReactNode + active: boolean + onClick: () => void +}) => { + const theme = useTheme() + return ( + + + {poolUri ? ( + + ) : ( + + )} + + {poolName} + + + {children} + + ) +} export default function AssetsPage({ pools }: { pools: Pool[] }) { - // Use a single hook that handles multiple pool IDs - const allLoans = useLoans(pools.map((pool) => pool.id)) - console.log(allLoans) -export default function AssetsPage() { - return <> + const cent = useCentrifuge() + const ids = pools.map((pool) => pool.id) + const { data: loans, isLoading } = useLoans(pools ? ids : []) + const [selectedPools, setSelectedPools] = useState([]) + const containerRef = useRef(null) + const [useCarousel, setUseCarousel] = useState(false) + + const loansByPool = loans?.reduce((acc: Record>, loan) => { + acc[loan.poolId] = acc[loan.poolId] || {} + acc[loan.poolId][loan.id] = loan + return acc + }, {}) + + const transformedLoans: TransformedLoan[] = pools + .map((pool) => { + const { data: poolMetadata } = usePoolMetadata(pool) + const poolUri = poolMetadata?.pool?.icon?.uri + ? cent.metadata.parseMetadataUrl(poolMetadata?.pool?.icon?.uri) + : undefined + return { + ...pool, + loans: loansByPool?.[pool.id] ? Object.values(loansByPool[pool.id]) : [], + poolName: poolMetadata?.pool?.name, + poolUri, + } + }) + .slice(0, 3) + + const renderPoolCards = (loans: TransformedLoan[]) => { + return loans.map((loan, index) => { + const { poolUri, poolName, id } = loan + + const selectedPool = () => { + const past = selectedPools.find((pool) => pool === id) + if (past) { + setSelectedPools(selectedPools.filter((pool) => pool !== id)) + } else { + setSelectedPools([...selectedPools, id]) + } + } + + return ( + } + onClick={selectedPool} + /> + ) + }) + } + + useEffect(() => { + const checkWrapping = () => { + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth + const totalCardWidth = transformedLoans.length * 300 + setUseCarousel(totalCardWidth > containerWidth) + } + } + + checkWrapping() + window.addEventListener('resize', checkWrapping) + return () => window.removeEventListener('resize', checkWrapping) + }, [transformedLoans]) + + if (isLoading) return + + return ( + + Dashboard + + {useCarousel ? ( + + {renderPoolCards(transformedLoans)} + + ) : ( + + {renderPoolCards(transformedLoans)} + + )} + + + ) } diff --git a/centrifuge-app/src/utils/useLoans.ts b/centrifuge-app/src/utils/useLoans.ts index b7cf7f73de..c5bebd4de9 100644 --- a/centrifuge-app/src/utils/useLoans.ts +++ b/centrifuge-app/src/utils/useLoans.ts @@ -3,16 +3,19 @@ import { Dec } from './Decimal' import { useTinlakeLoans } from './tinlake/useTinlakePools' export function useLoans(poolIds: string[]) { - const isTinlakePool = poolIds.length === 1 && poolIds[0].startsWith('0x') + const isTinlakePool = poolIds.length === 1 && poolIds[0]?.startsWith('0x') - if (isTinlakePool) { - const { data: tinlakeLoans, isLoading: isLoadingTinlake } = useTinlakeLoans(poolIds[0]) - return { data: tinlakeLoans, isLoading: isLoadingTinlake } - } else { - const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolIds], (cent) => cent.pools.getLoans({ poolIds })) - console.log('centLoans', centLoans) - return { data: centLoans, isLoading } + if (poolIds.length === 0) { + return { data: null, isLoading: false } } + + const { data: tinlakeLoans, isLoading: isLoadingTinlake } = useTinlakeLoans(poolIds[0]) + + const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolIds], (cent) => cent.pools.getLoans({ poolIds }), { + suspense: true, + enabled: !isTinlakePool, + }) + return { data: isTinlakePool ? tinlakeLoans : centLoans, isLoading: isTinlakePool ? isLoadingTinlake : isLoading } } export function useLoan(poolId: string, assetId: string | undefined) { diff --git a/fabric/src/components/Carousel/index.tsx b/fabric/src/components/Carousel/index.tsx new file mode 100644 index 0000000000..9bf0e78403 --- /dev/null +++ b/fabric/src/components/Carousel/index.tsx @@ -0,0 +1,168 @@ +import React, { TouchEvent, useEffect, useState } from 'react' +import styled from 'styled-components' +import { IconChevronLeft, IconChevronRight } from '../../icon' +import { Box } from '../Box' + +interface CarouselProps { + children: React.ReactNode[] + visibleItems?: number + itemWidth?: number + gap?: number +} + +const CarouselContainer = styled(Box)` + position: relative; + display: flex; + align-items: center; +` + +const CarouselViewport = styled(Box)` + overflow: hidden; + margin-left: 40px; + margin-right: 40px; +` + +const CarouselItems = styled(Box)<{ translateX: number }>` + display: flex; + transform: ${({ translateX }) => `translateX(-${translateX}px)`}; + transition: transform 0.3s ease-in-out; + margin-right: 16px; +` + +const CarouselItem = styled(Box)<{ itemWidth: number; gap: number }>` + flex: 0 0 auto; + min-width: ${({ itemWidth }) => itemWidth}px; + margin-right: ${({ gap }) => gap}px; + + &:last-child { + margin-right: 0; + } +` + +const CarouselArrow = styled.button<{ position: 'left' | 'right' }>` + background-color: transparent; + color: ${({ theme }) => theme.colors.backgroundInverted}; + border: none; + width: 20px; + height: 20px; + cursor: pointer; + font-size: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + position: absolute; + top: 50%; + ${({ position }) => position}: 8px; + transform: translateY(-50%); + transition: background-color 0.3s; + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +` + +export const Carousel = ({ children, visibleItems = 3, itemWidth = 200, gap = 16 }: CarouselProps) => { + const [currentIndex, setCurrentIndex] = useState(0) + const [dynamicVisibleItems, setDynamicVisibleItems] = useState(visibleItems) + const totalItems = children.length + const maxIndex = Math.max(totalItems - dynamicVisibleItems, 0) + + useEffect(() => { + const updateVisibleItems = () => { + const width = window.innerWidth + if (width < 600) { + setDynamicVisibleItems(1) + } else if (width >= 600 && width < 900) { + setDynamicVisibleItems(2) + } else { + setDynamicVisibleItems(3) + } + } + + updateVisibleItems() + + window.addEventListener('resize', updateVisibleItems) + return () => window.removeEventListener('resize', updateVisibleItems) + }, []) + + useEffect(() => { + setCurrentIndex((prev) => Math.min(prev, Math.max(totalItems - dynamicVisibleItems, 0))) + }, [dynamicVisibleItems, totalItems]) + + const handlePrev = () => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)) + } + + const handleNext = () => { + setCurrentIndex((prev) => Math.min(prev + 1, maxIndex)) + } + + const [touchStartX, setTouchStartX] = useState(null) + const [touchEndX, setTouchEndX] = useState(null) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + handlePrev() + } else if (e.key === 'ArrowRight') { + handleNext() + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, []) + + const onTouchStart = (e: TouchEvent) => { + setTouchStartX(e.changedTouches[0].screenX) + } + + const onTouchMove = (e: TouchEvent) => { + setTouchEndX(e.changedTouches[0].screenX) + } + + const onTouchEnd = () => { + if (touchStartX === null || touchEndX === null) return + const distance = touchStartX - touchEndX + const threshold = 50 + if (distance > threshold) { + handleNext() + } else if (distance < -threshold) { + handlePrev() + } + setTouchStartX(null) + setTouchEndX(null) + } + + return ( + + {currentIndex > 0 && ( + + + + )} + + + + {children.map((child, index) => ( + + {child} + + ))} + + + + {currentIndex < maxIndex && ( + + + + )} + + ) +} diff --git a/fabric/src/components/Checkbox/index.tsx b/fabric/src/components/Checkbox/index.tsx index 5585f954e4..290b99ea35 100644 --- a/fabric/src/components/Checkbox/index.tsx +++ b/fabric/src/components/Checkbox/index.tsx @@ -10,15 +10,22 @@ type CheckboxProps = React.InputHTMLAttributes & { label?: string | React.ReactElement errorMessage?: string extendedClickArea?: boolean + variant?: 'primary' | 'secondary' } -export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxProps }: CheckboxProps) { +export function Checkbox({ + label, + errorMessage, + extendedClickArea, + variant = 'primary', + ...checkboxProps +}: CheckboxProps) { return ( - + {label && ( @@ -88,20 +95,21 @@ const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>` } ` -const StyledCheckbox = styled.input` - width: 18px; - height: 18px; +const StyledCheckbox = styled.input<{ variant: 'primary' | 'secondary' }>` + width: 16px; + height: 16px; appearance: none; - border-radius: 2px; - border: 1px solid ${({ theme }) => theme.colors.borderPrimary}; + border-radius: 4px; + border: 1px solid + ${({ theme, variant }) => (variant === 'primary' ? theme.colors.borderPrimary : theme.colors.textPrimary)}; position: relative; cursor: pointer; transition: background-color 0.2s, border-color 0.2s; - ${({ theme }) => ` + ${({ theme, variant }) => ` &:checked { - border-color: ${theme.colors.borderSecondary}; - background-color: ${theme.colors.textGold}; + border-color: ${variant === 'primary' ? theme.colors.borderSecondary : theme.colors.textPrimary}; + background-color: ${variant === 'primary' ? theme.colors.textGold : 'white'}; } &:checked::after { @@ -109,9 +117,9 @@ const StyledCheckbox = styled.input` position: absolute; top: 2px; left: 5px; - width: 6px; - height: 10px; - border: solid white; + width: 4px; + height: 8px; + border: solid ${variant === 'primary' ? 'white' : 'black'}; border-width: 0 2px 2px 0; transform: rotate(45deg); } diff --git a/fabric/src/index.ts b/fabric/src/index.ts index b3145c0d9c..5323998ba4 100644 --- a/fabric/src/index.ts +++ b/fabric/src/index.ts @@ -4,6 +4,7 @@ export * from './components/BetaChip' export * from './components/Box' export * from './components/Button' export * from './components/Card' +export * from './components/Carousel' export * from './components/Checkbox' export * from './components/Collapsible' export * from './components/Container'