diff --git a/.env b/.env new file mode 100644 index 000000000..5408e5c59 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +NEXT_PUBLIC_SENTRY_DSN=https://e1dce4791416146de03ff1642ed719d5@o204651.ingest.us.sentry.io/4507788896239616 +SENTRY_AUTH_TOKEN=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 \ No newline at end of file diff --git a/package.json b/package.json index 52a73057a..6bb8c7439 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "express": "4.19.2", "formik": "2.4.3", "framer-motion": "10.16.4", + "gsap": "^3.12.5", "http-status-codes": "2.3.0", "husky": "8.0.3", "ioredis": "5.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e0d23471..0cc161300 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ dependencies: framer-motion: specifier: 10.16.4 version: 10.16.4(react-dom@18.2.0)(react@18.2.0) + gsap: + specifier: ^3.12.5 + version: 3.12.5 http-status-codes: specifier: 2.3.0 version: 2.3.0 @@ -11900,6 +11903,10 @@ packages: /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + /gsap@3.12.5: + resolution: {integrity: sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ==} + dev: false + /gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} diff --git a/public/bitcoin-price-tag-dark.svg b/public/bitcoin-price-tag-dark.svg new file mode 100644 index 000000000..4bb612ae4 --- /dev/null +++ b/public/bitcoin-price-tag-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/bitcoin-price-tag-fragment-light.svg b/public/bitcoin-price-tag-fragment-light.svg new file mode 100644 index 000000000..9720e19bd --- /dev/null +++ b/public/bitcoin-price-tag-fragment-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/bitcoin-price-tag-light.svg b/public/bitcoin-price-tag-light.svg new file mode 100644 index 000000000..6f1d231f9 --- /dev/null +++ b/public/bitcoin-price-tag-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/stx-price-tag-dark.svg b/public/stx-price-tag-dark.svg new file mode 100644 index 000000000..2739e0d7a --- /dev/null +++ b/public/stx-price-tag-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/stx-price-tag-fragment-light.svg b/public/stx-price-tag-fragment-light.svg new file mode 100644 index 000000000..012f299b8 --- /dev/null +++ b/public/stx-price-tag-fragment-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/stx-price-tag-light.svg b/public/stx-price-tag-light.svg new file mode 100644 index 000000000..67a6bb904 --- /dev/null +++ b/public/stx-price-tag-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/app/_components/BlockList/Grouped/BlockListGrouped.tsx b/src/app/_components/BlockList/Grouped/BlockListGrouped.tsx index 1c79068e8..9984bedca 100644 --- a/src/app/_components/BlockList/Grouped/BlockListGrouped.tsx +++ b/src/app/_components/BlockList/Grouped/BlockListGrouped.tsx @@ -5,6 +5,7 @@ import { Block, NakamotoBlock } from '@stacks/blockchain-api-client'; import { BlockLink, ExplorerLink } from '../../../../common/components/ExplorerLinks'; import { Timestamp } from '../../../../common/components/Timestamp'; +import { mobileBorderCss } from '../../../../common/constants/constants'; import { useInfiniteQueryResult } from '../../../../common/hooks/useInfiniteQueryResult'; import { useBlocksByBurnBlock } from '../../../../common/queries/useBlocksByBurnBlock'; import { truncateMiddle } from '../../../../common/utils/utils'; @@ -23,7 +24,7 @@ import { BlockCount } from '../BlockCount'; import { useBlockListContext } from '../BlockListContext'; import { LineAndNode } from '../LineAndNode'; import { ScrollableBox } from '../ScrollableDiv'; -import { getFadeAnimationStyle, mobileBorderCss } from '../consts'; +import { getFadeAnimationStyle } from '../consts'; import { BlockListBtcBlock, BlockListStxBlock } from '../types'; import { BlockListData, createBlockListStxBlock } from '../utils'; diff --git a/src/app/_components/BlockList/ScrollableDiv.tsx b/src/app/_components/BlockList/ScrollableDiv.tsx index 5389fab27..54b835ed5 100644 --- a/src/app/_components/BlockList/ScrollableDiv.tsx +++ b/src/app/_components/BlockList/ScrollableDiv.tsx @@ -26,8 +26,8 @@ export function ScrollableBox({ children, ...rest }: BoxProps & { children: Reac return ( {children} diff --git a/src/app/_components/BlockList/Ungrouped/BlockListUngrouped.tsx b/src/app/_components/BlockList/Ungrouped/BlockListUngrouped.tsx index b965caf82..a9a78ddb5 100644 --- a/src/app/_components/BlockList/Ungrouped/BlockListUngrouped.tsx +++ b/src/app/_components/BlockList/Ungrouped/BlockListUngrouped.tsx @@ -6,6 +6,7 @@ import { Block, NakamotoBlock } from '@stacks/blockchain-api-client'; import { BlockLink, ExplorerLink } from '../../../../common/components/ExplorerLinks'; import { Timestamp } from '../../../../common/components/Timestamp'; +import { mobileBorderCss } from '../../../../common/constants/constants'; import { useInfiniteQueryResult } from '../../../../common/hooks/useInfiniteQueryResult'; import { useBlocksByBurnBlock } from '../../../../common/queries/useBlocksByBurnBlock'; import { truncateMiddle } from '../../../../common/utils/utils'; @@ -23,7 +24,7 @@ import { BlockCount } from '../BlockCount'; import { useBlockListContext } from '../BlockListContext'; import { LineAndNode } from '../LineAndNode'; import { ScrollableBox } from '../ScrollableDiv'; -import { getFadeAnimationStyle, mobileBorderCss } from '../consts'; +import { getFadeAnimationStyle } from '../consts'; import { BlockListStxBlock } from '../types'; import { BlockListData, createBlockListStxBlock } from '../utils'; diff --git a/src/app/_components/NavBar/index.tsx b/src/app/_components/NavBar/index.tsx index b616705a7..8294c6b3b 100644 --- a/src/app/_components/NavBar/index.tsx +++ b/src/app/_components/NavBar/index.tsx @@ -59,6 +59,11 @@ export function NavBar({ tokenPrice }: { tokenPrice: TokenPrice }) { label: Signers, href: buildUrl('/signers', activeNetwork), }, + { + id: 'stacking', + label: Stacking, + href: buildUrl('/stacking', activeNetwork), + }, ], }, { diff --git a/src/app/search/filters/DateRange.tsx b/src/app/search/filters/DateRange.tsx index 6c0e1fdca..618ed8030 100644 --- a/src/app/search/filters/DateRange.tsx +++ b/src/app/search/filters/DateRange.tsx @@ -64,7 +64,6 @@ export function DateRangeForm({ defaultStartTime, defaultEndTime, onClose }: Dat customInput={} onChange={dateRange => { const [startDate, endDate] = dateRange; - console.log(startDate, endDate); const utcStart = startDate ? new UTCDate( startDate.getUTCFullYear(), diff --git a/src/app/signers/PageClient.tsx b/src/app/signers/PageClient.tsx index 4c613b01f..ddeb499e2 100644 --- a/src/app/signers/PageClient.tsx +++ b/src/app/signers/PageClient.tsx @@ -5,6 +5,7 @@ import { Flex } from '../../ui/Flex'; import { PageTitle } from '../_components/PageTitle'; import { SignersHeader } from './SignersHeader'; import SignersTable from './SignersTable'; +import { SignerTable2 } from './SignersTable2'; export default function ({ tokenPrice }: { tokenPrice: TokenPrice }) { return ( @@ -14,6 +15,7 @@ export default function ({ tokenPrice }: { tokenPrice: TokenPrice }) { + ); } diff --git a/src/app/signers/SignersTable.tsx b/src/app/signers/SignersTable.tsx index a8d46cf05..64d3010f7 100644 --- a/src/app/signers/SignersTable.tsx +++ b/src/app/signers/SignersTable.tsx @@ -5,6 +5,7 @@ import React, { ReactNode, Suspense, useMemo, useState } from 'react'; import { AddressLink } from '../../common/components/ExplorerLinks'; import { Section } from '../../common/components/Section'; +import { mobileBorderCss } from '../../common/constants/constants'; import { ApiResponseWithResultsOffset } from '../../common/types/api'; import { truncateMiddle } from '../../common/utils/utils'; import { Flex } from '../../ui/Flex'; @@ -21,7 +22,6 @@ import { ExplorerErrorBoundary } from '../_components/ErrorBoundary'; import { useSuspenseCurrentStackingCycle } from '../_components/Stats/CurrentStackingCycle/useCurrentStackingCycle'; import { removeStackingDaoFromName } from './SignerDistributionLegend'; import { SortByVotingPowerFilter, VotingPowerSortOrder } from './SortByVotingPowerFilter'; -import { mobileBorderCss } from './consts'; import { SignersStackersData, useGetStackersBySignerQuery } from './data/UseSignerAddresses'; import { SignerInfo, useSuspensePoxSigners } from './data/useSigners'; import { SignersTableSkeleton } from './skeleton'; diff --git a/src/app/signers/SignersTable2.tsx b/src/app/signers/SignersTable2.tsx new file mode 100644 index 000000000..30cb471f6 --- /dev/null +++ b/src/app/signers/SignersTable2.tsx @@ -0,0 +1,193 @@ +import { ColumnDefinition, Table } from '@/common/components/table/Table'; +import { UseQueryResult, useQueries, useQueryClient } from '@tanstack/react-query'; +import React, { useMemo, useState } from 'react'; + +import { AddressLink } from '../../common/components/ExplorerLinks'; +import { ApiResponseWithResultsOffset } from '../../common/types/api'; +import { truncateMiddle } from '../../common/utils/utils'; +import { Flex } from '../../ui/Flex'; +import { Text } from '../../ui/Text'; +import { useSuspenseCurrentStackingCycle } from '../_components/Stats/CurrentStackingCycle/useCurrentStackingCycle'; +import { removeStackingDaoFromName } from './SignerDistributionLegend'; +import { SortByVotingPowerFilter, VotingPowerSortOrder } from './SortByVotingPowerFilter'; +import { SignersStackersData, useGetStackersBySignerQuery } from './data/UseSignerAddresses'; +import { SignerInfo, useSuspensePoxSigners } from './data/useSigners'; +import { getSignerKeyName } from './utils'; + +const NUM_OF_ADDRESSES_TO_SHOW = 1; + +// export const SignersTableHeader = ({ +// headerTitle, +// isFirst, +// }: { +// headerTitle: string; +// isFirst: boolean; +// }) => ( +// +// +// +// {headerTitle} +// +// +// +// ); + +function getEntityName(signerKey: string) { + const entityName = removeStackingDaoFromName(getSignerKeyName(signerKey)); + return entityName === 'unknown' ? '-' : entityName; +} + +type SignerRow = [number, string, string, SignersStackersData[], number, number]; + +function formatSignerRowData( + index: number, + singerInfo: SignerInfo, + stackers: SignersStackersData[] +): SignerRow { + return [ + index, // index + singerInfo.signing_key, // signerKey + singerInfo.signing_key, // signerKey + stackers, // associatedAddress + singerInfo.weight_percent, // votingPower + parseFloat(singerInfo.stacked_amount) / 1_000_000, // stxStaked + ]; +} + +export const columnDefinitions: ColumnDefinition[] = [ + { + id: 'index', + header: '#', + accessor: (index: number | undefined) => index, + }, + { + id: 'signerKey', + header: 'Signer key', + accessor: (signerKey: string | undefined) => truncateMiddle(signerKey ?? ''), + sortable: true, + }, + { + id: 'entity', + header: 'Entity', + accessor: (signerKey: string | undefined) => getEntityName(signerKey ?? ''), + sortable: true, + }, + { + id: 'associatedAddress', + header: 'Associated address', + accessor: (stackers: SignersStackersData[] | undefined) => stackers, + cellRenderer: (stackers: SignersStackersData[] | undefined) => ( + + {stackers?.slice(0, NUM_OF_ADDRESSES_TO_SHOW).map((stacker, index) => ( + + + {truncateMiddle(stacker.stacker_address, 5, 5)} + + {index < stackers.length - 1 && ( + + ,  + + )} + {stackers.length > NUM_OF_ADDRESSES_TO_SHOW ? ( + +  +{stackers.length - NUM_OF_ADDRESSES_TO_SHOW} more + + ) : null} + + ))} + + ), + }, + { + id: 'votingPower', + header: 'Voting power', + accessor: (votingPower: number | undefined) => `${votingPower?.toFixed(2)}%`, + sortable: true, + }, + { + id: 'stxStaked', + header: 'STX stacked', + accessor: (stxStaked: number | undefined) => Number(stxStaked?.toFixed(0)).toLocaleString(), + sortable: true, + }, +]; + +export function SignerTable2() { + const [votingPowerSortOrder, setVotingPowerSortOrder] = useState(VotingPowerSortOrder.Desc); + const { currentCycleId } = useSuspenseCurrentStackingCycle(); + + // Get signers + const { + data: { results: signers }, + } = useSuspensePoxSigners(currentCycleId); + + if (!signers) { + throw new Error('Signers data is not available'); + } + + // Get signers' stackers + const queryClient = useQueryClient(); + const getQuery = useGetStackersBySignerQuery(); + const signersStackersQueries = useMemo(() => { + return { + queries: signers.map(signer => { + return getQuery(currentCycleId, signer.signing_key); + }), + combine: ( + response: UseQueryResult, Error>[] + ) => response.map(r => r.data?.results ?? []), + }; + }, [signers, getQuery, currentCycleId]); + const signersStackers = useQueries(signersStackersQueries, queryClient); + + // Format signers data + sort + const signersData = useMemo( + () => + signers + .map((signer, index) => formatSignerRowData(index, signer, signersStackers[index])) + .sort((a, b) => { + const aVotingPower = a[4]; + const bVotingPower = b[4]; + return votingPowerSortOrder === 'desc' + ? bVotingPower - aVotingPower + : aVotingPower - bVotingPower; + }), + [signers, signersStackers, votingPowerSortOrder] + ); + + return ( + {}} + sortColumn={null} + sortDirection={undefined} + topRight={ + } + /> + ); +} diff --git a/src/app/signers/consts.ts b/src/app/signers/consts.ts index a0c7b3591..2187af1d7 100644 --- a/src/app/signers/consts.ts +++ b/src/app/signers/consts.ts @@ -88,8 +88,3 @@ export const SIGNER_KEY_MAP: Record + Array.from({ length: 10 }, (_, index) => index + 1).map(row => [ + 'Xverse', + 'bc1q9hquna0...h5edvpgxfjp6d5g', + 'xverse-pool-btc-v-1-2', + 'BTC', + '10,426', + '118,432,860 STX ($12.3M)', + '2,325 BTC', + ]), + [] + ); + const columns: ColumnDefinition[] = useMemo( + () => [ + { id: 'Provider', header: 'Provider', accessor: (val: any) => val, sortable: true }, + { id: 'PoX Address', header: 'PoX Address', accessor: (val: any) => val, sortable: false }, + { id: 'Contract', header: 'Contract', accessor: (val: any) => val, sortable: false }, + { id: 'Rewards in', header: 'Rewards in', accessor: (val: any) => val, sortable: false }, + { + id: 'Stackers delegating', + header: 'Stackers delegating', + accessor: (val: any) => val, + sortable: true, + }, + { + id: 'Amount stacked', + header: 'Amount stacked', + accessor: (val: any) => val, + sortable: true, + }, + { id: 'Rewards', header: 'Rewards', accessor: (val: any) => val, sortable: true }, + ], + [] + ); + + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const onSort = useCallback((columnId: string, newSortDirection: 'asc' | 'desc') => { + setSortColumn(columnId); + setSortDirection(newSortDirection); + }, []); + + return ( + + ); +} diff --git a/src/app/stacking/CycleInformation.tsx b/src/app/stacking/CycleInformation.tsx new file mode 100644 index 000000000..5f214c774 --- /dev/null +++ b/src/app/stacking/CycleInformation.tsx @@ -0,0 +1,127 @@ +import { Box, VStack } from '@chakra-ui/react'; +import { ArrowRight } from '@phosphor-icons/react'; +import gsap from 'gsap'; +import { useEffect, useRef } from 'react'; + +import { Flex } from '../../ui/Flex'; +import { Icon } from '../../ui/Icon'; +import { Text } from '../../ui/Text'; + +function CountdownBadge({ daysLeft }: { daysLeft: number }) { + const outerDotRef = useRef(null); + const middleDotRef = useRef(null); + + useEffect(() => { + const tl = gsap.timeline(); + + tl.to(middleDotRef.current, { + keyframes: { + opacity: [0.2, 1, 0.2], + scale: [0.7, 1, 0.7], + }, + duration: 3, + delay: 0, + repeat: -1, + }); + + tl.to(outerDotRef.current, { + keyframes: { + opacity: [0.2, 1, 0.2], + scale: [0.7, 1, 0.7], + }, + duration: 3, + delay: 0.8, + repeat: -1, + }); + + return () => { + tl.kill(); // Clean up the animation when the component unmounts + }; + }, []); + + return ( + + + + + + Ends in {daysLeft} days + + ); +} + +type CycleType = 'current' | 'next'; + +// Current Cycle Component +export const CycleInformation = ({ + name = 'Current cycle', + id = 123, + stxStacked = 352735673, + cycleType, +}: { + name: string; + id: number; + stxStacked: number; + cycleType: CycleType; +}) => { + return ( + + + + + {name} + + {cycleType === 'current' && } + + + + {id} + + + + + + + ); +}; + +const StackedStxMetric = ({ stxStacked }: { stxStacked: number }) => { + const stackedStxString = stxStacked.toLocaleString(); + + return ( + + + {`${stackedStxString} STX`} + +   + + ($124.3M) + +   + + stacked + + + ); +}; diff --git a/src/app/stacking/FAQ.tsx b/src/app/stacking/FAQ.tsx new file mode 100644 index 000000000..7210ab65c --- /dev/null +++ b/src/app/stacking/FAQ.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import { ReverseAccordion, ReverseAccordionItem } from './ReverseAccordion'; + +export function FAQ() { + const items: ReverseAccordionItem[] = useMemo( + () => [ + { + title: 'What is Stacking', + text: "Stacking is a way to earn Bitcoin (BTC) by temporarily locking up STX tokens to support the Stacks network's security through the Proof of Transfer (PoX) consensus mechanism.", + link: 'https://docs.stacks.co', + linkLabel: 'Learn more', + }, + { + title: 'What is a pool and how to pool', + text: 'A pool is a collection of STX that is used to participate in the Stacking Protocol. You can pool your STX with other users to earn rewards.', + link: 'https://docs.stacks.co', + linkLabel: 'See active pools', + }, + { + title: 'How to start Stacking', + text: 'First, you need some STX tokens. If you meet the minimum required to stack independently, you can stack by running your own Signer. If you have fewer tokens, you can delegate to a Stacking pool provider.', + link: 'https://docs.stacks.co', + linkLabel: 'Learn more', + }, + ], + [] + ); + + return ; +} diff --git a/src/app/stacking/Header.tsx b/src/app/stacking/Header.tsx new file mode 100644 index 000000000..a557f5e9a --- /dev/null +++ b/src/app/stacking/Header.tsx @@ -0,0 +1,10 @@ +export function Header() { + return ( +
+

