diff --git a/campaign-launcher/client/package.json b/campaign-launcher/client/package.json index 6e80cea87..f04d54683 100644 --- a/campaign-launcher/client/package.json +++ b/campaign-launcher/client/package.json @@ -28,14 +28,11 @@ "@tanstack/react-query": "^5.90.21", "@walletconnect/ethereum-provider": "^2.23.5", "axios": "^1.13.2", - "chart.js": "^4.5.1", - "chartjs-plugin-annotation": "^3.1.0", "dayjs": "^1.11.19", "ethers": "~6.16.0", "jwt-decode": "^4.0.0", "notistack": "^3.0.2", "react": "^19.2.4", - "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.4", "react-hook-form": "^7.68.0", "react-number-format": "^5.4.4", diff --git a/campaign-launcher/client/src/api/recordingApiClient.ts b/campaign-launcher/client/src/api/recordingApiClient.ts index 13d4ce4ef..0e0072828 100644 --- a/campaign-launcher/client/src/api/recordingApiClient.ts +++ b/campaign-launcher/client/src/api/recordingApiClient.ts @@ -12,6 +12,7 @@ import type { UserProgress, CheckCampaignJoinStatusResponse, JoinedCampaignsResponse, + LeaderboardResponseDto, } from '@/types'; import { HttpClient, HttpError } from '@/utils/HttpClient'; import type { TokenData, TokenManager } from '@/utils/TokenManager'; @@ -219,4 +220,14 @@ export class RecordingApiClient extends HttpClient { return response || null; } + + async getLeaderboard( + chain_id: ChainId, + campaign_address: string + ): Promise { + const response = await this.get( + `/campaigns/${chain_id}-${campaign_address}/leaderboard` + ); + return response; + } } diff --git a/campaign-launcher/client/src/components/CampaignAddress/index.tsx b/campaign-launcher/client/src/components/CampaignAddress/index.tsx index 077c35afc..bfb22d0c8 100644 --- a/campaign-launcher/client/src/components/CampaignAddress/index.tsx +++ b/campaign-launcher/client/src/components/CampaignAddress/index.tsx @@ -24,8 +24,6 @@ const AddressLink: FC = ({ address, chainId, size }) => { size === 'small' ? '12px' : size === 'medium' ? '14px' : '16px', color: 'text.primary', textDecoration: 'underline', - textDecorationStyle: 'dotted', - textDecorationThickness: '12%', fontWeight: 600, }} > diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 99ede330c..c2b5ed287 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -1,21 +1,26 @@ -import { useState, type FC } from 'react'; +import { type FC } from 'react'; -import { Box, Button, Skeleton, Stack, Typography } from '@mui/material'; +import { + Box, + Divider as MuiDivider, + Skeleton, + Stack, + styled, + Typography, +} from '@mui/material'; import CampaignAddress from '@/components/CampaignAddress'; import CampaignStatusLabel from '@/components/CampaignStatusLabel'; -import CampaignTypeLabel from '@/components/CampaignTypeLabel'; import CustomTooltip from '@/components/CustomTooltip'; import JoinCampaignButton from '@/components/JoinCampaignButton'; -import ChartModal from '@/components/modals/ChartModal'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import { CalendarIcon } from '@/icons'; -import type { CampaignDetails, CampaignJoinStatus } from '@/types'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; +import type { CampaignDetails } from '@/types'; import { getChainIcon, getNetworkName } from '@/utils'; import dayjs from '@/utils/dayjs'; const formatDate = (dateString: string): string => { - return dayjs(dateString).format('D MMM YYYY'); + return dayjs(dateString).format('Do MMM YYYY'); }; const formatTime = (dateString: string): string => { @@ -23,114 +28,242 @@ const formatTime = (dateString: string): string => { return date.format('HH:mm [GMT]Z'); }; +const formatJoinTime = (dateString: string): string => { + return dayjs(dateString).format('HH:mm'); +}; + +const DividerStyled = styled(MuiDivider)({ + borderColor: 'rgba(255, 255, 255, 0.3)', + height: 16, + alignSelf: 'center', +}); + type Props = { campaign: CampaignDetails | null | undefined; isCampaignLoading: boolean; - joinStatus?: CampaignJoinStatus; - joinedAt?: string; + isJoined: boolean; + joinedAt: string | undefined; isJoinStatusLoading: boolean; }; -const CampaignInfo: FC = ({ campaign, isCampaignLoading }) => { - const [isChartModalOpen, setIsChartModalOpen] = useState(false); - +const CampaignInfo: FC = ({ + campaign, + isCampaignLoading, + isJoined, + joinedAt, + isJoinStatusLoading, +}) => { const isMobile = useIsMobile(); + const { activeAddress } = useActiveAccount(); + + const isHosted = campaign?.launcher === activeAddress; if (isCampaignLoading) { - if (!isMobile) return null; + if (isMobile) { + return ( + + + + {isJoined && ( + + )} + + ); + } return ( - - - - + + + + {isJoined && ( + + )} ); } if (!campaign) return null; + const oracleFee = + campaign.exchange_oracle_fee_percent + + campaign.recording_oracle_fee_percent + + campaign.reputation_oracle_fee_percent; + return ( - - - - {isMobile && } + + Campaign Details + + + + {!isMobile && } + - + + + svg': { fontSize: { xs: '12px', md: '16px' } } }} + > + {getChainIcon(campaign.chain_id)} + + + {getNetworkName(campaign.chain_id)?.slice(0, 3)} + + + - - + + {(isJoined || isHosted) && ( + <> + + {isJoined ? 'Joined' : 'Hosted'} + + + + )} - - + {formatDate(campaign.start_date)} - - - + + > - + {formatDate(campaign.end_date)} - + - {getChainIcon(campaign.chain_id)} - + {oracleFee}% Oracle fees + - {!isMobile && ( - - - setIsChartModalOpen(false)} - campaign={campaign} - /> + Joined at + + + {' '} + {formatDate(joinedAt)} + {', '} + {formatJoinTime(joinedAt)} + )} - + ); }; diff --git a/campaign-launcher/client/src/components/CampaignResultsSection/index.tsx b/campaign-launcher/client/src/components/CampaignResultsSection/index.tsx new file mode 100644 index 000000000..88132e70f --- /dev/null +++ b/campaign-launcher/client/src/components/CampaignResultsSection/index.tsx @@ -0,0 +1,104 @@ +import { type FC } from 'react'; + +import { Box, Link, Stack, Typography } from '@mui/material'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { OpenInNewIcon } from '@/icons'; +import { CampaignStatus, type Campaign } from '@/types'; + +const RESULT = { + none: { + label: 'N/A', + color: '#a0a0a0', + }, + intermediate: { + label: 'Intermediate', + color: 'warning.main', + }, + final: { + label: 'Final', + color: 'success.main', + }, +}; + +type ResultType = typeof RESULT; + +type Props = { + campaign: Campaign; +}; + +const CampaignResultsSection: FC = ({ campaign }) => { + const isMobile = useIsMobile(); + const { final_results_url, intermediate_results_url, status } = campaign; + + const isFinished = [ + CampaignStatus.CANCELLED, + CampaignStatus.COMPLETED, + ].includes(status); + + let result: ResultType[keyof ResultType]; + let resultUrl: string; + + if (isFinished && final_results_url) { + result = RESULT.final; + resultUrl = final_results_url; + } else if (!isFinished && intermediate_results_url) { + result = RESULT.intermediate; + resultUrl = intermediate_results_url; + } else { + result = RESULT.none; + resultUrl = ''; + } + + return ( + + + + Previous Cycles Results History + + + + + {result.label} + + {!!resultUrl && ( + + + + )} + + + + ); +}; + +export default CampaignResultsSection; diff --git a/campaign-launcher/client/src/components/CampaignResultsWidget/index.tsx b/campaign-launcher/client/src/components/CampaignResultsWidget/index.tsx deleted file mode 100644 index c67ec0ec1..000000000 --- a/campaign-launcher/client/src/components/CampaignResultsWidget/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { FC, MouseEvent } from 'react'; - -import { Box, IconButton, Stack, Typography } from '@mui/material'; - -import { useIsMobile } from '@/hooks/useBreakpoints'; -import { OpenInNewIcon } from '@/icons'; -import { CampaignStatus } from '@/types'; - -type Props = { - campaignStatus: CampaignStatus; - finalResultsUrl: string | null; - intermediateResultsUrl: string | null; -}; - -const handleOpenUrl = (e: MouseEvent, url: string) => { - e.stopPropagation(); - window.open(url, '_blank'); -}; - -const RESULT = { - none: { - label: 'N/A', - description: 'No results have been recorded for campaign.', - bgcolor: 'error.main', - }, - intermediate: { - label: 'Intermediate', - description: 'Campaign is active. Results show progress so far.', - bgcolor: 'warning.main', - }, - final: { - label: 'Final', - description: - 'Campaign has ended. These are the final results of the campaign.', - bgcolor: 'success.main', - }, -}; -type ResultType = typeof RESULT; - -export const StatusTooltip = () => ( - - - - - Final: {RESULT.final.description} - - - - - - Intermediate: {RESULT.intermediate.description} - - - - - - N/A: {RESULT.none.description} - - - -); - -const CampaignResultsWidget: FC = ({ - campaignStatus, - finalResultsUrl, - intermediateResultsUrl, -}) => { - const isMobile = useIsMobile(); - - const isFinished = [ - CampaignStatus.CANCELLED, - CampaignStatus.COMPLETED, - ].includes(campaignStatus); - - let result: ResultType[keyof ResultType]; - let resultUrl: string; - - if (isFinished && finalResultsUrl) { - result = RESULT.final; - resultUrl = finalResultsUrl; - } else if (!isFinished && intermediateResultsUrl) { - result = RESULT.intermediate; - resultUrl = intermediateResultsUrl; - } else { - result = RESULT.none; - resultUrl = ''; - } - - return ( - - - - {result.label} - - {result !== RESULT.none && ( - handleOpenUrl(e, resultUrl || '')} - > - - - )} - - ); -}; - -export default CampaignResultsWidget; diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 4b63b5f05..941099fbd 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -1,194 +1,144 @@ -import { type FC, type PropsWithChildren, Children, useState } from 'react'; +import { type FC } from 'react'; -import { Box, Button, Skeleton, styled, Typography, Grid } from '@mui/material'; +import { Box, Skeleton, Stack, styled, Typography, Grid } from '@mui/material'; -import CampaignResultsWidget, { - StatusTooltip, -} from '@/components/CampaignResultsWidget'; import CampaignSymbol from '@/components/CampaignSymbol'; -import CustomTooltip from '@/components/CustomTooltip'; import FormattedNumber from '@/components/FormattedNumber'; -import InfoTooltipInner from '@/components/InfoTooltipInner'; -import UserProgressWidget from '@/components/UserProgressWidget'; -import { useIsXlDesktop, useIsMobile } from '@/hooks/useBreakpoints'; -import { ChartIcon } from '@/icons'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { CancelIcon } from '@/icons'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; import { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import { CampaignStatus, CampaignType, type CampaignDetails } from '@/types'; import { - formatTokenAmount, + CampaignStatus, + type LeaderboardData, + type CampaignDetails, + CampaignType, +} from '@/types'; +import { + getCompactNumberParts, getDailyTargetTokenSymbol, + getTargetInfo, getTokenInfo, + mapTypeToLabel, } from '@/utils'; +import dayjs from '@/utils/dayjs'; -import ChartModal from '../modals/ChartModal'; - -type Props = { - campaign: CampaignDetails | null | undefined; - isJoined: boolean; - isCampaignLoading: boolean; -}; - -const StatsCard = styled(Box)(({ theme }) => ({ +export const StatsCard = styled(Box, { + shouldForwardProp: (prop) => prop !== 'withBorder', +})<{ withBorder?: boolean }>(({ theme, withBorder }) => ({ display: 'flex', flexDirection: 'column', - height: '216px', - padding: '16px 32px', - backgroundColor: theme.palette.background.default, - borderRadius: '16px', - border: '1px solid rgba(255, 255, 255, 0.1)', - - [theme.breakpoints.down('xl')]: { - height: 'unset', - minHeight: '125px', - justifyContent: 'space-between', - padding: '16px', - }, + justifyContent: 'start', + height: '175px', + padding: '32px', + flex: 1, + gap: '45px', + ...(withBorder && { + backgroundColor: '#251D47', + borderRadius: '16px', + border: '1px solid rgba(255, 255, 255, 0.1)', + }), [theme.breakpoints.down('md')]: { - height: 'unset', - minHeight: '125px', + height: 'auto', + minHeight: '90px', + padding: '12px', + gap: '8px', + ...(withBorder && { + borderRadius: '8px', + }), }, })); -const Title = styled(Typography)(({ theme }) => ({ - color: theme.palette.text.primary, - marginBottom: '56px', - textTransform: 'capitalize', - - [theme.breakpoints.down('xl')]: { - marginBottom: '16px', - }, +export const CardName = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: '16px', + fontWeight: 600, + lineHeight: '18px', + letterSpacing: '1.5px', + textTransform: 'uppercase', [theme.breakpoints.down('md')]: { - marginBottom: 'auto', + fontSize: '14px', + fontWeight: 400, + lineHeight: '150%', + letterSpacing: '0px', + textTransform: 'none', }, })); -const FlexGrid = styled(Box)(({ theme }) => ({ - display: 'flex', - flexWrap: 'wrap', - gap: '16px', - width: '100%', - '& > *': { - flexBasis: 'calc(50% - 8px)', - }, +export const CardValue = styled(Typography, { + shouldForwardProp: (prop) => prop !== 'color', +})<{ color?: string }>(({ theme, color = 'white' }) => ({ + color, + fontSize: '36px', + fontWeight: 800, + lineHeight: '100%', [theme.breakpoints.down('md')]: { - gap: '8px', + fontSize: '20px', + fontWeight: 500, + lineHeight: '150%', }, })); const now = new Date().toISOString(); -const FirstRowWrapper: FC< - PropsWithChildren<{ - showProgressWidget: boolean; - }> -> = ({ showProgressWidget, children }) => { - if (showProgressWidget) { - return ( - - {children} - - ); - } - +const renderSkeletonBlocks = (isMobile: boolean) => { return ( - <> - {Children.map(children, (child) => ( - {child} - ))} - + + + + + ); }; -const getDailyTargetCardLabel = (campaignType: CampaignType) => { - switch (campaignType) { - case CampaignType.MARKET_MAKING: - return 'Daily volume target'; - case CampaignType.HOLDING: - return 'Daily balance target'; - case CampaignType.THRESHOLD: - return 'Minimum balance target'; - default: - return campaignType as never; - } -}; - -const getDailyTargetValue = (campaign: CampaignDetails) => { - switch (campaign.type) { - case CampaignType.MARKET_MAKING: - return campaign.details.daily_volume_target; - case CampaignType.HOLDING: - return campaign.details.daily_balance_target; - case CampaignType.THRESHOLD: - return campaign.details.minimum_balance_target; - default: - return 0; - } +const formatCancellationRequestedAt = (date: number) => { + return dayjs(date).format('Do MMM YYYY HH:mm'); }; -const renderProgressWidget = (campaign: CampaignDetails) => ( - - - - - -); - -const renderSkeletonBlocks = () => { - const row = Array(4).fill(0); - return ( - <> - - {row.map((_, index) => ( - - - - - - - ))} - - - {row.map((_, index) => ( - - - - - - - ))} - - - ); +type Props = { + campaign: CampaignDetails | null | undefined; + isJoined: boolean; + isCampaignLoading: boolean; + leaderboard?: LeaderboardData; }; const CampaignStats: FC = ({ campaign, isJoined, isCampaignLoading, + leaderboard, }) => { - const [isChartModalOpen, setIsChartModalOpen] = useState(false); - const { exchangesMap } = useExchangesContext(); - const isXl = useIsXlDesktop(); - const isMobile = useIsMobile(); const { isAuthenticated } = useWeb3Auth(); + const { activeAddress } = useActiveAccount(); + const isMobile = useIsMobile(); - if (isCampaignLoading) return renderSkeletonBlocks(); + if (isCampaignLoading) return renderSkeletonBlocks(isMobile); if (!campaign) return null; + const isCancelled = campaign.status === CampaignStatus.CANCELLED; + const isThresholdCampaign = campaign.type === CampaignType.THRESHOLD; + const isOngoingCampaign = campaign.status === CampaignStatus.ACTIVE && now >= campaign.start_date && @@ -198,193 +148,146 @@ const CampaignStats: FC = ({ campaign.status === CampaignStatus.TO_CANCEL && campaign.reserved_funds !== campaign.balance; - const showProgressWidget = + const totalParticipants = leaderboard?.data.length || 0; + + const userRank = leaderboard?.data.find( + (entry) => entry.address === activeAddress + )?.rank; + + const showUserPerformance = isAuthenticated && isJoined && + !isThresholdCampaign && + !!userRank && (isOngoingCampaign || hasProgressBeforeCancel); const exchangeName = exchangesMap.get(campaign.exchange_name)?.display_name || campaign.exchange_name; - const totalFee = - campaign.exchange_oracle_fee_percent + - campaign.recording_oracle_fee_percent + - campaign.reputation_oracle_fee_percent; - - const formattedTokenAmount = +formatTokenAmount( - campaign.fund_amount, - campaign.fund_token_decimals - ); - - const formattedAmountPaid = +formatTokenAmount( - campaign.amount_paid, - campaign.fund_token_decimals - ); - - const formattedReservedFunds = +formatTokenAmount( - campaign.reserved_funds, - campaign.fund_token_decimals - ); + const targetInfo = getTargetInfo(campaign); + const { + value: targetValue, + suffix: targetSuffix, + decimals: targetDecimals, + } = getCompactNumberParts(targetInfo.value || 0); const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); const { label: targetTokenSymbol } = getTokenInfo(targetToken); return ( - <> - - {showProgressWidget && isMobile && renderProgressWidget(campaign)} - - - Total Funded Amount - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - Amount Paid - - {formattedAmountPaid} {campaign.fund_token_symbol} - - - - Oracle fees - - {' '} - {campaign.fund_token_symbol}{' '} - - ({totalFee}%) - - - - - - {getDailyTargetCardLabel(campaign.type)} - + + + Details + + {isCancelled && ( + + + - {' '} - {targetTokenSymbol} + Campaign Cancelled - - - {showProgressWidget && !isMobile && renderProgressWidget(campaign)} - - - + + + Cancelled on{' '} + {formatCancellationRequestedAt( + campaign.cancellation_requested_at || 0 + )} + + + )} + + - Reserved funds - - {formattedReservedFunds} {campaign.fund_token_symbol} - + Symbol + - + - - Campaign results - <CustomTooltip - title={<StatusTooltip />} - arrow - placement={isMobile ? 'left' : 'top'} - > - <InfoTooltipInner /> - </CustomTooltip> - - + Exchange + {exchangeName} - + - Exchange - - {exchangeName} - + Campaign Type + {mapTypeToLabel(campaign.type)} - - - Symbol - + + + + + {targetInfo.label} + + + + {isOngoingCampaign && ( + + + + {showUserPerformance ? 'Ranking' : 'Total Participants'} + + + {showUserPerformance + ? `${userRank} / ${totalParticipants}` + : totalParticipants} + + + + )} - {isMobile && ( - <> - - setIsChartModalOpen(false)} - campaign={campaign} - /> - - )} - + ); }; diff --git a/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx b/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx index 68d0ee7df..212d21e01 100644 --- a/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx @@ -1,9 +1,56 @@ import { type FC } from 'react'; -import { Chip } from '@mui/material'; +import { Box, Typography } from '@mui/material'; -import { CampaignStatus } from '@/types'; -import { mapStatusToColor } from '@/utils'; +import { type Campaign, CampaignStatus } from '@/types'; + +export const mapStatusToColorAndText = ( + status: Campaign['status'], + startDate: string, + endDate: string +) => { + const now = new Date().toISOString(); + + switch (status) { + case CampaignStatus.ACTIVE: + if (now < startDate) { + return { + color: '#b78608', + text: 'Awaiting start date', + }; + } else if (now > endDate) { + return { + color: '#5596ff', + text: 'Waiting for payouts', + }; + } else { + return { + color: '#1a926e', + text: 'Active', + }; + } + case CampaignStatus.COMPLETED: + return { + color: '#d4cfff', + text: 'Ended', + }; + case CampaignStatus.CANCELLED: + return { + color: '#da4c4f', + text: 'Cancelled', + }; + case CampaignStatus.TO_CANCEL: + return { + color: '#da4c4f', + text: 'Pending cancellation', + }; + default: + return { + color: '#d4cfff', + text: 'Unknown', + }; + } +}; type Props = { campaignStatus: CampaignStatus; @@ -16,29 +63,30 @@ const CampaignStatusLabel: FC = ({ startDate, endDate, }) => { - const isCompleted = campaignStatus === CampaignStatus.COMPLETED; + const { color, text } = mapStatusToColorAndText( + campaignStatus, + startDate, + endDate + ); return ( - .MuiChip-label': { - py: 0, - px: 2, - color: isCompleted ? 'secondary.contrast' : 'primary.contrast', - fontSize: 14, - fontWeight: 600, - lineHeight: '24px', - letterSpacing: '0.1px', - }, - }} - /> + + + + {text} + + ); }; diff --git a/campaign-launcher/client/src/components/CampaignTypeLabel/index.tsx b/campaign-launcher/client/src/components/CampaignTypeLabel/index.tsx deleted file mode 100644 index 4b3f9ed7d..000000000 --- a/campaign-launcher/client/src/components/CampaignTypeLabel/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { FC } from 'react'; - -import { Chip } from '@mui/material'; - -import type { CampaignType } from '@/types'; -import { mapTypeToLabel } from '@/utils'; - -type Props = { - campaignType: CampaignType; -}; - -const CampaignTypeLabel: FC = ({ campaignType }) => { - return ( - .MuiChip-label': { - py: 0, - px: 2, - color: 'secondary.contrast', - fontSize: 14, - fontWeight: 600, - lineHeight: '24px', - letterSpacing: '0.1px', - }, - }} - /> - ); -}; - -export default CampaignTypeLabel; diff --git a/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx b/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx new file mode 100644 index 000000000..eba55484b --- /dev/null +++ b/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx @@ -0,0 +1,153 @@ +import { useCallback, useState, type FC } from 'react'; + +import { EscrowClient } from '@human-protocol/sdk'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; + +import { ModalLoading, ModalSuccess } from '@/components/ModalState'; +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { QUERY_KEYS } from '@/constants/queryKeys'; +import { useNotification } from '@/hooks/useNotification'; +import { WarningIcon } from '@/icons'; +import { useSignerContext } from '@/providers/SignerProvider'; +import { type Campaign } from '@/types'; + +type Props = { + open: boolean; + onClose: () => void; + campaign: Campaign; +}; + +// TODO: Update the copy +const CancelCampaignDialog: FC = ({ open, onClose, campaign }) => { + const [isCancelling, setIsCancelling] = useState(false); + const [isCancelled, setIsCancelled] = useState(false); + + const { isSignerReady, signer } = useSignerContext(); + const { showError } = useNotification(); + const queryClient = useQueryClient(); + + const isIdle = !isCancelling && !isCancelled; + + const handleCancelCampaign = async () => { + if (!isSignerReady || isCancelling) return; + + setIsCancelling(true); + try { + const client = await EscrowClient.build(signer); + await client.requestCancellation(campaign.address); + setIsCancelled(true); + queryClient.invalidateQueries({ + queryKey: [ + QUERY_KEYS.CAMPAIGN_DETAILS, + campaign.chain_id, + campaign.address, + ], + }); + } catch (error) { + console.error('Failed to cancel campaign', error); + showError('Failed to cancel campaign'); + } finally { + setIsCancelling(false); + } + }; + + const handleClose = useCallback(() => { + if (isCancelled) { + setIsCancelled(false); + } + onClose(); + }, [onClose, isCancelled]); + + return ( + + + + + Cancel Campaign + + {isCancelling && } + {isCancelled && ( + + + {/* TODO: we may need to update the docs and reference to the docs here */} + + Cancellation successfully requested. Updates might take a while. + + + + )} + {isIdle && ( + + Canceling this campaign will sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation + + )} + + + {isCancelled && ( + + )} + {!isCancelled && ( + + )} + + + ); +}; + +export default CancelCampaignDialog; diff --git a/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx new file mode 100644 index 000000000..0523e5875 --- /dev/null +++ b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx @@ -0,0 +1,69 @@ +import { useState, type FC } from 'react'; + +import { Box, Button, Typography } from '@mui/material'; + +import { useSignerContext } from '@/providers/SignerProvider'; +import { CampaignStatus, type Campaign } from '@/types'; + +import CancelCampaignDialog from './CancelCampaignDialog'; + +type Props = { + campaign: Campaign; +}; + +// TODO: Update the copy +const CancelCampaignSection: FC = ({ campaign }) => { + const [openDialog, setOpenDialog] = useState(false); + + const { signer } = useSignerContext(); + + const showSection = + campaign.status === CampaignStatus.ACTIVE && + campaign.launcher === signer?.address; + + return ( + <> + {showSection && ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + + + + )} + setOpenDialog(false)} + campaign={campaign} + /> + + ); +}; + +export default CancelCampaignSection; diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx new file mode 100644 index 000000000..5e39e60f9 --- /dev/null +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -0,0 +1,194 @@ +import { type FC, useEffect, useMemo, useState } from 'react'; + +import { Box, Grid, Stack, Typography } from '@mui/material'; + +import { CardName, CardValue, StatsCard } from '@/components/CampaignStats'; +import FormattedNumber from '@/components/FormattedNumber'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { CampaignType, type LeaderboardData, type Campaign } from '@/types'; +import { + formatTokenAmount, + getCompactNumberParts, + getDailyTargetTokenSymbol, + getTokenInfo, +} from '@/utils'; +import dayjs from '@/utils/dayjs'; + +type Props = { + campaign: Campaign; + leaderboard: LeaderboardData; +}; + +const useCycleTimeline = (startDate: string, endDate: string) => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => { + setNow(Date.now()); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, []); + + const cycleTimeInfo = useMemo(() => { + const start = dayjs(startDate); + const end = dayjs(endDate); + const nowDate = dayjs(now); + + const totalCycles = Math.max(1, Math.ceil(end.diff(start, 'day', true))); + const fullCyclesPassed = nowDate.diff(start, 'day', false); + const currentCycle = Math.min(totalCycles, fullCyclesPassed + 1); + const cycleEndCandidate = start.add(currentCycle, 'day'); + const currentCycleEnd = cycleEndCandidate.isBefore(end) + ? cycleEndCandidate + : end; + const remainingMs = Math.max(0, currentCycleEnd.diff(nowDate)); + + return { + currentCycle, + totalCycles, + remainingTime: dayjs + .duration(Math.max(0, remainingMs)) + .format('HH[h]:mm[m]:ss[s]'), + }; + }, [startDate, endDate, now]); + + return cycleTimeInfo; +}; + +const getTotalGeneratedCardTitle = (campaignType: CampaignType) => { + switch (campaignType) { + case CampaignType.MARKET_MAKING: + return 'Total Generated Volume'; + case CampaignType.HOLDING: + return 'Total Generated Balance'; + } +}; + +const CycleInfoSection: FC = ({ campaign, leaderboard }) => { + const isMobile = useIsMobile(); + + const isThreshold = campaign.type === CampaignType.THRESHOLD; + + const cycleTimeline = useCycleTimeline( + campaign.start_date, + campaign.end_date + ); + const rewardPool = +formatTokenAmount( + campaign.fund_amount, + campaign.fund_token_decimals + ); + + const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); + const { label: targetTokenSymbol } = getTokenInfo(targetToken); + + const { + value: totalGeneratedValue, + suffix: totalGeneratedSuffix, + decimals: totalGeneratedDecimals, + } = getCompactNumberParts(leaderboard.total); + + const eligibleParticipants = leaderboard.data.filter( + (entry) => entry.score > 0 + ); + + return ( + + + + Cycle Info + + + + {`Cycle ${cycleTimeline.currentCycle} of ${cycleTimeline.totalCycles}`} + + + + Resets every 24h + + + + + + + Cycle Reward Pool + + + + + + + + Ends in + + {cycleTimeline.remainingTime} + + + + + + + {isThreshold + ? 'Eligible Participants' + : getTotalGeneratedCardTitle(campaign.type)} + + + {isThreshold ? ( + eligibleParticipants.length + ) : ( + + )} + + + + + + ); +}; + +export default CycleInfoSection; diff --git a/campaign-launcher/client/src/components/DailyAmountPaidChart/index.tsx b/campaign-launcher/client/src/components/DailyAmountPaidChart/index.tsx deleted file mode 100644 index 51b05fa34..000000000 --- a/campaign-launcher/client/src/components/DailyAmountPaidChart/index.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { type FC, useEffect, useState } from 'react'; - -import { Box, useTheme } from '@mui/material'; -import { - type ChartOptions, - type ChartData, - type ScriptableContext, - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - Tooltip, - Filler, -} from 'chart.js'; -import annotationPlugin from 'chartjs-plugin-annotation'; -import { Line } from 'react-chartjs-2'; -import { numericFormatter } from 'react-number-format'; - -import { formatTokenAmount } from '@/utils'; - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Tooltip, - Filler, - annotationPlugin -); - -type ProcessedData = { - date: string; - value: number; -}; - -type Props = { - data: { - date: string; - amount: string; - }[]; - endDate: string; - tokenSymbol: string; - tokenDecimals: number; -}; - -const DailyAmountPaidChart: FC = ({ - data, - endDate, - tokenSymbol, - tokenDecimals, -}) => { - const theme = useTheme(); - const [hoverIndex, setHoverIndex] = useState(null); - const [processedData, setProcessedData] = useState([]); - - useEffect(() => { - const today = new Date().toISOString(); - const lastPayoutDate = data.length > 0 ? new Date(data[0].date) : endDate; - - const chartStartDate = today > endDate ? lastPayoutDate : today; - - const lastWeekDates = Array.from({ length: 7 }, (_, i) => { - const date = new Date(chartStartDate); - date.setDate(date.getDate() - i); - return date.toISOString().split('T')[0]; - }).reverse(); - - const processed = lastWeekDates.map((date) => { - const foundData = data.find((item) => item.date === date); - const [, month, day] = date.split('-'); - return { - date: `${day}/${month}`, - value: foundData - ? +formatTokenAmount(foundData.amount, tokenDecimals) - : 0, - }; - }); - - setProcessedData(processed); - }, [data, endDate, tokenDecimals]); - - const dates = processedData.map((item) => item.date); - const values = processedData.map((item) => item.value); - - const hasPoints = values.some((value) => value > 0); - - const chartData: ChartData<'line'> = { - labels: dates, - datasets: [ - { - data: values, - borderColor: theme.palette.text.primary, - borderWidth: 2, - backgroundColor: (context: ScriptableContext<'line'>) => { - const chart = context.chart; - const { ctx, chartArea } = chart; - const gradient = ctx.createLinearGradient( - 0, - 0, - 0, - chartArea.height + 80 - ); - gradient.addColorStop(0.3, 'rgba(202, 207, 232, 0.3)'); - gradient.addColorStop(1, 'rgba(233, 236, 255, 0)'); - return gradient; - }, - tension: 0.3, - fill: true, - pointRadius: 0, - pointBackgroundColor: theme.palette.primary.violet, - pointBorderColor: 'white', - pointBorderWidth: 2, - pointHitRadius: 10, - pointHoverRadius: 5, - pointHoverBackgroundColor: theme.palette.primary.violet, - pointHoverBorderColor: 'white', - pointHoverBorderWidth: 2, - clip: false, - }, - ], - }; - - const options: ChartOptions<'line'> = { - onHover: (_, elements) => { - if (elements.length > 0) { - setHoverIndex(elements[0].index); - } else { - setHoverIndex(null); - } - }, - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - animation: { - duration: 0, - }, - plugins: { - tooltip: { - backgroundColor: theme.palette.text.primary, - bodyColor: theme.palette.primary.light, - bodyFont: { - size: 16, - weight: 500, - }, - boxWidth: 95, - boxHeight: 42, - boxPadding: 6, - usePointStyle: true, - cornerRadius: 10, - caretSize: 0, - caretPadding: 10, - displayColors: false, - callbacks: { - title: () => '', - label: (context) => { - const value = context.raw as number; - const formattedValue = numericFormatter(value.toString(), { - decimalScale: 3, - fixedDecimalScale: false, - }); - const trimmedValue = parseFloat(formattedValue); - return `${trimmedValue} ${tokenSymbol}`; - }, - }, - }, - annotation: { - annotations: { - hoverLine: { - type: 'line', - xScaleID: 'x', - xMin: hoverIndex !== null ? hoverIndex : undefined, - xMax: hoverIndex !== null ? hoverIndex : undefined, - borderColor: '#858ec6', - borderWidth: 1, - borderDash: [2, 2], - display: hoverIndex !== null, - }, - }, - }, - legend: { - display: false, - }, - }, - scales: { - x: { - offset: true, - grid: { - display: false, - }, - ticks: { - color: theme.palette.text.primary, - font: { - size: 10, - weight: 500, - }, - padding: 10, - }, - }, - y: { - position: 'left', - min: 0, - max: Math.max(...values) || 1, - grid: { - color: 'rgba(203, 207, 230, 0.5)', - drawTicks: false, - }, - ticks: { - stepSize: hasPoints ? Math.max(...values) / 4 : 1, - color: theme.palette.text.primary, - font: { - size: 10, - weight: 500, - }, - padding: 10, - }, - border: { - display: false, - dash: [2, 2], - }, - }, - }, - }; - - return ( - setHoverIndex(null)} - > - - - ); -}; - -export default DailyAmountPaidChart; diff --git a/campaign-launcher/client/src/components/JoinCampaignButton/index.tsx b/campaign-launcher/client/src/components/JoinCampaignButton/index.tsx index b7735c087..61d2a4076 100644 --- a/campaign-launcher/client/src/components/JoinCampaignButton/index.tsx +++ b/campaign-launcher/client/src/components/JoinCampaignButton/index.tsx @@ -45,9 +45,7 @@ const JoinCampaignButton: FC = ({ campaign }) => { const isAlreadyJoined = useMemo( () => !!joinedCampaigns?.results.some( - (joinedCampaign) => - joinedCampaign.address.toLowerCase() === - campaign.address.toLowerCase() + (joinedCampaign) => joinedCampaign.address === campaign.address ), [joinedCampaigns?.results, campaign.address] ); diff --git a/campaign-launcher/client/src/components/Leaderboard/List.tsx b/campaign-launcher/client/src/components/Leaderboard/List.tsx new file mode 100644 index 000000000..84056bf37 --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/List.tsx @@ -0,0 +1,98 @@ +import { memo } from 'react'; + +import { Box, Stack, Typography } from '@mui/material'; + +import FormattedNumber from '@/components/FormattedNumber'; +import { + type CampaignType, + type EvmAddress, + type LeaderboardEntry, +} from '@/types'; +import { formatAddress, getCompactNumberParts } from '@/utils'; + +import MyEntryLabel from './MyEntryLabel'; + +import { getTargetLabel } from '.'; + +type Props = { + data: LeaderboardEntry[]; + activeAddress: EvmAddress | undefined; + campaignType: CampaignType; + tokenSymbol: string; +}; + +const LeaderboardList = memo( + ({ data, activeAddress, campaignType, tokenSymbol }: Props) => ( + + {data.map((entry) => { + const { address, rank, result, score } = entry; + const { + value: resultValue, + suffix: resultSuffix, + decimals: resultDecimals, + } = getCompactNumberParts(result); + const { + value: scoreValue, + suffix: scoreSuffix, + decimals: scoreDecimals, + } = getCompactNumberParts(score); + const isMyEntry = address === activeAddress; + return ( + + + + #{rank} + + + {formatAddress(address)} + + {isMyEntry && } + + + + + + + Score + + + + + + + {getTargetLabel(campaignType)} + + + + + ); + })} + + ) +); + +export default LeaderboardList; diff --git a/campaign-launcher/client/src/components/Leaderboard/MyEntryLabel.tsx b/campaign-launcher/client/src/components/Leaderboard/MyEntryLabel.tsx new file mode 100644 index 000000000..384b45e97 --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/MyEntryLabel.tsx @@ -0,0 +1,27 @@ +import { Box, Typography } from '@mui/material'; + +const MyEntryLabel = () => ( + + + You + + +); + +export default MyEntryLabel; diff --git a/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx b/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx new file mode 100644 index 000000000..12f4be48d --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx @@ -0,0 +1,144 @@ +import { + type ChangeEvent, + type FC, + useDeferredValue, + useMemo, + useState, +} from 'react'; + +import SearchIcon from '@mui/icons-material/Search'; +import { + Box, + InputAdornment, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; +import { type CampaignType, type LeaderboardEntry } from '@/types'; + +import LeaderboardList from './List'; + +import { formatActualOnDate } from '.'; + +type Props = { + open: boolean; + onClose: () => void; + data: LeaderboardEntry[]; + updatedAt: string; + symbol: string; + campaignType: CampaignType; + tokenSymbol: string; +}; + +const LeaderboardOverlay: FC = ({ + open, + onClose, + data, + updatedAt, + symbol, + campaignType, + tokenSymbol, +}) => { + const [search, setSearch] = useState(''); + const deferredSearch = useDeferredValue(search); + const isMobile = useIsMobile(); + const { activeAddress } = useActiveAccount(); + + const filteredData = useMemo(() => { + const normalizedSearch = deferredSearch.trim().toLowerCase(); + if (!normalizedSearch) return data; + + return data.filter(({ address }) => + address.toLowerCase().includes(normalizedSearch) + ); + }, [data, deferredSearch]); + + const handleSearchChange = (event: ChangeEvent) => { + setSearch(event.target.value); + }; + + const handleClose = () => { + setSearch(''); + onClose(); + }; + + return ( + + + + + {`Leaderboard (${symbol})`} + + + Actual on: {formatActualOnDate(updatedAt)} + + + + + ), + }, + }} + sx={{ + mt: 2, + '& .MuiOutlinedInput-root': { + color: 'white', + bgcolor: '#382c6b', + borderRadius: '28px', + border: 'none', + '& fieldset': { + border: 'none', + }, + }, + '& .MuiInputBase-input::placeholder': { + color: 'white', + opacity: 1, + }, + }} + /> + + + + + ); +}; + +export default LeaderboardOverlay; diff --git a/campaign-launcher/client/src/components/Leaderboard/index.tsx b/campaign-launcher/client/src/components/Leaderboard/index.tsx new file mode 100644 index 000000000..7617df764 --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/index.tsx @@ -0,0 +1,352 @@ +import { useMemo, useState, type FC } from 'react'; + +import { Box, Button, Paper, Stack, Typography } from '@mui/material'; + +import FormattedNumber from '@/components/FormattedNumber'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; +import { type LeaderboardData, type Campaign, CampaignType } from '@/types'; +import { + formatAddress, + getCompactNumberParts, + getDailyTargetTokenSymbol, + getTokenInfo, +} from '@/utils'; +import dayjs from '@/utils/dayjs'; + +import LeaderboardList from './List'; +import MyEntryLabel from './MyEntryLabel'; +import LeaderboardOverlay from './Overlay'; + +const ViewAllButton = ({ onClick }: { onClick: () => void }) => ( + + + +); + +const calculateListSlice = ( + leaderboard: { address: string }[], + activeAddress?: string +): [number, number] => { + const DEFAULT_START = 3; + const WINDOW = 5; + const total = leaderboard.length; + + const userIndex = activeAddress + ? leaderboard.findIndex((entry) => entry.address === activeAddress) + : -1; + + if (userIndex === -1) { + return [DEFAULT_START, DEFAULT_START + WINDOW]; + } + + const idealStart = userIndex - 2; + const minStart = DEFAULT_START; + const maxStart = Math.max(DEFAULT_START, total - WINDOW); + + const start = Math.min(maxStart, Math.max(minStart, idealStart)); + const end = start + WINDOW; + + return [start, end]; +}; + +export const formatActualOnDate = (date: string) => { + const value = dayjs(date); + const localTime = value.format('HH:mm'); + + if (value.isSame(dayjs(), 'day')) { + return localTime; + } + + return `${value.format('Do MMM')} ${localTime}`; +}; + +export const getTargetLabel = (campaignType: CampaignType): string => { + switch (campaignType) { + case CampaignType.MARKET_MAKING: + return 'Volume'; + case CampaignType.HOLDING: + return 'Balance'; + default: + return 'Volume'; + } +}; +type Props = { + campaign: Campaign; + leaderboard: LeaderboardData; +}; + +const Leaderboard: FC = ({ campaign, leaderboard }) => { + const [isOverlayOpen, setIsOverlayOpen] = useState(false); + + const { activeAddress } = useActiveAccount(); + const isMobile = useIsMobile(); + + const [listStart, listEnd] = useMemo( + () => calculateListSlice(leaderboard.data, activeAddress), + [leaderboard.data, activeAddress] + ); + + const showList = leaderboard.data.length > 3; + const showViewAllButton = leaderboard.data.length > 8; + + const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); + const { label: targetTokenSymbol } = getTokenInfo(targetToken); + + return ( + + + + Leaderboard + + + Actual on: {formatActualOnDate(leaderboard.updated_at)} + + + + + {leaderboard.data.slice(0, 3).map((entry) => { + const { rank, address, result, score } = entry; + const { + value: resultValue, + suffix: resultSuffix, + decimals: resultDecimals, + } = getCompactNumberParts(result); + const { + value: scoreValue, + suffix: scoreSuffix, + decimals: scoreDecimals, + } = getCompactNumberParts(score); + const isMyEntry = address === activeAddress; + return ( + + + + #{rank} + + + + {formatAddress(address)} + + {isMyEntry && } + + + + + + + + + {getTargetLabel(campaign.type)} + + + + + {isMobile ? 'Score:' : 'Score'} + + + + + + + + ); + })} + + + {showList && ( + + )} + {showViewAllButton && ( + setIsOverlayOpen(true)} /> + )} + + + setIsOverlayOpen(false)} + data={leaderboard.data} + updatedAt={leaderboard.updated_at} + symbol={campaign.symbol} + campaignType={campaign.type} + tokenSymbol={targetTokenSymbol || ''} + /> + + ); +}; + +export default Leaderboard; diff --git a/campaign-launcher/client/src/components/modals/ChartModal/index.tsx b/campaign-launcher/client/src/components/modals/ChartModal/index.tsx deleted file mode 100644 index ff77f3276..000000000 --- a/campaign-launcher/client/src/components/modals/ChartModal/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { type FC } from 'react'; - -import { CircularProgress, Typography } from '@mui/material'; - -import DailyAmountPaidChart from '@/components/DailyAmountPaidChart'; -import ModalError from '@/components/ModalState/Error'; -import { useIsMobile } from '@/hooks/useBreakpoints'; -import { useCampaignDailyPaidAmounts } from '@/hooks/useCampaigns'; -import type { CampaignDetails } from '@/types'; - -import BaseModal from '../BaseModal'; - -type Props = { - open: boolean; - onClose: () => void; - campaign: CampaignDetails; -}; - -const ChartModal: FC = ({ open, onClose, campaign }) => { - const isMobile = useIsMobile(); - const { data, isLoading, isError, isSuccess } = useCampaignDailyPaidAmounts( - campaign.address, - { enabled: open } - ); - - return ( - - - {isMobile ? 'Amount Paid' : 'Paid Amount Chart'} - - {isLoading && } - {isError && } - {isSuccess && ( - - )} - - ); -}; - -export default ChartModal; diff --git a/campaign-launcher/client/src/constants/queryKeys.ts b/campaign-launcher/client/src/constants/queryKeys.ts index 7899bb294..e80cc18e2 100644 --- a/campaign-launcher/client/src/constants/queryKeys.ts +++ b/campaign-launcher/client/src/constants/queryKeys.ts @@ -14,5 +14,5 @@ export const QUERY_KEYS = { EXCHANGE_CURRENCIES: 'exchange-currencies', USER_PROGRESS: 'user-progress', CHECK_CAMPAIGN_JOIN_STATUS: 'check-campaign-join-status', - CAMPAIGN_DAILY_PAID_AMOUNTS: 'campaign-daily-paid-amounts', + LEADERBOARD: 'leaderboard', }; diff --git a/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts b/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts index 2153a5fe7..33b6c485d 100644 --- a/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts +++ b/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts @@ -10,7 +10,11 @@ import { recordingApi } from '@/api'; import { AUTHED_QUERY_TAG, QUERY_KEYS } from '@/constants/queryKeys'; import { useNetwork } from '@/providers/NetworkProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import type { EvmAddress, CampaignsQueryParams } from '@/types'; +import type { + EvmAddress, + CampaignsQueryParams, + LeaderboardData, +} from '@/types'; type JoinedCampaignsParams = Partial>; @@ -75,3 +79,26 @@ export const useCheckCampaignJoinStatus = (address: EvmAddress) => { enabled: isAuthenticated && !!appChainId && !!address, }); }; + +export const useGetLeaderboard = ({ + address, + enabled = true, +}: { + address: string; + enabled?: boolean; +}) => { + const { appChainId } = useNetwork(); + + return useQuery({ + queryKey: [QUERY_KEYS.LEADERBOARD, appChainId, address], + queryFn: () => recordingApi.getLeaderboard(appChainId, address), + enabled: enabled && !!appChainId && !!address, + select: (data): LeaderboardData => ({ + ...data, + data: data.data.map((entry, idx) => ({ + ...entry, + rank: idx + 1, + })), + }), + }); +}; diff --git a/campaign-launcher/client/src/hooks/useCampaigns.ts b/campaign-launcher/client/src/hooks/useCampaigns.ts index 871a88207..24232771e 100644 --- a/campaign-launcher/client/src/hooks/useCampaigns.ts +++ b/campaign-launcher/client/src/hooks/useCampaigns.ts @@ -1,8 +1,4 @@ -import { - keepPreviousData, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { launcherApi } from '@/api'; import { QUERY_KEYS } from '@/constants/queryKeys'; @@ -50,11 +46,6 @@ export const useHostedCampaigns = (params: CampaignsQueryParams) => { export const useCampaignDetails = (address: string) => { const { appChainId } = useNetwork(); - const queryClient = useQueryClient(); - - queryClient.removeQueries({ - queryKey: [QUERY_KEYS.CAMPAIGN_DAILY_PAID_AMOUNTS, appChainId, address], - }); return useQuery({ queryKey: [QUERY_KEYS.CAMPAIGN_DETAILS, appChainId, address], @@ -72,17 +63,3 @@ export const useGetCampaignsStats = () => { enabled: !!appChainId, }); }; - -export const useCampaignDailyPaidAmounts = ( - address: string, - options?: { enabled?: boolean } -) => { - const { appChainId } = useNetwork(); - return useQuery({ - queryKey: [QUERY_KEYS.CAMPAIGN_DAILY_PAID_AMOUNTS, appChainId, address], - queryFn: () => launcherApi.getCampaignDailyPaidAmounts(appChainId, address), - enabled: (options?.enabled ?? true) && !!appChainId && !!address, - retry: false, - staleTime: Infinity, - }); -}; diff --git a/campaign-launcher/client/src/icons/index.tsx b/campaign-launcher/client/src/icons/index.tsx index ec3ebef6c..df2be6de5 100644 --- a/campaign-launcher/client/src/icons/index.tsx +++ b/campaign-launcher/client/src/icons/index.tsx @@ -140,22 +140,32 @@ export const ChevronIcon: FC = (props) => { export const OpenInNewIcon: FC = (props) => { return ( - + ); }; -export const CalendarIcon: FC = (props) => { +export const CopyIcon: FC = (props) => { return ( - - + + + + ); }; @@ -215,17 +225,6 @@ export const SuccessIcon: FC = (props) => { ); }; -export const ChartIcon: FC = (props) => { - return ( - - - - ); -}; - export const WalletIcon: FC = (props) => { return ( @@ -372,23 +371,6 @@ export const ConnectWalletIcon: FC = (props) => { ); }; -export const CopyIcon: FC = (props) => { - return ( - - - - - - ); -}; - export const MobileBottomNavIcon: FC = (props) => { return ( @@ -519,7 +501,22 @@ export const NoKeysIcon: FC = (props) => { width="2.87756" height="32.6271" transform="rotate(-45 10.4092 12.6621)" - fill="current Color" + fill="currentColor" + /> + + ); +}; + +export const CancelIcon: FC = (props) => { + return ( + + ); diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 3be14956e..5005d3c13 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -1,29 +1,79 @@ -import { type FC, useMemo } from 'react'; +import { type FC, type PropsWithChildren, useMemo } from 'react'; -import { Skeleton } from '@mui/material'; +import { Box } from '@mui/material'; +import { getAddress } from 'ethers'; import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; +import CampaignResultsSection from '@/components/CampaignResultsSection'; import CampaignStats from '@/components/CampaignStats'; +import CancelCampaignSection from '@/components/CancelCampaignSection'; +import CycleInfoSection from '@/components/CycleInfoSection'; import JoinCampaignButton from '@/components/JoinCampaignButton'; -import PageTitle from '@/components/PageTitle'; +import { useReserveLayoutBottomOffset } from '@/components/Layout'; +import Leaderboard from '@/components/Leaderboard'; import PageWrapper from '@/components/PageWrapper'; -import { useCheckCampaignJoinStatus } from '@/hooks/recording-oracle'; +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; +import { + useCheckCampaignJoinStatus, + useGetLeaderboard, +} from '@/hooks/recording-oracle/campaign'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; -import { CampaignJoinStatus, type EvmAddress } from '@/types'; +import { useAuthedUserData } from '@/providers/AuthedUserData'; +import { useExchangesContext } from '@/providers/ExchangesProvider'; +import { + CampaignStatus, + CampaignType, + type Campaign, + type EvmAddress, +} from '@/types'; import { isCampaignDetails } from '@/utils'; +const BottomButtonWrapper: FC = ({ children }) => { + return ( + theme.zIndex.appBar} + > + {children} + + ); +}; + const CampaignDetails: FC = () => { const { address } = useParams() as { address: EvmAddress }; const [searchParams] = useSearchParams(); - const { data: campaign, isFetching: isCampaignLoading } = - useCampaignDetails(address); - const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = - useCheckCampaignJoinStatus(address); + const normalizedAddress = getAddress(address) as EvmAddress; + + const { joinedCampaigns } = useAuthedUserData(); + const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); + const { data: campaign, isFetching: isCampaignLoading } = + useCampaignDetails(normalizedAddress); + + const { data: leaderboard } = useGetLeaderboard({ + address: campaign?.address || '', + enabled: campaign?.status === CampaignStatus.ACTIVE, + }); + + const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = + useCheckCampaignJoinStatus(normalizedAddress); + const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); if (!encodedData) return undefined; @@ -44,32 +94,63 @@ const CampaignDetails: FC = () => { } }, [searchParams]); + const isJoined = useMemo(() => { + return !!joinedCampaigns?.results.some( + (joinedCampaign) => joinedCampaign.address === normalizedAddress + ); + }, [joinedCampaigns?.results, normalizedAddress]); + + const exchangeInfo = exchangesMap.get(campaign?.exchange_name || ''); + const campaignData = campaign || parsedData; + const isOngoingCampaign = + !!campaignData && + campaignData.status === CampaignStatus.ACTIVE && + campaignData.start_date < new Date().toISOString() && + campaignData.end_date > new Date().toISOString(); + + const showJoinCampaignButton = + isMobile && !isJoined && isOngoingCampaign && !!exchangeInfo?.enabled; + + useReserveLayoutBottomOffset(showJoinCampaignButton); + + const showLeaderboard = + isOngoingCampaign && + campaignData.type !== CampaignType.THRESHOLD && + leaderboard && + leaderboard.data.length > 0; + return ( - - {!isMobile && campaignData && ( - - )} - - {isCampaignLoading && !isMobile && ( - - )} + {isOngoingCampaign && !!leaderboard && ( + + )} + {showLeaderboard && ( + + )} + {!!campaign && } + {!!campaign && ( + + )} + {showJoinCampaignButton && ( + + + + )} ); }; diff --git a/campaign-launcher/client/src/types/index.ts b/campaign-launcher/client/src/types/index.ts index 8ed803ac9..58641adb5 100644 --- a/campaign-launcher/client/src/types/index.ts +++ b/campaign-launcher/client/src/types/index.ts @@ -81,6 +81,7 @@ export type Campaign = { launcher: string; recording_oracle: string; reputation_oracle: string; + cancellation_requested_at: number | null; start_date: string; status: CampaignStatus; symbol: string; @@ -131,6 +132,27 @@ export type JoinedCampaignsResponse = { has_more: boolean; }; +export type LeaderboardEntryDto = { + address: EvmAddress; + result: number; + score: number; + estimated_reward: number; +}; + +export type LeaderboardEntry = LeaderboardEntryDto & { + rank: number; +}; + +export type LeaderboardResponseDto = { + data: LeaderboardEntryDto[]; + total: number; + updated_at: string; +}; + +export type LeaderboardData = Omit & { + data: LeaderboardEntry[]; +}; + type BaseManifestDto = { exchange: string; start_date: string; diff --git a/campaign-launcher/client/yarn.lock b/campaign-launcher/client/yarn.lock index a1691d472..042031877 100644 --- a/campaign-launcher/client/yarn.lock +++ b/campaign-launcher/client/yarn.lock @@ -800,13 +800,6 @@ __metadata: languageName: node linkType: hard -"@kurkle/color@npm:^0.3.0": - version: 0.3.4 - resolution: "@kurkle/color@npm:0.3.4" - checksum: 10c0/0e9fd55c614b005c5f0c4c755bca19ec0293bc7513b4ea3ec1725234f9c2fa81afbc78156baf555c8b9cb0d305619253c3f5bca016067daeebb3d00ebb4ea683 - languageName: node - linkType: hard - "@lit-labs/ssr-dom-shim@npm:^1.4.0": version: 1.4.0 resolution: "@lit-labs/ssr-dom-shim@npm:1.4.0" @@ -4618,8 +4611,6 @@ __metadata: "@vitejs/plugin-react-refresh": "npm:^1.3.6" "@walletconnect/ethereum-provider": "npm:^2.23.5" axios: "npm:^1.13.2" - chart.js: "npm:^4.5.1" - chartjs-plugin-annotation: "npm:^3.1.0" dayjs: "npm:^1.11.19" eslint: "npm:^10.2.0" eslint-import-resolver-typescript: "npm:^4.4.4" @@ -4633,7 +4624,6 @@ __metadata: notistack: "npm:^3.0.2" prettier: "npm:^3.8.1" react: "npm:^19.2.4" - react-chartjs-2: "npm:^5.3.1" react-dom: "npm:^19.2.4" react-hook-form: "npm:^7.68.0" react-number-format: "npm:^5.4.4" @@ -4679,24 +4669,6 @@ __metadata: languageName: node linkType: hard -"chart.js@npm:^4.5.1": - version: 4.5.1 - resolution: "chart.js@npm:4.5.1" - dependencies: - "@kurkle/color": "npm:^0.3.0" - checksum: 10c0/3f2a11dcaae9079e8e6b8ad077e2ae311f04996f9da14815730891e66215ee8b5f2c0eb70b5a156e5bde0f89a41bae13506dc6153e50fd22dcb282b21eec706f - languageName: node - linkType: hard - -"chartjs-plugin-annotation@npm:^3.1.0": - version: 3.1.0 - resolution: "chartjs-plugin-annotation@npm:3.1.0" - peerDependencies: - chart.js: ">=4.0.0" - checksum: 10c0/5494a008a013888b122ac6754c1314e100b6368f55110f08401e3cbdb5969b372ece9569ed3b166b8fbad8c2dc84f15795602079613e2f5f65f787a0931bd676 - languageName: node - linkType: hard - "chokidar@npm:^4.0.3": version: 4.0.3 resolution: "chokidar@npm:4.0.3" @@ -8446,16 +8418,6 @@ __metadata: languageName: node linkType: hard -"react-chartjs-2@npm:^5.3.1": - version: 5.3.1 - resolution: "react-chartjs-2@npm:5.3.1" - peerDependencies: - chart.js: ^4.1.1 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/40fac3fe23a163232d952bf5cd2a14d7baceadea326b57909d5e556ed2481b7cfb177582ac8f39b8dc51ac11f3d19a6127784746f35f5771181ab700110e7326 - languageName: node - linkType: hard - "react-dom@npm:^19.2.4": version: 19.2.4 resolution: "react-dom@npm:19.2.4"