Stacking

+

+ Stacking is a way to earn Bitcoin by locking up your STX tokens for a set period of time. +

+
+ ); +} diff --git a/src/app/stacking/MetricCards.tsx b/src/app/stacking/MetricCards.tsx new file mode 100644 index 000000000..9fc1ba573 --- /dev/null +++ b/src/app/stacking/MetricCards.tsx @@ -0,0 +1,48 @@ +import { Info } from '@phosphor-icons/react'; + +import { Card } from '../../common/components/Card'; +import { Flex } from '../../ui/Flex'; +import { Icon } from '../../ui/Icon'; +import { Text } from '../../ui/Text'; + +const MetricCard = ({ + title, + primaryMetric, + secondaryMetric, +}: { + title: string; + primaryMetric: string; + secondaryMetric: string; +}) => { + return ( + + + + {title} + + + + + {primaryMetric} + + + {secondaryMetric} + + + + + ); +}; + +export const MetricCards = () => { + return ( + + + + + ); +}; diff --git a/src/app/stacking/PageClient.tsx b/src/app/stacking/PageClient.tsx new file mode 100644 index 000000000..ddfb87062 --- /dev/null +++ b/src/app/stacking/PageClient.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { TokenPrice } from '../../common/types/tokenPrice'; +import { Flex } from '../../ui/Flex'; +import { Grid } from '../../ui/Grid'; +import { Stack } from '../../ui/Stack'; +import { ActivePoolsTable } from './ActivePoolsTable'; +import { FAQ } from './FAQ'; +import { MetricCards } from './MetricCards'; +import { PoxCycleDiagram } from './PoxCycleDiagram'; +import { PreviousCyclesTable } from './PreviousCyclesTable'; +import { PriceTag } from './PriceTag'; +import { StackersEarnings } from './StackersEarnings'; + +export default function ({ tokenPrice }: { tokenPrice: TokenPrice }) { + return ( + <> + {/* + Stacking + */} + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/stacking/PoxCycleDiagram.tsx b/src/app/stacking/PoxCycleDiagram.tsx new file mode 100644 index 000000000..8bce530ce --- /dev/null +++ b/src/app/stacking/PoxCycleDiagram.tsx @@ -0,0 +1,24 @@ +import { useBreakpointValue } from '../../ui/hooks/useBreakpointValue'; +import { VerticalPoxCycleDiagram } from './VerticalPoxCycleDiagram'; +import { HorizontalPoxCycleDiagram } from './horizontal-pox-cycle-diagram/HorizontalPoxCycleDiagram'; +import { usePoxCycle } from './usePoxCycle'; + +export const PoxCycleDiagram = () => { + const poxCycleData = usePoxCycle(); + + const poxCycleDiagram = useBreakpointValue( + { + lg: , + md: , + sm: , + xs: , + base: , + }, + { + fallback: 'lg', + ssr: false, + } + ); + + return poxCycleDiagram ?? null; +}; diff --git a/src/app/stacking/PreviousCyclesTable.tsx b/src/app/stacking/PreviousCyclesTable.tsx new file mode 100644 index 000000000..4de8bd00e --- /dev/null +++ b/src/app/stacking/PreviousCyclesTable.tsx @@ -0,0 +1,162 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { ColumnDefinition } from '../../common/components/table/Table'; +import { CustomTableWithCursorPagination } from '../../common/components/table/TableWithCursorPagination'; +import { Flex } from '../../ui/Flex'; +import { Icon } from '../../ui/Icon'; +import { Text } from '../../ui/Text'; +import BitcoinIcon from '../../ui/icons/BitcoinIcon'; +import StxIcon from '../../ui/icons/StxIcon'; + +type PreviousCyclesData = [ + number, + { + bitcoin: number; + stx: number; + }, + { + bitcoin: number; + stx: number; + }, + string, + string, + string, + number, + string, + string, + string, +]; + +export function PreviousCyclesTable() { + const data: PreviousCyclesData[] = useMemo( + () => + Array.from({ length: 10 }, (_, index) => index + 1).map((row, i) => [ + i, + { + bitcoin: 42946, + stx: 784946, + }, + { + bitcoin: 42946, + stx: 784946, + }, + '22', + '245', + '67', + 118432860, + '35.1 BTC', + '7.5%', + '100,000 STX', + ]), + [] + ); + const columns: ColumnDefinition[] = useMemo( + () => [ + { id: 'Cycle', header: 'Cycle', accessor: (val: any) => val, sortable: true }, + { + id: 'Started', + header: 'Started', + accessor: (val: any) => val, + sortable: false, + cellRenderer: (val: any) => { + return ( + + + + {val.bitcoin} + + + + {val.stx} + + + ); + }, + }, + { + id: 'Ended', + header: 'Ended', + accessor: (val: any) => val, + sortable: false, + cellRenderer: (val: any) => { + return ( + + + + {val.bitcoin} + + + + {val.stx} + + + ); + }, + }, + { id: 'Pools', header: 'Pools', accessor: (val: any) => val, sortable: true }, + { + id: 'Solo Stackers', + header: 'Solo Stackers', + accessor: (val: any) => val, + sortable: true, + }, + { + id: 'Signers', + header: 'Signers', + accessor: (val: any) => val, + sortable: true, + }, + { + id: 'Amount stacked', + header: 'Amount stacked', + accessor: (val: any) => val, + sortable: true, + cellRenderer: (val: any) => { + return ( + + + {`${val} STX`} + + + {`(12.3M)`} + + + ); + }, + }, + { id: 'Rewards', header: 'Rewards', accessor: (val: any) => val, sortable: true }, + { id: 'APY', header: 'APY', accessor: (val: any) => val, sortable: true }, + { + id: 'Minimum to stack', + header: 'Minimum to stack', + accessor: (val: any) => val, + sortable: true, + }, + ], + [] + ); + + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const onSort = useCallback((columnId: string, newSortDirection: 'asc' | 'desc') => { + setSortColumn(columnId); + setSortDirection(newSortDirection); + }, []); + + return ( + ({ + data: [], // TODO: Replace with actual data fetching logic + nextCursor: null, + })} + /> + ); +} diff --git a/src/app/stacking/PriceTag.tsx b/src/app/stacking/PriceTag.tsx new file mode 100644 index 000000000..267ac9479 --- /dev/null +++ b/src/app/stacking/PriceTag.tsx @@ -0,0 +1,93 @@ +import { Flex } from '@/ui/Flex'; +import { useEffect, useState } from 'react'; + +import { Text } from '../../ui/Text'; + +// import bitcoinPriceTagFragmentLight from '../../ui/icons/bitcoin-price-tag-fragment-light.svg'; + +export type PriceTagToken = 'btc' | 'stx'; + +export function PriceTag({ price, token }: { price: number; token: PriceTagToken }) { + // return ( + // + // + // + // + // + // + // {/* {price} */} + // + // $68,297 + // + // + // + // + // ); + const imageHeight = 40; // Height of your SVG image in pixels + const overlapAmount = 2; // Amount of overlap between image and gradient (in pixels) + const [imageWidth, setImageWidth] = useState(0); + useEffect(() => { + const img = new Image(); + img.src = + token === 'btc' + ? '/bitcoin-price-tag-fragment-light.svg' + : '/stx-price-tag-fragment-light.svg'; + + img.onload = () => { + const aspectRatio = img.width / img.height; + const calculatedWidth = imageHeight * aspectRatio; + setImageWidth(calculatedWidth); + }; + }, [token]); + + return ( + + + {/* {price} */} + $68,297 + + + ); +} diff --git a/src/app/stacking/ReverseAccordion.tsx b/src/app/stacking/ReverseAccordion.tsx new file mode 100644 index 000000000..e6091673a --- /dev/null +++ b/src/app/stacking/ReverseAccordion.tsx @@ -0,0 +1,194 @@ +import { Plus, X } from '@phosphor-icons/react'; +import { RefObject, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Box } from '../../ui/Box'; +import { Button } from '../../ui/Button'; +import { Flex } from '../../ui/Flex'; +import { Icon } from '../../ui/Icon'; +import { Stack } from '../../ui/Stack'; +import { Text } from '../../ui/Text'; +import useResizeObserver from './useResizeObserver'; + +const cardPaddingY = 3; +const cardMarginBottom = 3; +const cardOverlap = 10; // Amount of overlap between cards +const onHoverRise = 40; // How much the card rises when hovered +const extraRiseToShowContent = cardOverlap - 5; + +const calculateCardHeight = (cardTitleHeight: number) => { + return cardTitleHeight + cardMarginBottom * 4 + cardPaddingY * 2 * 4; +}; + +function ReverseAccordionItem({ + title, + text, + link, + linkLabel, + index, + setIsExpanded, + isExpanded, + itemTitleRef, + topPosition, + accordionWidth, +}: { + title: string; + text: string; + link: string; + linkLabel: string; + index: number; + setIsExpanded: (index: number, state: boolean) => void; + isExpanded: boolean; + itemTitleRef: RefObject; + topPosition: number; + accordionWidth: number; +}) { + const [isHovered, setIsHovered] = useState(false); + const [contentHeight, setContentHeight] = useState(0); + const [titleHeight, setTitleHeight] = useState(0); + const contentRef = useRef(null); + const containerRef = useRef(null); + const cardHeight = useMemo(() => calculateCardHeight(titleHeight), [titleHeight]); + + useEffect(() => { + if (contentRef.current) { + setContentHeight(contentRef.current.scrollHeight); + } + }, [text, isExpanded, isHovered, accordionWidth]); + + useEffect(() => { + if (itemTitleRef.current) { + setTitleHeight(itemTitleRef.current.scrollHeight); + } + }, [text, isExpanded, isHovered, accordionWidth]); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => (isExpanded ? null : setIsExpanded(index, true))} + style={{ + // the transform is there to balance out the height so that the card will look like it moves upward instead of downward. For example, if the height grows by 20px, we can move the card up 20px so that the height drops back down to the original bottom of the card + transition: 'all 0.3s ease-out', + transform: isExpanded + ? `translateY(-${contentHeight + extraRiseToShowContent}px)` + : isHovered + ? `translateY(-${onHoverRise}px)` + : 'translateY(0)', + height: isExpanded + ? `${cardHeight + contentHeight}px` + : isHovered + ? `${cardHeight + onHoverRise}px` + : `${cardHeight}px`, + overflow: 'hidden', + zIndex: 10 + index, + }} + boxShadow="0px -8px 10px -6px rgba(0, 0, 0, 0.1)" + > + + + + {title} + (isExpanded ? setIsExpanded(index, false) : null)} + size={4} + /> + + + + {text} + + + + + ); +} + +export interface ReverseAccordionItem { + // This should be made more generic to accomodate other types of item content + title: string; + text: string; + link: string; + linkLabel: string; +} + +export function ReverseAccordion({ items }: { items: ReverseAccordionItem[] }) { + const [totalHeight, setTotalHeight] = useState(0); + const containerRef = useRef(null); + const [expandedIndex, setExpandedIndex] = useState(null); + const itemTitleRefs = useRef>>( + Array(items.length) + .fill(null) + .map(() => createRef()) + ); + + const setIsExpanded = useCallback((index: number, state: boolean) => { + if (state) { + setExpandedIndex(index); + } else { + setExpandedIndex(null); + } + }, []); + + const { width } = useResizeObserver(containerRef); + + useEffect(() => { + if (containerRef.current) { + const height = + itemTitleRefs.current.reduce( + (sum, ref) => sum + (calculateCardHeight(ref.current?.scrollHeight ?? 0) - cardOverlap), + 0 + ) + cardOverlap; // add the overlap back on to account for the last card + setTotalHeight(height); + } + }, [items, width]); + + return ( + + {items.map((item, index) => ( + + sum + (calculateCardHeight(ref.current?.scrollHeight ?? 0) - cardOverlap), + 0 + )} + accordionWidth={width} + /> + ))} + + ); +} diff --git a/src/app/stacking/StackersEarnings.tsx b/src/app/stacking/StackersEarnings.tsx new file mode 100644 index 000000000..110e0e758 --- /dev/null +++ b/src/app/stacking/StackersEarnings.tsx @@ -0,0 +1,52 @@ +import { Card } from '../../common/components/Card'; +import { Box } from '../../ui/Box'; +import { Flex } from '../../ui/Flex'; +import { Stack } from '../../ui/Stack'; +import { Tab } from '../../ui/Tab'; +import { TabList } from '../../ui/TabList'; +import { TabPanel } from '../../ui/TabPanel'; +import { TabPanels } from '../../ui/TabPanels'; +import { Tabs } from '../../ui/Tabs'; +import { Text } from '../../ui/Text'; + +function CustomTab({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +export function StackersEarnings() { + return ( + + + + + All Time + Last Cycle + + + + + Stackers have earned + + + 142,532 BTC + + + ($8.4M) + + + in rewards ✨ + + + + + + + + ); +} diff --git a/src/app/stacking/VerticalPoxCycleDiagram.tsx b/src/app/stacking/VerticalPoxCycleDiagram.tsx new file mode 100644 index 000000000..22a9fbe12 --- /dev/null +++ b/src/app/stacking/VerticalPoxCycleDiagram.tsx @@ -0,0 +1,368 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Box } from '../../ui/Box'; +import { Flex } from '../../ui/Flex'; +import { Stack } from '../../ui/Stack'; +import { Text } from '../../ui/Text'; +import { CycleInformation } from './CycleInformation'; +import { PoxCycleInfo } from './usePoxCycle'; + +export const VerticalPoxCycleDiagram = ({ data }: { data: PoxCycleInfo }) => { + const { + currentCycleId, + currentCycleStackedStx, + currentCycleBurnChainBlockHeightStart, + currentBlockDate, + prepareCycleProgress, + preparePhaseBurnBlockHeightStart, + preparePhaseDate, + progressPercentageForCurrentCycle, + rewardPhaseBurnBlockHeightStart, + approximateNextCycleDate, + progressPercentageForNextCycle, + } = data; + + return ( + + + + + + + + + + + + ); +}; + +interface Section { + name: string; + percentageMark: number; + bitcoinBlockNumber: number; + stacksBlockNumber: number; + date: Date; +} + +const progressBarPadding = 1; +const progressBarPaddingWidth = progressBarPadding * 4; +const progressBarPointSize = 1; +const currentCycleProgressBarHeight = '300px'; +const nextCycleProgressBarHeight = '100px'; +const progressBarWidth = 2; +const textLeftOffset = 5; + +function CurrentCycleProgressBar({ + start, + preparePhase, + progressPercentage, +}: { + start: Section; + preparePhase: Section; + progressPercentage: number; +}) { + const progressBarRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + + useEffect(() => { + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + if (entry.target === progressBarRef.current) { + setContainerHeight(entry.contentRect.height); + } + } + }); + + const currentProgressBarRef = progressBarRef.current; + + if (currentProgressBarRef) { + resizeObserver.observe(currentProgressBarRef); + } + + return () => { + if (currentProgressBarRef) { + resizeObserver.unobserve(currentProgressBarRef); + } + }; + }, []); + + return ( + + + + + + + + + + Started + + + + {start.date.toLocaleDateString()} + + + + Bitcoin block {start.bitcoinBlockNumber} + + + Stacks block {start.stacksBlockNumber} + + + + + Prepare Phase + + + + {preparePhase.date.toLocaleDateString()} + + + + Bitcoin block {preparePhase.bitcoinBlockNumber} + + + Stacks block {preparePhase.stacksBlockNumber} + + + + ); +} + +function NextCycleProgressBar({ + start, + progressPercentageForNextCycle, +}: { + start: Section; + progressPercentageForNextCycle: number; +}) { + // console.log('NextCycleProgressBar', { start }); + const nextCycleStartPercentage = 0.25; // magic number for setting the location of the dot indicating the start of the next cycle + + const progressBarRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + useEffect(() => { + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + if (entry.target === progressBarRef.current) { + setContainerHeight(entry.contentRect.width); + } + } + }); + + const currentProgressBarRef = progressBarRef.current; + + if (currentProgressBarRef) { + resizeObserver.observe(currentProgressBarRef); + } + + return () => { + if (currentProgressBarRef) { + resizeObserver.unobserve(currentProgressBarRef); + } + }; + }, []); + + return ( + + + + + + + + + + Starts + + + + {start.date.toLocaleDateString()} + + + + Bitcoin block {start.bitcoinBlockNumber} + + + Stacks block {start.stacksBlockNumber} + + + + ); +} diff --git a/src/app/stacking/horizontal-pox-cycle-diagram/CurrentCycleProgressBar.tsx b/src/app/stacking/horizontal-pox-cycle-diagram/CurrentCycleProgressBar.tsx new file mode 100644 index 000000000..9d5287037 --- /dev/null +++ b/src/app/stacking/horizontal-pox-cycle-diagram/CurrentCycleProgressBar.tsx @@ -0,0 +1,179 @@ +import { Flex } from '@/ui/Flex'; +import { Icon } from '@/ui/Icon'; +import BitcoinIcon from '@/ui/icons/BitcoinIcon'; +import StxIcon from '@/ui/icons/StxIcon'; +import { useRef } from 'react'; + +import { Box } from '../../../ui/Box'; +import { Stack } from '../../../ui/Stack'; +import { Text } from '../../../ui/Text'; +import useResizeObserver from '../useResizeObserver'; +import { Section } from './HorizontalPoxCycleDiagram'; +import { + progressBarHeight, + progressBarPadding, + progressBarPaddingWidth, + progressBarPointSize, +} from './consts'; + +export function CurrentCycleProgressBar({ + start, + preparePhase, + progressPercentage, + progressBarBottom, +}: { + start: Section; + preparePhase: Section; + progressPercentage: number; + progressBarBottom: number; +}) { + const progressBarRef = useRef(null); + const { width: progressBarWidth } = useResizeObserver(progressBarRef); + + return ( + + + + Started + + + + + Prepare Phase + + + + + + + + + + + {start.date.toLocaleDateString()} + + + + + + #{start.bitcoinBlockNumber} + + + {/* + Bitcoin block {start.bitcoinBlockNumber} + */} + + + + #{start.stacksBlockNumber} + + + {/* + Stacks block {start.stacksBlockNumber} + */} + + + + + {preparePhase.date.toLocaleDateString()} + + + + + + #{preparePhase.bitcoinBlockNumber} + + + {/* + Bitcoin block {preparePhase.bitcoinBlockNumber} + */} + + + + #{preparePhase.stacksBlockNumber} + + + {/* + Stacks block {preparePhase.stacksBlockNumber} + */} + + + ); +} diff --git a/src/app/stacking/horizontal-pox-cycle-diagram/HorizontalPoxCycleDiagram.tsx b/src/app/stacking/horizontal-pox-cycle-diagram/HorizontalPoxCycleDiagram.tsx new file mode 100644 index 000000000..7ca7a9b63 --- /dev/null +++ b/src/app/stacking/horizontal-pox-cycle-diagram/HorizontalPoxCycleDiagram.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Box } from '../../../ui/Box'; +import { Flex } from '../../../ui/Flex'; +import { Grid } from '../../../ui/Grid'; +import { Stack } from '../../../ui/Stack'; +import { CycleInformation } from '../CycleInformation'; +import { PoxCycleInfo } from '../usePoxCycle'; +import { useResizeObserver } from '../useResizeObserver'; +import { CurrentCycleProgressBar } from './CurrentCycleProgressBar'; +import { NextCycleProgressBar } from './NextCycleProgressBar'; + +const sizePadding = 4; +const progressBarSize = 2 + 2 + 2 + 15 + 4; +const size = sizePadding * 2 + progressBarSize; + +export const HorizontalPoxCycleDiagram = ({ data }: { data: PoxCycleInfo }) => { + const { + currentCycleId, + prepareCycleProgress, + progressPercentageForCurrentCycle, + progressPercentageForNextCycle, + nextCycleId, + currentCycleStackedStx, + nextCycleStackedStx, + currentBlockDate, + preparePhaseDate, + currentCycleBurnChainBlockHeightStart, + preparePhaseBurnBlockHeightStart, + rewardPhaseBurnBlockHeightStart, + approximateNextCycleDate, + } = data; + + const currentCycleProgressBarBlockRef = useRef(null); + const { height: currentCycleProgressBarBlockHeight } = useResizeObserver( + currentCycleProgressBarBlockRef + ); + console.log({ currentCycleProgressBarBlockHeight }); + + const nextCycleProgressBarBlockRef = useRef(null); + const { height: nextCycleProgressBarBlockHeight } = useResizeObserver( + nextCycleProgressBarBlockRef + ); + console.log({ nextCycleProgressBarBlockHeight }); + + const [progressBarBottom, setProgressBarBottom] = useState( + Math.min(currentCycleProgressBarBlockHeight, nextCycleProgressBarBlockHeight) / 2 + ); + + useEffect(() => { + setProgressBarBottom( + Math.min(currentCycleProgressBarBlockHeight, nextCycleProgressBarBlockHeight) / 2 + ); + }, [currentCycleProgressBarBlockHeight, nextCycleProgressBarBlockHeight]); + + console.log({ progressBarBottom }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export interface Section { + name: string; + percentageMark: number; + bitcoinBlockNumber: number; + stacksBlockNumber: number; + date: Date; +} diff --git a/src/app/stacking/horizontal-pox-cycle-diagram/NextCycleProgressBar.tsx b/src/app/stacking/horizontal-pox-cycle-diagram/NextCycleProgressBar.tsx new file mode 100644 index 000000000..025a211d2 --- /dev/null +++ b/src/app/stacking/horizontal-pox-cycle-diagram/NextCycleProgressBar.tsx @@ -0,0 +1,145 @@ +import { Flex } from '@/ui/Flex'; +import { Icon } from '@/ui/Icon'; +import BitcoinIcon from '@/ui/icons/BitcoinIcon'; +import StxIcon from '@/ui/icons/StxIcon'; +import { useRef } from 'react'; + +import { Box } from '../../../ui/Box'; +import { Stack } from '../../../ui/Stack'; +import { Text } from '../../../ui/Text'; +import useResizeObserver from '../useResizeObserver'; +import { Section } from './HorizontalPoxCycleDiagram'; +import { + progressBarHeight, + progressBarPadding, + progressBarPaddingWidth, + progressBarPointSize, +} from './consts'; + +export function NextCycleProgressBar({ + start, + progressPercentageForNextCycle, + progressBarBottom, +}: { + start: Section; + progressPercentageForNextCycle: number; + progressBarBottom: number; +}) { + const nextCycleStartPercentage = 0.25; // magic number for setting the location of the dot indicating the start of the next cycle + + const progressBarRef = useRef(null); + const { width: progressBarWidth } = useResizeObserver(progressBarRef); + + return ( + + + + Starts + + + + + + + + + + {start.date.toLocaleDateString()} + + + + + + #{start.bitcoinBlockNumber} + + + {/* + Bitcoin block {start.bitcoinBlockNumber} + */} + + + + #{start.stacksBlockNumber} + + + {/* + Stacks block {start.stacksBlockNumber} + */} + + + ); +} diff --git a/src/app/stacking/horizontal-pox-cycle-diagram/consts.ts b/src/app/stacking/horizontal-pox-cycle-diagram/consts.ts new file mode 100644 index 000000000..14207e4ed --- /dev/null +++ b/src/app/stacking/horizontal-pox-cycle-diagram/consts.ts @@ -0,0 +1,4 @@ +export const progressBarPadding = 1; +export const progressBarPaddingWidth = progressBarPadding * 4; +export const progressBarPointSize = 1; +export const progressBarHeight = 2; diff --git a/src/app/stacking/page.tsx b/src/app/stacking/page.tsx new file mode 100644 index 000000000..90f2660e5 --- /dev/null +++ b/src/app/stacking/page.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; + +import { Box } from '../../ui/Box'; +import { getTokenPrice } from '../getTokenPriceInfo'; + +const Page = dynamic(() => import('./PageClient'), { + loading: () => Loading..., // TODO: replace this + ssr: false, +}); + +export default async function () { + const tokenPrice = await getTokenPrice(); + return ; +} diff --git a/src/app/stacking/usePoxCycle.tsx b/src/app/stacking/usePoxCycle.tsx new file mode 100644 index 000000000..0270130fb --- /dev/null +++ b/src/app/stacking/usePoxCycle.tsx @@ -0,0 +1,115 @@ +import { NUM_SECONDS_IN_TEN_MINUTES } from '../../common/constants/constants'; +import { useBlockByHeight } from '../../common/queries/useBlockByHeight'; +import { useSuspensePoxInfoRaw } from '../../common/queries/usePoxInforRaw'; + +export interface PoxCycleInfo { + currentBurnChainBlockHeight: number; + currentBlockDate: Date | undefined; + preparePhaseBlockLength: number; + rewardCycleLength: number; + blocksUntilPreparePhase: number; + blocksUntilRewardPhase: number; + nextRewardCycleIn: number; + nextCycleId: number; + currentCycleId: number; + nextCycleStackedStx: number; + currentCycleStackedStx: number; + cycleBlockLength: number; + progressInBlocks: number; + currentCycleBurnChainBlockHeightStart: number; + progressPercentageForCurrentCycle: number; + progressPercentageForNextCycle: number; + prepareCycleProgress: number; + blocksTilNextCycle: number; + approximateSecondsTilNextCycle: number; + approximateNextCycleDate: Date; + approximateSecondsTilNextCyclePreparePhase: number; + preparePhaseDate: Date; + preparePhaseBurnBlockHeightStart: number; + rewardPhaseBurnBlockHeightStart: number; +} + +export function usePoxCycle(): PoxCycleInfo { + const { data: poxInfo } = useSuspensePoxInfoRaw(); + + const currentBurnChainBlockHeight = poxInfo.current_burnchain_block_height; + const { data: currentBlockData } = useBlockByHeight(currentBurnChainBlockHeight); + const currentBlockDate = currentBlockData?.block_time_iso + ? new Date(currentBlockData.block_time_iso) + : undefined; + + // const nextCycleRewardPhaseBlockHeight = poxInfo.next_cycle.reward_phase_start_block_height; + const preparePhaseBlockLength = poxInfo.prepare_phase_block_length; + // const rewardPhaseBlockLength = poxInfo.reward_phase_block_length; + // const prepareCycleLength = poxInfo.prepare_cycle_length; + const rewardCycleLength = poxInfo.reward_cycle_length; + // const preparePhaseStartBlockHeight = poxInfo.next_cycle.prepare_phase_start_block_height; + const blocksUntilPreparePhase = poxInfo.next_cycle.blocks_until_prepare_phase; + const blocksUntilRewardPhase = poxInfo.next_cycle.blocks_until_reward_phase; + // const rewardPhaseStartBlockHeight = poxInfo.next_cycle.reward_phase_start_block_height; + const nextRewardCycleIn = poxInfo.next_reward_cycle_in; + + const nextCycleId = poxInfo.next_cycle.id; + const currentCycleId = poxInfo.current_cycle.id; + const nextCycleStackedStx = poxInfo.next_cycle.stacked_ustx / 1000000; + const currentCycleStackedStx = poxInfo.current_cycle.stacked_ustx / 1000000; + + const cycleBlockLength = rewardCycleLength; + const progressInBlocks = cycleBlockLength - 1 - blocksUntilRewardPhase; + const currentCycleBurnChainBlockHeightStart = currentBurnChainBlockHeight - progressInBlocks; + // console.log({ + // currentCycleBurnChainBlockHeightStart, + // currentBurnChainBlockHeight, + // progressInBlocks, + // }); + // const progressPercentageForCurrentCycle = progressInBlocks / cycleBlockLength; + const progressPercentageForCurrentCycle = 0.15; + + const progressPercentageForNextCycle = blocksUntilRewardPhase === 0 ? 1 : 0; + const prepareCycleProgress = (cycleBlockLength - preparePhaseBlockLength) / cycleBlockLength; + + const blocksTilNextCycle = nextRewardCycleIn || 0; + const approximateSecondsTilNextCycle = Math.floor( + blocksTilNextCycle * NUM_SECONDS_IN_TEN_MINUTES + ); + const approximateNextCycleDate = new Date( + new Date().getTime() + approximateSecondsTilNextCycle * 1000 + ); + + const approximateSecondsTilNextCyclePreparePhase = Math.floor( + blocksUntilPreparePhase * NUM_SECONDS_IN_TEN_MINUTES + ); + const preparePhaseDate = new Date( + new Date().getTime() + approximateSecondsTilNextCyclePreparePhase * 1000 + ); + const preparePhaseBurnBlockHeightStart = currentBurnChainBlockHeight + blocksUntilPreparePhase; + + const rewardPhaseBurnBlockHeightStart = currentBurnChainBlockHeight + blocksUntilRewardPhase; + + return { + currentBurnChainBlockHeight, + currentBlockDate, + preparePhaseBlockLength, + rewardCycleLength, + blocksUntilPreparePhase, + blocksUntilRewardPhase, + nextRewardCycleIn, + nextCycleId, + currentCycleId, + nextCycleStackedStx, + currentCycleStackedStx, + cycleBlockLength, + progressInBlocks, + currentCycleBurnChainBlockHeightStart, + progressPercentageForCurrentCycle, + progressPercentageForNextCycle, + prepareCycleProgress, + blocksTilNextCycle, + approximateSecondsTilNextCycle, + approximateNextCycleDate, + approximateSecondsTilNextCyclePreparePhase, + preparePhaseDate, + preparePhaseBurnBlockHeightStart, + rewardPhaseBurnBlockHeightStart, + }; +} diff --git a/src/app/stacking/useResizeObserver.tsx b/src/app/stacking/useResizeObserver.tsx new file mode 100644 index 000000000..5b788ad23 --- /dev/null +++ b/src/app/stacking/useResizeObserver.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react'; + +interface Size { + width: number; + height: number; +} + +export function useResizeObserver(ref: React.RefObject): Size { + const [size, setSize] = useState({ width: 0, height: 0 }); + const observerRef = useRef(null); + + useEffect(() => { + if (ref.current) { + observerRef.current = new ResizeObserver(entries => { + for (let entry of entries) { + const { width, height } = entry.contentRect; + setSize({ width, height }); + } + }); + + observerRef.current.observe(ref.current); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [ref]); + + return size; +} + +export default useResizeObserver; diff --git a/src/app/stacking/useStackingPoolMembers.ts b/src/app/stacking/useStackingPoolMembers.ts new file mode 100644 index 000000000..733e15a61 --- /dev/null +++ b/src/app/stacking/useStackingPoolMembers.ts @@ -0,0 +1,51 @@ +// import { useApi } from "@/common/api/useApi"; +// import { useQuery, UseQueryResult } from "@tanstack/react-query"; + +// export function useStackingPoolMembers( +// heightOrHash: number | string, +// options: any = {} +// ): UseQueryResult { +// const api = useApi(); +// return useQuery({ +// queryKey: ['burn-block', heightOrHash], +// queryFn: () => +// api.infoApi. +// staleTime: Infinity, +// ...options, +// }); +// } + +// interface StackingPoolMember { + +// } + +// const fetchStackingPoolMembers = async ( +// tokenId: string, +// pageParam: number, +// options: any +// ): Promise => { +// limit: limit.toString(), +// offset: offset.toString(), +// }).toString(); +// const response = await fetch( +// `https://api.dev.hiro.so/extended/v1/tokens/ft/${tokenId}/holders${ +// queryString ? `?${queryString}` : '' +// }` +// ); +// return response.json(); +// }; + +// export function useSuspenseFtHolders(fullyQualifiedTokenId: string, options: any = {}) { +// // const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + +// return useSuspenseInfiniteQuery({ +// queryKey: [HOLDERS_QUERY_KEY, fullyQualifiedTokenId], +// queryFn: ({ pageParam }: { pageParam: number }) => +// fetchHolders(fullyQualifiedTokenId, pageParam, options), +// getNextPageParam, +// initialPageParam: 0, +// staleTime: TEN_MINUTES, +// enabled: !!fullyQualifiedTokenId, +// ...options, +// }); +// } diff --git a/src/app/token/[tokenId]/Tabs/holders/Holders.tsx b/src/app/token/[tokenId]/Tabs/holders/Holders.tsx index c80c22949..bb23e04d6 100644 --- a/src/app/token/[tokenId]/Tabs/holders/Holders.tsx +++ b/src/app/token/[tokenId]/Tabs/holders/Holders.tsx @@ -5,6 +5,7 @@ import { ReactNode, Suspense } from 'react'; import { AddressLink } from '../../../../../common/components/ExplorerLinks'; import { ListFooter } from '../../../../../common/components/ListFooter'; import { Section } from '../../../../../common/components/Section'; +import { mobileBorderCss } from '../../../../../common/constants/constants'; import { useSuspenseInfiniteQueryResult } from '../../../../../common/hooks/useInfiniteQueryResult'; import { useContractById } from '../../../../../common/queries/useContractById'; import { truncateMiddle } from '../../../../../common/utils/utils'; @@ -17,7 +18,6 @@ import { Th } from '../../../../../ui/Th'; import { Thead } from '../../../../../ui/Thead'; import { Tr } from '../../../../../ui/Tr'; import { ScrollableBox } from '../../../../_components/BlockList/ScrollableDiv'; -import { mobileBorderCss } from '../../../../_components/BlockList/consts'; import { ExplorerErrorBoundary } from '../../../../_components/ErrorBoundary'; import { TokenInfoProps } from '../../types'; import { HolderInfo, HolderResponseType, useSuspenseFtHolders } from '../data/useHolders'; diff --git a/src/common/components/pagination/PaginationControls.tsx b/src/common/components/pagination/PaginationControls.tsx new file mode 100644 index 000000000..9a0ff8c5c --- /dev/null +++ b/src/common/components/pagination/PaginationControls.tsx @@ -0,0 +1,101 @@ +import { Button, Flex, Icon, Text } from '@chakra-ui/react'; +import { CaretDoubleLeft, CaretDoubleRight, CaretLeft, CaretRight } from '@phosphor-icons/react'; +import React, { useState } from 'react'; + +import { Box } from '../../../ui/Box'; +import { Input } from '../../../ui/Input'; + +interface PaginationControlProps { + currentPage: number; + totalPages: number; + onNextPage: () => void; + onPreviousPage: () => void; +} + +export const PaginationControl: React.FC = ({ + currentPage, + totalPages, + onNextPage, + onPreviousPage, +}) => { + const [page, setPage] = useState(currentPage.toString()); + + return ( + + + + + + + + + + + + + + + + + + + + setPage(e.target.value)} + mr={2} + type="number" + /> + + of {totalPages} pages + + + + + + ); +}; diff --git a/src/common/components/pagination/withCursorPagination.tsx b/src/common/components/pagination/withCursorPagination.tsx new file mode 100644 index 000000000..432af8f94 --- /dev/null +++ b/src/common/components/pagination/withCursorPagination.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; + +import { Stack } from '../../../ui/Stack'; +import { TableProps } from '../table/Table'; +import { PaginationControl } from './PaginationControls'; + +interface CursorPaginationProps { + pageSize: number; + fetchNextPage: (cursor: string | null) => Promise<{ data: any[]; nextCursor: string | null }>; +} + +type CustomTableWithPaginationProps = TableProps & CursorPaginationProps; + +export function withCursorPagination(Component: React.ComponentType) { + return function WrappedComponent({ + data, + pageSize, + fetchNextPage, + ...props + }: CustomTableWithPaginationProps) { + const [currentPage, setCurrentPage] = useState(1); + const [paginatedData, setPaginatedData] = useState(data.slice(0, pageSize)); + const [nextCursor, setNextCursor] = useState(null); + + useEffect(() => { + setPaginatedData(data.slice(0, pageSize)); + }, [data, pageSize]); + + const handleNextPage = async () => { + if (nextCursor) { + const result = await fetchNextPage(nextCursor); + setPaginatedData(result.data); + setNextCursor(result.nextCursor); + setCurrentPage(currentPage + 1); + } + }; + + const handlePreviousPage = () => { + if (currentPage > 1) { + setPaginatedData(data.slice((currentPage - 2) * pageSize, (currentPage - 1) * pageSize)); + setCurrentPage(currentPage - 1); + } + }; + + return ( + + + + + ); + }; +} diff --git a/src/common/components/table/Table.tsx b/src/common/components/table/Table.tsx new file mode 100644 index 000000000..81318d680 --- /dev/null +++ b/src/common/components/table/Table.tsx @@ -0,0 +1,205 @@ +import { Tooltip } from '@/ui/Tooltip'; +import { useColorModeValue } from '@chakra-ui/react'; +import { ArrowDown, ArrowUp, ArrowsDownUp, Info } from '@phosphor-icons/react'; +import React, { Suspense } from 'react'; + +import { ExplorerErrorBoundary } from '../../../app/_components/ErrorBoundary'; +import { Box } from '../../../ui/Box'; +import { Flex } from '../../../ui/Flex'; +import { Icon } from '../../../ui/Icon'; +import { Td } from '../../../ui/Td'; +import { Text } from '../../../ui/Text'; +import { Th } from '../../../ui/Th'; +import { Tr } from '../../../ui/Tr'; +import { mobileBorderCss } from '../../constants/constants'; +import { TableLayout } from './TableLayout'; + +export const TableHeader = ({ + columnDefinition, + sortColumn, + sortDirection, + headerTitle, + isFirst, + onSort, +}: { + sortColumn?: string | null; + sortDirection?: 'asc' | 'desc'; + columnDefinition: ColumnDefinition; + headerTitle: string | React.ReactNode; + isFirst: boolean; + onSort?: (columnId: string, direction: 'asc' | 'desc') => void; +}) => { + const colorVal = useColorModeValue('slate.700', 'slate.250'); + + const sortIcon = columnDefinition.sortable ? ( + sortColumn !== columnDefinition.id ? ( + + ) : sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : null; + + return ( +
+ ); +}; + +export function TableRow({ + rowData, + columns, + rowIndex, + isFirst, + isLast, +}: { + rowData: any; + columns: ColumnDefinition[]; + rowIndex: number; + isFirst: boolean; + isLast: boolean; +}) { + return ( + + {columns.map((col, colIndex) => ( + + ))} + + ); +} + +export interface ColumnDefinition { + id: string; + header: string | React.ReactNode; + tooltip?: string; + accessor: (value: any) => any; + sortable?: boolean; + cellRenderer?: (value: any) => React.ReactNode; +} + +export interface TableProps { + title?: string; + topRight?: React.ReactNode; + topLeft?: React.ReactNode; + data: any[]; + columnDefinitions: ColumnDefinition[]; + onSort?: (columnId: string, direction: 'asc' | 'desc') => void; + sortColumn?: string | null; + sortDirection?: 'asc' | 'desc'; +} + +export function Table({ + title, + topRight, + topLeft, + data, + columnDefinitions: columns, + onSort, + sortColumn, + sortDirection, +}: TableProps) { + return ( + + Loading...}> + + + + ); +} diff --git a/src/common/components/table/TableLayout.tsx b/src/common/components/table/TableLayout.tsx new file mode 100644 index 000000000..0beedf3ab --- /dev/null +++ b/src/common/components/table/TableLayout.tsx @@ -0,0 +1,121 @@ +import { Box } from '@/ui/Box'; +import { Flex, FlexProps } from '@/ui/Flex'; +import { Stack, useColorModeValue } from '@chakra-ui/react'; +import styled from '@emotion/styled'; +import { ReactNode } from 'react'; + +import { ScrollableBox } from '../../../app/_components/BlockList/ScrollableDiv'; +import { Table } from '../../../ui/Table'; +import { Tbody } from '../../../ui/Tbody'; +import { Text } from '../../../ui/Text'; +import { Thead } from '../../../ui/Thead'; +import { Card } from '../Card'; +import { TableHeader, TableProps, TableRow } from './Table'; + +const StyledTable = styled(Table)` + tr td { + border-bottom: none; + } +`; + +function TableContainerHeader({ + topLeft, + topRight, + title, +}: { + topLeft?: string | ReactNode; + topRight?: ReactNode; + title?: string | ReactNode; +}) { + const titleColor = useColorModeValue('slate.900', 'white'); + + if (!title && !topLeft && !topRight) { + return null; + } + + return ( + + + {title ? ( + + {title} + + ) : topLeft ? ( + topLeft + ) : null} + + {topRight && ( + + {topRight} + + )} + + ); +} + +interface TableContainerProps extends FlexProps { + topLeft?: ReactNode; + topRight?: ReactNode; + title?: string; +} + +function TableContainer({ topLeft, topRight, title, children, ...rest }: TableContainerProps) { + return ( + + + + {children} + + + ); +} + +export function TableLayout({ + title, + data, + columnDefinitions: columns, + onSort, + sortColumn, + sortDirection, + topRight, +}: TableProps) { + return ( + + + + + {columns?.map((col, colIndex) => ( + + ))} + + + {data?.map((rowData, rowIndex) => ( + + ))} + + + + + ); +} diff --git a/src/common/components/table/TableSkeleton.tsx b/src/common/components/table/TableSkeleton.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/common/components/table/TableWithCursorPagination.tsx b/src/common/components/table/TableWithCursorPagination.tsx new file mode 100644 index 000000000..32cc41c26 --- /dev/null +++ b/src/common/components/table/TableWithCursorPagination.tsx @@ -0,0 +1,4 @@ +import { withCursorPagination } from '../pagination/withCursorPagination'; +import { Table } from './Table'; + +export const CustomTableWithCursorPagination = withCursorPagination(Table); diff --git a/src/common/constants/constants.ts b/src/common/constants/constants.ts index da50bbbef..d8b238cfc 100644 --- a/src/common/constants/constants.ts +++ b/src/common/constants/constants.ts @@ -62,3 +62,15 @@ export const SUBNETS_PARENT_NETWORK_IDS = { }; export const PAGE_MAX_WIDTH = '1280px'; + +export const mobileBorderCss = { + '.has-horizontal-scroll &': { + borderRight: '2px solid var(--stacks-colors-borderPrimary)', + }, +}; + +export const NUM_TEN_MINUTES_IN_DAY = (24 * 60) / 10; // ie approx number of 10 minute blocks in a day + +export const NUM_SECONDS_IN_A_DAY = 24 * 60 * 60; + +export const NUM_SECONDS_IN_TEN_MINUTES = 10 * 60; diff --git a/src/common/queries/useAddressBurnchainRewards.ts b/src/common/queries/useAddressBurnchainRewards.ts new file mode 100644 index 000000000..7ca364b22 --- /dev/null +++ b/src/common/queries/useAddressBurnchainRewards.ts @@ -0,0 +1,40 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { useGlobalContext } from '../context/useGlobalContext'; + +const ADDRESS_BURNCHAIN_REWARDS_QUERY_KEY = 'address-burnchain-rewards'; + +// TODO: Update StacksJS to support fetching burnchain rewards +export interface AddressBurnchainRewards { + reward_recipient: 'string'; + reward_amount: 'string'; +} + +export function useGetAddressBurnChainRewardsQuery() { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return (poxAddress: string) => ({ + queryKey: [ADDRESS_BURNCHAIN_REWARDS_QUERY_KEY, poxAddress], + queryFn: () => + fetch(`${activeNetworkUrl}/extended/v1/burnchain/rewards/${poxAddress}/total`).then(res => + res.json() + ), + staleTime: Infinity, + refetchOnWindowFocus: false, + }); +} + +export const useSuspenseAddressBurnchainRewards = (poxAddress: string) => { + // TODO: check that the address is a valid btc address + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useSuspenseQuery({ + queryKey: [ADDRESS_BURNCHAIN_REWARDS_QUERY_KEY, poxAddress], + queryFn: () => + fetch(`${activeNetworkUrl}/extended/v1/burnchain/rewards/${poxAddress}/total`).then(res => + res.json() + ), + staleTime: Infinity, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/stories/Card.stories.tsx b/src/stories/Card.stories.tsx new file mode 100644 index 000000000..182b2749f --- /dev/null +++ b/src/stories/Card.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Card } from '../common/components/Card'; +import { Text } from '../ui/Text'; + +const meta: Meta = { + title: 'Components/Card', + component: Card, + parameters: { + layout: 'centered', + }, + argTypes: { + width: { control: 'text' }, + height: { control: 'text' }, + padding: { control: 'text' }, + gap: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Basic card with text +export const Default: Story = { + args: { + width: '300px', + padding: '4', + children: This is a basic card component with some sample text content., + }, +}; diff --git a/src/stories/Table.stories.tsx b/src/stories/Table.stories.tsx new file mode 100644 index 000000000..b293381d1 --- /dev/null +++ b/src/stories/Table.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ColumnDefinition, Table } from '../common/components/table/Table'; +import { Box } from '../ui/Box'; + +const meta: Meta = { + title: 'Components/Table', + component: Table, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +// Sample data +const sampleData = [ + ['John Doe', 'john@example.com', 30, 'Active'], + ['Jane Smith', 'jane@example.com', 25, 'Inactive'], + ['Bob Johnson', 'bob@example.com', 35, 'Active'], + ['Alice Brown', 'alice@example.com', 28, 'Active'], +]; + +const columns: ColumnDefinition[] = [ + { + id: 'name', + header: 'Name', + accessor: row => row, + sortable: true, + }, + { + id: 'email', + header: 'Email', + accessor: row => row, + sortable: true, + }, + { + id: 'age', + header: 'Age', + accessor: row => row, + sortable: true, + }, + { + id: 'status', + header: 'Status', + accessor: row => row, + cellRenderer: value => ( + + {value} + + ), + }, +]; + +export const Default: Story = { + args: { + title: 'Users Table', + data: sampleData, + columnDefinitions: columns, + }, +}; + +export const WithSorting: Story = { + args: { + ...Default.args, + sortColumn: 'name', + sortDirection: 'asc', + onSort: (columnId, direction) => { + console.log(`Sorting ${columnId} in ${direction} direction`); + }, + }, +}; + +export const WithTopRight: Story = { + args: { + ...Default.args, + topRight: ( + + Custom Top Right Content + + ), + }, +}; + +export const EmptyTable: Story = { + args: { + ...Default.args, + data: [], + }, +}; + +export const LongContent: Story = { + args: { + ...Default.args, + data: Array(20).fill(sampleData[0]), + }, +}; diff --git a/src/ui/icons/BitcoinLogo.tsx b/src/ui/icons/BitcoinLogo.tsx new file mode 100644 index 000000000..b5f65e174 --- /dev/null +++ b/src/ui/icons/BitcoinLogo.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Icon, IconBase, IconWeight } from '@phosphor-icons/react'; +import { ReactElement, forwardRef } from 'react'; + +const weights = new Map([ + [ + 'regular', + + + , + ], +]); + +const BitcoinLogo: Icon = forwardRef((props, ref) => ( + +)); + +BitcoinLogo.displayName = 'BitcoinLogo'; + +export default BitcoinLogo; diff --git a/src/ui/icons/BitcoinPriceTagFragment.tsx b/src/ui/icons/BitcoinPriceTagFragment.tsx new file mode 100644 index 000000000..dc1467c8a --- /dev/null +++ b/src/ui/icons/BitcoinPriceTagFragment.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export const BitcoinPriceTagFragmentLight = () => ( + + + + + + + + + + +); + +export default BitcoinPriceTagFragmentLight; \ No newline at end of file diff --git a/src/ui/theme/colors.ts b/src/ui/theme/colors.ts index dc2be04fb..26c7dfc16 100644 --- a/src/ui/theme/colors.ts +++ b/src/ui/theme/colors.ts @@ -56,6 +56,17 @@ export const COLORS = { lilac: '#9985FF', limeGreen: '#C1D21B', }, + sand: { + 50: '#F7F6F5', + 150: '#EAE8E6', + 175: '#D8D6D3', + 200: '#D5D3D1', + 400: '#95918C', + 500: '#777470', + 600: '#595754', + 800: '#2D2C2A', + 1000: '#0C0C0D', + }, }; export const NEW_COLORS = { diff --git a/src/ui/theme/theme.ts b/src/ui/theme/theme.ts index c93a14e87..4cd6ba78d 100644 --- a/src/ui/theme/theme.ts +++ b/src/ui/theme/theme.ts @@ -33,6 +33,7 @@ export const theme = extendTheme({ colors: { ...COLORS, ...NEW_COLORS }, semanticTokens: { colors: { + bitcoinOrange: '#FF9835', brand: '#FC6432', borderPrimary: { default: 'slate.250',
+ {typeof headerTitle === 'string' ? ( // TODO: why not also use a custom renderer + + + {headerTitle} + + {columnDefinition.tooltip && ( + + + + )} + {sortIcon && ( + { + onSort?.(columnDefinition.id, sortDirection === 'asc' ? 'desc' : 'asc'); + }} + p={1} + bg="sand.150" + borderRadius="md" + > + + {sortIcon} + + + )} + + ) : ( + headerTitle + )} +
+ {col.cellRenderer ? ( + col.cellRenderer(col.accessor(rowData[colIndex])) + ) : ( + + {col.accessor(rowData[colIndex])} + + )} +