From 629ed85ca6b2a13ed11a439f6c520e198fafc11e Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 13 Mar 2026 17:49:11 +0300 Subject: [PATCH 01/27] feat: redesign campaign info, wip campaign stats, remove unused components and chart packages --- campaign-launcher/client/package.json | 3 - .../src/components/CampaignInfo/index.tsx | 255 +++++++++----- .../src/components/CampaignStats/index.tsx | 315 +++++------------- .../components/CampaignStatusLabel/index.tsx | 99 ++++-- .../components/CampaignTypeLabel/index.tsx | 35 -- .../components/DailyAmountPaidChart/index.tsx | 241 -------------- .../components/modals/ChartModal/index.tsx | 59 ---- .../client/src/hooks/useCampaigns.ts | 14 - campaign-launcher/client/src/icons/index.tsx | 22 -- .../src/pages/CampaignDetails/index.tsx | 14 - campaign-launcher/client/yarn.lock | 39 +-- 11 files changed, 325 insertions(+), 771 deletions(-) delete mode 100644 campaign-launcher/client/src/components/CampaignTypeLabel/index.tsx delete mode 100644 campaign-launcher/client/src/components/DailyAmountPaidChart/index.tsx delete mode 100644 campaign-launcher/client/src/components/modals/ChartModal/index.tsx 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/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 99ede330c..3af7b4d91 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -1,27 +1,36 @@ -import { useState, type FC } from 'react'; +import { type FC } from 'react'; -import { Box, Button, Skeleton, Stack, Typography } from '@mui/material'; +import { + Box, + IconButton, + Divider as MuiDivider, + Skeleton, + Stack, + styled, + Typography, +} from '@mui/material'; +import { useNavigate } from 'react-router'; 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 CampaignSymbol from '@/components/CampaignSymbol'; +import JoinCampaign from '@/components/JoinCampaign'; +import { ROUTES } from '@/constants'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import { CalendarIcon } from '@/icons'; +import { ArrowLeftIcon } from '@/icons'; import type { CampaignDetails, CampaignJoinStatus } from '@/types'; -import { getChainIcon, getNetworkName } from '@/utils'; +import { getChainIcon, getNetworkName, mapTypeToLabel } 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 => { - const date = dayjs(dateString); - return date.format('HH:mm [GMT]Z'); -}; +const DividerStyled = styled(MuiDivider)({ + borderColor: 'rgba(255, 255, 255, 0.3)', + height: 16, + alignSelf: 'center', +}); type Props = { campaign: CampaignDetails | null | undefined; @@ -31,106 +40,172 @@ type Props = { isJoinStatusLoading: boolean; }; -const CampaignInfo: FC = ({ campaign, isCampaignLoading }) => { - const [isChartModalOpen, setIsChartModalOpen] = useState(false); - +const CampaignInfo: FC = ({ + campaign, + isCampaignLoading, + joinStatus, + joinedAt, + isJoinStatusLoading, +}) => { const isMobile = useIsMobile(); + const navigate = useNavigate(); + + const handleGoBack = () => { + const appHistoryIndex = window.history.state?.idx; + const canGoBackInApp = Number(appHistoryIndex) > 0; + + if (canGoBackInApp) { + navigate(-1); + return; + } + + navigate(ROUTES.CAMPAIGNS); + }; if (isCampaignLoading) { - if (!isMobile) return null; + if (isMobile) { + return ( + + + + + ); + } return ( - - - - + + + ); } if (!campaign) return null; + const oracleFee = + campaign.exchange_oracle_fee_percent + + campaign.recording_oracle_fee_percent + + campaign.reputation_oracle_fee_percent; + return ( - - - - {isMobile && } - - - + + + + + + + + + {!isMobile && ( + + )} + - + - - svg': { fontSize: { xs: '12px', md: '16px' } } }} > - - {formatDate(campaign.start_date)} - - - - - - - + - - {formatDate(campaign.end_date)} - - + {getNetworkName(campaign.chain_id)?.slice(0, 3)} + - + + + + {mapTypeToLabel(campaign.type)} + + + - {getChainIcon(campaign.chain_id)} - + {formatDate(campaign.start_date)} + {' > '} + {formatDate(campaign.end_date)} + + + + {oracleFee}% Oracle fees + - {!isMobile && ( - - - setIsChartModalOpen(false)} - campaign={campaign} - /> - - )} - + ); }; diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 4b63b5f05..47908b6df 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -1,28 +1,20 @@ -import { type FC, type PropsWithChildren, Children, useState } from 'react'; +import { type FC, type PropsWithChildren, Children } from 'react'; -import { Box, Button, Skeleton, styled, Typography, Grid } from '@mui/material'; +import { Box, Skeleton, styled, Typography, Grid } from '@mui/material'; -import CampaignResultsWidget, { - StatusTooltip, -} from '@/components/CampaignResultsWidget'; -import CampaignSymbol from '@/components/CampaignSymbol'; -import CustomTooltip from '@/components/CustomTooltip'; +import CampaignResultsWidget from '@/components/CampaignResultsWidget'; 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 { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import { CampaignStatus, CampaignType, type CampaignDetails } from '@/types'; +import { CampaignStatus, type CampaignDetails } from '@/types'; import { formatTokenAmount, getDailyTargetTokenSymbol, + getTargetInfo, getTokenInfo, } from '@/utils'; -import ChartModal from '../modals/ChartModal'; - type Props = { campaign: CampaignDetails | null | undefined; isJoined: boolean; @@ -32,36 +24,49 @@ type Props = { const StatsCard = styled(Box)(({ theme }) => ({ display: 'flex', flexDirection: 'column', - height: '216px', - padding: '16px 32px', - backgroundColor: theme.palette.background.default, + justifyContent: 'space-between', + height: '175px', + padding: '28px 32px', + backgroundColor: '#251D47', borderRadius: '16px', border: '1px solid rgba(255, 255, 255, 0.1)', - [theme.breakpoints.down('xl')]: { - height: 'unset', - minHeight: '125px', - justifyContent: 'space-between', - padding: '16px', - }, - [theme.breakpoints.down('md')]: { - height: 'unset', - minHeight: '125px', + flexDirection: 'column-reverse', + height: '100px', + padding: '12px 16px 16px', + borderRadius: '8px', }, })); -const Title = styled(Typography)(({ theme }) => ({ - color: theme.palette.text.primary, - marginBottom: '56px', - textTransform: 'capitalize', +const CardName = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: '16px', + fontWeight: 600, + lineHeight: '18px', + letterSpacing: '1.5px', + textTransform: 'uppercase', + marginBottom: '45px', - [theme.breakpoints.down('xl')]: { - marginBottom: '16px', + [theme.breakpoints.down('md')]: { + fontSize: '14px', + lineHeight: '150%', + letterSpacing: '0px', + marginBottom: '0px', }, +})); + +const CardValue = styled(Typography)(({ theme }) => ({ + color: 'white', + fontSize: '36px', + fontWeight: 800, + lineHeight: '100%', [theme.breakpoints.down('md')]: { - marginBottom: 'auto', + fontSize: '20px', + fontWeight: 500, + lineHeight: '150%', + marginBottom: '16px', }, })); @@ -103,73 +108,20 @@ const FirstRowWrapper: FC< ); }; -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 renderProgressWidget = (campaign: CampaignDetails) => ( - - - - - -); - -const renderSkeletonBlocks = () => { - const row = Array(4).fill(0); +const renderSkeletonBlocks = (isMobile: boolean) => { + const size = isMobile ? 6 : 8; + const row = Array(size).fill(0); return ( - <> - - {row.map((_, index) => ( - - - - - - - ))} - - - {row.map((_, index) => ( - - - - - - - ))} - - + + {row.map((_, index) => ( + + + + + + + ))} + ); }; @@ -178,14 +130,11 @@ const CampaignStats: FC = ({ isJoined, isCampaignLoading, }) => { - const [isChartModalOpen, setIsChartModalOpen] = useState(false); - const { exchangesMap } = useExchangesContext(); - const isXl = useIsXlDesktop(); const isMobile = useIsMobile(); const { isAuthenticated } = useWeb3Auth(); - if (isCampaignLoading) return renderSkeletonBlocks(); + if (isCampaignLoading) return renderSkeletonBlocks(isMobile); if (!campaign) return null; @@ -207,21 +156,11 @@ const CampaignStats: FC = ({ 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 @@ -232,105 +171,48 @@ const CampaignStats: FC = ({ return ( <> - - {showProgressWidget && isMobile && renderProgressWidget(campaign)} + - Total Funded Amount - + + {isMobile ? 'Reward Pool' : 'Total Reward Pool'} + + {formattedTokenAmount} {campaign.fund_token_symbol} - - - - Amount Paid - - {formattedAmountPaid} {campaign.fund_token_symbol} - - - - Oracle fees - - {' '} - {campaign.fund_token_symbol}{' '} - - ({totalFee}%) - - + + - - {getDailyTargetCardLabel(campaign.type)} - - + + {getTargetInfo(campaign).label} + + {' '} - {targetTokenSymbol} - + suffix={` ${targetTokenSymbol}`} + /> + - {showProgressWidget && !isMobile && renderProgressWidget(campaign)} - + - Reserved funds - + Reserved funds + {formattedReservedFunds} {campaign.fund_token_symbol} - + - - Campaign results - <CustomTooltip - title={<StatusTooltip />} - arrow - placement={isMobile ? 'left' : 'top'} - > - <InfoTooltipInner /> - </CustomTooltip> - + Campaign results = ({ - Exchange - - {exchangeName} - + Exchange + {exchangeName} - - Symbol - + + Symbol + {campaign.symbol} - {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..6ac40ea0d 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,31 @@ 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/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/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/hooks/useCampaigns.ts b/campaign-launcher/client/src/hooks/useCampaigns.ts index 871a88207..4586d7f6d 100644 --- a/campaign-launcher/client/src/hooks/useCampaigns.ts +++ b/campaign-launcher/client/src/hooks/useCampaigns.ts @@ -72,17 +72,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..7e28fd12f 100644 --- a/campaign-launcher/client/src/icons/index.tsx +++ b/campaign-launcher/client/src/icons/index.tsx @@ -149,17 +149,6 @@ export const OpenInNewIcon: FC = (props) => { ); }; -export const CalendarIcon: FC = (props) => { - return ( - - - - ); -}; - export const ApiKeyIcon: FC = (props) => { return ( @@ -215,17 +204,6 @@ export const SuccessIcon: FC = (props) => { ); }; -export const ChartIcon: FC = (props) => { - return ( - - - - ); -}; - export const WalletIcon: 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..2ef0f87a4 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -1,15 +1,11 @@ import { type FC, useMemo } from 'react'; -import { Skeleton } from '@mui/material'; import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; import CampaignStats from '@/components/CampaignStats'; -import JoinCampaignButton from '@/components/JoinCampaignButton'; -import PageTitle from '@/components/PageTitle'; import PageWrapper from '@/components/PageWrapper'; import { useCheckCampaignJoinStatus } from '@/hooks/recording-oracle'; -import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; import { CampaignJoinStatus, type EvmAddress } from '@/types'; import { isCampaignDetails } from '@/utils'; @@ -22,8 +18,6 @@ const CampaignDetails: FC = () => { const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = useCheckCampaignJoinStatus(address); - const isMobile = useIsMobile(); - const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); if (!encodedData) return undefined; @@ -48,14 +42,6 @@ const CampaignDetails: FC = () => { return ( - - {!isMobile && campaignData && ( - - )} - - {isCampaignLoading && !isMobile && ( - - )} =4.0.0" - checksum: 10c0/5494a008a013888b122ac6754c1314e100b6368f55110f08401e3cbdb5969b372ece9569ed3b166b8fbad8c2dc84f15795602079613e2f5f65f787a0931bd676 +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e languageName: node linkType: hard @@ -8446,16 +8425,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" From c69d1b552a060e8abf024e5bad453192612c5842 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 13 Mar 2026 18:08:40 +0300 Subject: [PATCH 02/27] fix: address feedback from copilot --- .../client/src/components/CampaignInfo/index.tsx | 7 ++++++- .../client/src/components/CampaignStats/index.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 3af7b4d91..d724678a0 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -110,7 +110,12 @@ const CampaignInfo: FC = ({ height={{ xs: 'auto', md: '42px' }} > - + = ({ campaign.fund_token_decimals ); + const targetInfo = getTargetInfo(campaign); + const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); const { label: targetTokenSymbol } = getTokenInfo(targetToken); @@ -183,12 +185,10 @@ const CampaignStats: FC = ({ - - {getTargetInfo(campaign).label} - + {targetInfo.label} From fe9cb579c1bb9769d49d7173bc9f0b7b1ea53d80 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 19 Mar 2026 19:06:29 +0300 Subject: [PATCH 03/27] feat: add a few widgets, minor styles improvements --- .../src/components/CampaignInfo/index.tsx | 37 +--- .../CampaignResultsWidget/index.tsx | 103 +++++----- .../src/components/CampaignStats/index.tsx | 191 ++++++++---------- 3 files changed, 138 insertions(+), 193 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index d724678a0..a6b64b186 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -2,22 +2,18 @@ import { type FC } from 'react'; import { Box, - IconButton, Divider as MuiDivider, Skeleton, Stack, styled, Typography, } from '@mui/material'; -import { useNavigate } from 'react-router'; import CampaignAddress from '@/components/CampaignAddress'; import CampaignStatusLabel from '@/components/CampaignStatusLabel'; import CampaignSymbol from '@/components/CampaignSymbol'; import JoinCampaign from '@/components/JoinCampaign'; -import { ROUTES } from '@/constants'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import { ArrowLeftIcon } from '@/icons'; import type { CampaignDetails, CampaignJoinStatus } from '@/types'; import { getChainIcon, getNetworkName, mapTypeToLabel } from '@/utils'; import dayjs from '@/utils/dayjs'; @@ -48,19 +44,6 @@ const CampaignInfo: FC = ({ isJoinStatusLoading, }) => { const isMobile = useIsMobile(); - const navigate = useNavigate(); - - const handleGoBack = () => { - const appHistoryIndex = window.history.state?.idx; - const canGoBackInApp = Number(appHistoryIndex) > 0; - - if (canGoBackInApp) { - navigate(-1); - return; - } - - navigate(ROUTES.CAMPAIGNS); - }; if (isCampaignLoading) { if (isMobile) { @@ -109,21 +92,11 @@ const CampaignInfo: FC = ({ gap={2} height={{ xs: 'auto', md: '42px' }} > - - - - - - + , 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', + cardBgColor: '#361034', }, intermediate: { label: 'Intermediate', description: 'Campaign is active. Results show progress so far.', bgcolor: 'warning.main', + cardBgColor: 'rgba(255, 187, 0, 0.20)', }, final: { label: 'Final', description: 'Campaign has ended. These are the final results of the campaign.', bgcolor: 'success.main', + cardBgColor: 'rgba(83, 255, 60, 0.15)', }, }; + type ResultType = typeof RESULT; -export const StatusTooltip = () => ( - - - - - Final: {RESULT.final.description} - - - - - - Intermediate: {RESULT.intermediate.description} - - - - - - N/A: {RESULT.none.description} - - - -); +const resultStyles = { + color: 'white', + fontSize: { xs: '20px', md: '36px' }, + fontWeight: { xs: 500, md: 800 }, + lineHeight: { xs: '150%', md: '100%' }, +}; const CampaignResultsWidget: FC = ({ campaignStatus, finalResultsUrl, intermediateResultsUrl, }) => { - const isMobile = useIsMobile(); - const isFinished = [ CampaignStatus.CANCELLED, CampaignStatus.COMPLETED, @@ -87,28 +66,48 @@ const CampaignResultsWidget: FC = ({ } return ( - - + - {result.label} + Campaign results - {result !== RESULT.none && ( - handleOpenUrl(e, resultUrl || '')} - > - - - )} - + + + + {resultUrl ? ( + + {result.label} + + ) : ( + {result.label} + )} + + + ); }; diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 58161b2b0..2c7393c10 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -1,10 +1,11 @@ -import { type FC, type PropsWithChildren, Children } from 'react'; +import { type FC } from 'react'; import { Box, Skeleton, styled, Typography, Grid } from '@mui/material'; import CampaignResultsWidget from '@/components/CampaignResultsWidget'; import FormattedNumber from '@/components/FormattedNumber'; import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useCampaignTimeline } from '@/hooks/useCampaignTimeline'; import { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; import { CampaignStatus, type CampaignDetails } from '@/types'; @@ -24,9 +25,10 @@ type Props = { const StatsCard = styled(Box)(({ theme }) => ({ display: 'flex', flexDirection: 'column', - justifyContent: 'space-between', + justifyContent: 'start', height: '175px', - padding: '28px 32px', + padding: '32px', + gap: '45px', backgroundColor: '#251D47', borderRadius: '16px', border: '1px solid rgba(255, 255, 255, 0.1)', @@ -34,7 +36,8 @@ const StatsCard = styled(Box)(({ theme }) => ({ [theme.breakpoints.down('md')]: { flexDirection: 'column-reverse', height: '100px', - padding: '12px 16px 16px', + padding: '12px', + gap: '16px', borderRadius: '8px', }, })); @@ -46,13 +49,13 @@ const CardName = styled(Typography)(({ theme }) => ({ lineHeight: '18px', letterSpacing: '1.5px', textTransform: 'uppercase', - marginBottom: '45px', [theme.breakpoints.down('md')]: { fontSize: '14px', + fontWeight: 400, lineHeight: '150%', letterSpacing: '0px', - marginBottom: '0px', + textTransform: 'none', }, })); @@ -66,58 +69,20 @@ const CardValue = styled(Typography)(({ theme }) => ({ fontSize: '20px', fontWeight: 500, lineHeight: '150%', - marginBottom: '16px', - }, -})); - -const FlexGrid = styled(Box)(({ theme }) => ({ - display: 'flex', - flexWrap: 'wrap', - gap: '16px', - width: '100%', - '& > *': { - flexBasis: 'calc(50% - 8px)', - }, - - [theme.breakpoints.down('md')]: { - gap: '8px', }, })); const now = new Date().toISOString(); -const FirstRowWrapper: FC< - PropsWithChildren<{ - showProgressWidget: boolean; - }> -> = ({ showProgressWidget, children }) => { - if (showProgressWidget) { - return ( - - {children} - - ); - } - - return ( - <> - {Children.map(children, (child) => ( - {child} - ))} - - ); -}; - const renderSkeletonBlocks = (isMobile: boolean) => { - const size = isMobile ? 6 : 8; - const row = Array(size).fill(0); + const elements = Array(6).fill(0); return ( - {row.map((_, index) => ( - + {elements.map((_, index) => ( + - - + + ))} @@ -133,6 +98,7 @@ const CampaignStats: FC = ({ const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); const { isAuthenticated } = useWeb3Auth(); + const campaignTimeline = useCampaignTimeline(campaign); if (isCampaignLoading) return renderSkeletonBlocks(isMobile); @@ -147,7 +113,7 @@ const CampaignStats: FC = ({ campaign.status === CampaignStatus.TO_CANCEL && campaign.reserved_funds !== campaign.balance; - const showProgressWidget = + const showUserPerformance = isAuthenticated && isJoined && (isOngoingCampaign || hasProgressBeforeCancel); @@ -172,68 +138,75 @@ const CampaignStats: FC = ({ const { label: targetTokenSymbol } = getTokenInfo(targetToken); return ( - <> - - - - - {isMobile ? 'Reward Pool' : 'Total Reward Pool'} - - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - - {targetInfo.label} - - - - - + + + + + {isMobile ? 'Reward Pool' : 'Total Reward Pool'} + + + {formattedTokenAmount} {campaign.fund_token_symbol} + + - - - - Reserved funds - - {formattedReservedFunds} {campaign.fund_token_symbol} - - - - - - Campaign results - + + + Ranking + 14 / 81 + + + + + My Volume + + {formattedTokenAmount} {campaign.fund_token_symbol} + + + + + )} + + + {targetInfo.label} + + - - - - - Exchange - {exchangeName} - - - - - Symbol - {campaign.symbol} - - + + + + + + Reserved funds + + {formattedReservedFunds} {campaign.fund_token_symbol} + + - + + + Exchange + {exchangeName} + + + + + + + + {campaignTimeline.label} + {campaignTimeline.value} + + + ); }; From f838bb1b3a0c715686f7c31aff3c22aed5ea9576 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 20 Mar 2026 12:50:25 +0300 Subject: [PATCH 04/27] fix: reinstall dependencies --- campaign-launcher/client/yarn.lock | 7 ------- 1 file changed, 7 deletions(-) diff --git a/campaign-launcher/client/yarn.lock b/campaign-launcher/client/yarn.lock index 74d74f615..042031877 100644 --- a/campaign-launcher/client/yarn.lock +++ b/campaign-launcher/client/yarn.lock @@ -4669,13 +4669,6 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^2.1.1": - version: 2.1.1 - resolution: "check-error@npm:2.1.1" - checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e - languageName: node - linkType: hard - "chokidar@npm:^4.0.3": version: 4.0.3 resolution: "chokidar@npm:4.0.3" From e37feadd573761c04769957e55db48ca7f49d00e Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 20 Mar 2026 18:03:15 +0300 Subject: [PATCH 05/27] chore: minor changes of the widgets --- .../src/components/CampaignInfo/index.tsx | 1 + .../CampaignResultsWidget/index.tsx | 82 ++++++++++--------- .../src/components/CampaignStats/index.tsx | 49 ++++------- campaign-launcher/client/src/icons/index.tsx | 27 +++++- 4 files changed, 86 insertions(+), 73 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index a6b64b186..2d1e4a553 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -149,6 +149,7 @@ const CampaignInfo: FC = ({ address={campaign.address} chainId={campaign.chain_id} size={isMobile ? 'medium' : 'large'} + withCopy /> = ({ @@ -75,37 +74,42 @@ const CampaignResultsWidget: FC = ({ bgcolor={result.cardBgColor} border="1px solid rgba(255, 255, 255, 0.1)" > - - Campaign results - - - - - {resultUrl ? ( - - {result.label} - - ) : ( - {result.label} - )} - + {resultUrl ? ( + + Campaign results + + + ) : ( + Campaign results + )} + + + + {result.label} + ); diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 2c7393c10..6f1563669 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -127,11 +127,6 @@ const CampaignStats: FC = ({ campaign.fund_token_decimals ); - const formattedReservedFunds = +formatTokenAmount( - campaign.reserved_funds, - campaign.fund_token_decimals - ); - const targetInfo = getTargetInfo(campaign); const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); @@ -149,24 +144,6 @@ const CampaignStats: FC = ({ - {showUserPerformance && ( - <> - - - Ranking - 14 / 81 - - - - - My Volume - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - - )} {targetInfo.label} @@ -179,20 +156,30 @@ const CampaignStats: FC = ({ - - - Reserved funds - - {formattedReservedFunds} {campaign.fund_token_symbol} - - - Exchange {exchangeName} + {showUserPerformance && ( + <> + + + Ranking + 14 / 81 + + + + + My Volume + + {formattedTokenAmount} {campaign.fund_token_symbol} + + + + + )} = (props) => { export const OpenInNewIcon: FC = (props) => { return ( - + ); }; +export const CopyIcon: FC = (props) => { + return ( + + + + + + ); +}; + export const ApiKeyIcon: FC = (props) => { return ( From d788b140bb7ad206603b85e6646ca17072931735 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 25 Mar 2026 14:55:53 +0300 Subject: [PATCH 06/27] feat: rework join campaign flow; store all joined campaigns in context --- .../src/components/CampaignInfo/index.tsx | 22 +- .../src/components/CampaignsTable/index.tsx | 11 +- .../JoinCampaign/JoinCampaignOverlay.tsx | 231 ++++++++++++++++++ .../src/components/JoinCampaign/index.tsx | 137 +++++++++++ .../src/pages/CampaignDetails/index.tsx | 21 +- 5 files changed, 391 insertions(+), 31 deletions(-) create mode 100644 campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx create mode 100644 campaign-launcher/client/src/components/JoinCampaign/index.tsx diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 2d1e4a553..d26eb3cd2 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -14,7 +14,7 @@ import CampaignStatusLabel from '@/components/CampaignStatusLabel'; import CampaignSymbol from '@/components/CampaignSymbol'; import JoinCampaign from '@/components/JoinCampaign'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import type { CampaignDetails, CampaignJoinStatus } from '@/types'; +import type { CampaignDetails } from '@/types'; import { getChainIcon, getNetworkName, mapTypeToLabel } from '@/utils'; import dayjs from '@/utils/dayjs'; @@ -31,18 +31,9 @@ const DividerStyled = styled(MuiDivider)({ type Props = { campaign: CampaignDetails | null | undefined; isCampaignLoading: boolean; - joinStatus?: CampaignJoinStatus; - joinedAt?: string; - isJoinStatusLoading: boolean; }; -const CampaignInfo: FC = ({ - campaign, - isCampaignLoading, - joinStatus, - joinedAt, - isJoinStatusLoading, -}) => { +const CampaignInfo: FC = ({ campaign, isCampaignLoading }) => { const isMobile = useIsMobile(); if (isCampaignLoading) { @@ -103,14 +94,7 @@ const CampaignInfo: FC = ({ startDate={campaign.start_date} endDate={campaign.end_date} /> - {!isMobile && ( - - )} + {!isMobile && } = ({ > - + ); }, diff --git a/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx b/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx new file mode 100644 index 000000000..ee22bcee7 --- /dev/null +++ b/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx @@ -0,0 +1,231 @@ +import { type FC, useState } from 'react'; + +import { Box, Button, Grid, Stack, Typography } from '@mui/material'; +import { useConnect, useConnectors, useDisconnect } from 'wagmi'; + +import coinbasePng from '@/assets/coinbase.png'; +import metaMaskPng from '@/assets/metamask.png'; +import walletConnectPng from '@/assets/walletConnect.png'; +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useNotification } from '@/hooks/useNotification'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; +import { useWeb3Auth } from '@/providers/Web3AuthProvider'; +import { formatAddress } from '@/utils'; + +type JoinFlowStep = 'connect' | 'auth'; + +type Props = { + open: boolean; + onClose: () => void; + startStep: JoinFlowStep; +}; + +const WALLET_ICONS: Record = { + metaMaskSDK: metaMaskPng, + coinbaseWalletSDK: coinbasePng, + walletConnect: walletConnectPng, +}; + +const JoinCampaignOverlay: FC = ({ open, onClose, startStep }) => { + const [step, setStep] = useState(startStep); + const [selectedConnectorId, setSelectedConnectorId] = useState(); + + const connectors = useConnectors(); + const connect = useConnect(); + const disconnect = useDisconnect(); + const { activeAddress } = useActiveAccount(); + const { signIn, isLoading: isAuthLoading } = useWeb3Auth(); + const { showError } = useNotification(); + const isMobile = useIsMobile(); + + const selectedConnector = connectors.find( + (connector) => connector.id === selectedConnectorId + ); + + const isOverlayActionLoading = + connect.isPending || disconnect.isPending || isAuthLoading; + + const handleOverlayClose = () => { + if (isOverlayActionLoading) return; + onClose(); + }; + + const handleConnectWallet = async () => { + if (!selectedConnector) return; + + try { + await connect.mutateAsync({ connector: selectedConnector }); + setStep('auth'); + } catch (error) { + const err = error as { message?: string }; + if (err.message?.includes('Connector already connected')) { + await disconnect.mutateAsync(); + await connect.mutateAsync({ connector: selectedConnector }); + setStep('auth'); + return; + } + showError('Failed to connect wallet'); + } + }; + + const handleAuthenticate = async () => { + try { + await signIn(); + onClose(); + } catch { + showError('Failed to sign in'); + } + }; + + const isConnectStep = step === 'connect'; + const shouldShowTwoSteps = startStep === 'connect'; + + return ( + + + {shouldShowTwoSteps && ( + + {Array.from({ length: 2 }).map((_, index) => ( + + ))} + + )} + + + {isConnectStep ? 'Connect Wallet' : 'Sign In'} + + + {isConnectStep + ? 'Connect your wallet to create, participate in campaigns and track your performance.' + : 'To keep your account secure, please sign this message. This is a gasless way to confirm you own this address.'} + + + {isConnectStep ? ( + + {connectors.map((connector) => { + const isSelected = connector.id === selectedConnectorId; + + return ( + + + + ); + })} + + ) : ( + + Connected Wallet + + {formatAddress(activeAddress)} + + + )} + {isConnectStep ? ( + + ) : ( + + + + + )} + + + ); +}; + +export default JoinCampaignOverlay; diff --git a/campaign-launcher/client/src/components/JoinCampaign/index.tsx b/campaign-launcher/client/src/components/JoinCampaign/index.tsx new file mode 100644 index 000000000..322c3b2cb --- /dev/null +++ b/campaign-launcher/client/src/components/JoinCampaign/index.tsx @@ -0,0 +1,137 @@ +import { type FC, useMemo, useState } from 'react'; + +import { Button, CircularProgress } from '@mui/material'; +import { useNavigate } from 'react-router'; +import { useConnection } from 'wagmi'; + +import { ROUTES } from '@/constants'; +import { + useGetEnrolledExchanges, + useJoinCampaign, +} from '@/hooks/recording-oracle'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useNotification } from '@/hooks/useNotification'; +import { useExchangesContext } from '@/providers/ExchangesProvider'; +import { useWeb3Auth } from '@/providers/Web3AuthProvider'; +import { CampaignStatus, ExchangeType, type Campaign } from '@/types'; +import * as errorUtils from '@/utils/error'; + +import JoinCampaignOverlay from './JoinCampaignOverlay'; + +type Props = { + campaign: Campaign; +}; + +const JoinCampaign: FC = ({ campaign }) => { + const [isOverlayOpen, setIsOverlayOpen] = useState(false); + const [startStep, setStartStep] = useState<'connect' | 'auth'>('connect'); + + const navigate = useNavigate(); + const { isConnected } = useConnection(); + const { isAuthenticated, joinedCampaigns, isJoinedCampaignsLoading } = + useWeb3Auth(); + const { data: enrolledExchanges, isLoading: isEnrolledExchangesLoading } = + useGetEnrolledExchanges(); + const { exchangesMap } = useExchangesContext(); + const { mutateAsync: joinCampaign, isPending: isJoining } = useJoinCampaign(); + const { showError } = useNotification(); + + const isMobile = useIsMobile(); + + const isLoading = + isEnrolledExchangesLoading || isJoinedCampaignsLoading || isJoining; + + const isAlreadyJoined = useMemo( + () => + !!joinedCampaigns?.results.some( + (joinedCampaign) => + joinedCampaign.address.toLowerCase() === + campaign.address.toLowerCase() + ), + [joinedCampaigns?.results, campaign.address] + ); + const exchangeInfo = exchangesMap.get(campaign.exchange_name); + + const handleOverlayClose = () => { + setIsOverlayOpen(false); + }; + + const handleJoinCampaign = async () => { + if (!campaign || !exchangeInfo) return; + + const hasEnrolledApiKey = (enrolledExchanges || []).includes( + campaign.exchange_name + ); + if (exchangeInfo.type === ExchangeType.CEX && !hasEnrolledApiKey) { + navigate(ROUTES.MANAGE_API_KEYS); + return; + } + try { + await joinCampaign({ + chainId: campaign.chain_id, + address: campaign.address, + }); + } catch (error) { + console.error('Failed to join campaign', error); + showError( + errorUtils.getMessageFromError(error) || + 'Failed to join campaign. Please try again.' + ); + } + }; + + const handleButtonClick = async () => { + if (isLoading || !campaign) return; + + if (!isConnected || !isAuthenticated) { + setIsOverlayOpen(true); + setStartStep(isConnected ? 'auth' : 'connect'); + return; + } + + await handleJoinCampaign(); + }; + + const isCampaignFinished = + campaign.end_date < new Date().toISOString() || + campaign.status !== CampaignStatus.ACTIVE; + + if ( + !campaign || + !exchangeInfo?.enabled || + isAlreadyJoined || + isCampaignFinished + ) { + return null; + } + + return ( + <> + + + + ); +}; + +export default JoinCampaign; diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 2ef0f87a4..25f0a3812 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -5,9 +5,9 @@ import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; import CampaignStats from '@/components/CampaignStats'; import PageWrapper from '@/components/PageWrapper'; -import { useCheckCampaignJoinStatus } from '@/hooks/recording-oracle'; import { useCampaignDetails } from '@/hooks/useCampaigns'; -import { CampaignJoinStatus, type EvmAddress } from '@/types'; +import { useWeb3Auth } from '@/providers/Web3AuthProvider'; +import { type EvmAddress } from '@/types'; import { isCampaignDetails } from '@/utils'; const CampaignDetails: FC = () => { @@ -15,8 +15,7 @@ const CampaignDetails: FC = () => { const [searchParams] = useSearchParams(); const { data: campaign, isFetching: isCampaignLoading } = useCampaignDetails(address); - const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = - useCheckCampaignJoinStatus(address); + const { joinedCampaigns } = useWeb3Auth(); const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); @@ -38,6 +37,13 @@ const CampaignDetails: FC = () => { } }, [searchParams]); + const isJoined = useMemo(() => { + return !!joinedCampaigns?.results.some( + (joinedCampaign) => + joinedCampaign.address.toLowerCase() === campaign?.address.toLowerCase() + ); + }, [joinedCampaigns?.results, campaign?.address]); + const campaignData = campaign || parsedData; return ( @@ -45,16 +51,11 @@ const CampaignDetails: FC = () => { ); From e29636ca8cbf38b3f314a9af15c037bd849e3579 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 26 Mar 2026 15:18:37 +0300 Subject: [PATCH 07/27] chore: show join button at the bottom on mobile; overlay logic if user isnt authed; add authed user data provider --- .../src/components/CampaignInfo/index.tsx | 21 +- .../CampaignResultsWidget/index.tsx | 5 +- .../src/components/CampaignsTable/index.tsx | 9 +- .../JoinCampaign/JoinCampaignOverlay.tsx | 231 ------------------ .../src/components/JoinCampaign/index.tsx | 137 ----------- .../hooks/recording-oracle/exchangeApiKeys.ts | 1 - .../src/pages/CampaignDetails/index.tsx | 60 ++++- 7 files changed, 77 insertions(+), 387 deletions(-) delete mode 100644 campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx delete mode 100644 campaign-launcher/client/src/components/JoinCampaign/index.tsx diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index d26eb3cd2..044163daa 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -12,7 +12,7 @@ import { import CampaignAddress from '@/components/CampaignAddress'; import CampaignStatusLabel from '@/components/CampaignStatusLabel'; import CampaignSymbol from '@/components/CampaignSymbol'; -import JoinCampaign from '@/components/JoinCampaign'; +import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useIsMobile } from '@/hooks/useBreakpoints'; import type { CampaignDetails } from '@/types'; import { getChainIcon, getNetworkName, mapTypeToLabel } from '@/utils'; @@ -31,9 +31,10 @@ const DividerStyled = styled(MuiDivider)({ type Props = { campaign: CampaignDetails | null | undefined; isCampaignLoading: boolean; + isJoined: boolean; }; -const CampaignInfo: FC = ({ campaign, isCampaignLoading }) => { +const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { const isMobile = useIsMobile(); if (isCampaignLoading) { @@ -94,7 +95,7 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading }) => { startDate={campaign.start_date} endDate={campaign.end_date} /> - {!isMobile && } + {!isMobile && } = ({ campaign, isCampaignLoading }) => { {mapTypeToLabel(campaign.type)} + {isJoined && ( + <> + + Joined + + + + )} = ({ )} = ({ > - + ); }, diff --git a/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx b/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx deleted file mode 100644 index ee22bcee7..000000000 --- a/campaign-launcher/client/src/components/JoinCampaign/JoinCampaignOverlay.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { type FC, useState } from 'react'; - -import { Box, Button, Grid, Stack, Typography } from '@mui/material'; -import { useConnect, useConnectors, useDisconnect } from 'wagmi'; - -import coinbasePng from '@/assets/coinbase.png'; -import metaMaskPng from '@/assets/metamask.png'; -import walletConnectPng from '@/assets/walletConnect.png'; -import ResponsiveOverlay from '@/components/ResponsiveOverlay'; -import { useIsMobile } from '@/hooks/useBreakpoints'; -import { useNotification } from '@/hooks/useNotification'; -import { useActiveAccount } from '@/providers/ActiveAccountProvider'; -import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import { formatAddress } from '@/utils'; - -type JoinFlowStep = 'connect' | 'auth'; - -type Props = { - open: boolean; - onClose: () => void; - startStep: JoinFlowStep; -}; - -const WALLET_ICONS: Record = { - metaMaskSDK: metaMaskPng, - coinbaseWalletSDK: coinbasePng, - walletConnect: walletConnectPng, -}; - -const JoinCampaignOverlay: FC = ({ open, onClose, startStep }) => { - const [step, setStep] = useState(startStep); - const [selectedConnectorId, setSelectedConnectorId] = useState(); - - const connectors = useConnectors(); - const connect = useConnect(); - const disconnect = useDisconnect(); - const { activeAddress } = useActiveAccount(); - const { signIn, isLoading: isAuthLoading } = useWeb3Auth(); - const { showError } = useNotification(); - const isMobile = useIsMobile(); - - const selectedConnector = connectors.find( - (connector) => connector.id === selectedConnectorId - ); - - const isOverlayActionLoading = - connect.isPending || disconnect.isPending || isAuthLoading; - - const handleOverlayClose = () => { - if (isOverlayActionLoading) return; - onClose(); - }; - - const handleConnectWallet = async () => { - if (!selectedConnector) return; - - try { - await connect.mutateAsync({ connector: selectedConnector }); - setStep('auth'); - } catch (error) { - const err = error as { message?: string }; - if (err.message?.includes('Connector already connected')) { - await disconnect.mutateAsync(); - await connect.mutateAsync({ connector: selectedConnector }); - setStep('auth'); - return; - } - showError('Failed to connect wallet'); - } - }; - - const handleAuthenticate = async () => { - try { - await signIn(); - onClose(); - } catch { - showError('Failed to sign in'); - } - }; - - const isConnectStep = step === 'connect'; - const shouldShowTwoSteps = startStep === 'connect'; - - return ( - - - {shouldShowTwoSteps && ( - - {Array.from({ length: 2 }).map((_, index) => ( - - ))} - - )} - - - {isConnectStep ? 'Connect Wallet' : 'Sign In'} - - - {isConnectStep - ? 'Connect your wallet to create, participate in campaigns and track your performance.' - : 'To keep your account secure, please sign this message. This is a gasless way to confirm you own this address.'} - - - {isConnectStep ? ( - - {connectors.map((connector) => { - const isSelected = connector.id === selectedConnectorId; - - return ( - - - - ); - })} - - ) : ( - - Connected Wallet - - {formatAddress(activeAddress)} - - - )} - {isConnectStep ? ( - - ) : ( - - - - - )} - - - ); -}; - -export default JoinCampaignOverlay; diff --git a/campaign-launcher/client/src/components/JoinCampaign/index.tsx b/campaign-launcher/client/src/components/JoinCampaign/index.tsx deleted file mode 100644 index 322c3b2cb..000000000 --- a/campaign-launcher/client/src/components/JoinCampaign/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { type FC, useMemo, useState } from 'react'; - -import { Button, CircularProgress } from '@mui/material'; -import { useNavigate } from 'react-router'; -import { useConnection } from 'wagmi'; - -import { ROUTES } from '@/constants'; -import { - useGetEnrolledExchanges, - useJoinCampaign, -} from '@/hooks/recording-oracle'; -import { useIsMobile } from '@/hooks/useBreakpoints'; -import { useNotification } from '@/hooks/useNotification'; -import { useExchangesContext } from '@/providers/ExchangesProvider'; -import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import { CampaignStatus, ExchangeType, type Campaign } from '@/types'; -import * as errorUtils from '@/utils/error'; - -import JoinCampaignOverlay from './JoinCampaignOverlay'; - -type Props = { - campaign: Campaign; -}; - -const JoinCampaign: FC = ({ campaign }) => { - const [isOverlayOpen, setIsOverlayOpen] = useState(false); - const [startStep, setStartStep] = useState<'connect' | 'auth'>('connect'); - - const navigate = useNavigate(); - const { isConnected } = useConnection(); - const { isAuthenticated, joinedCampaigns, isJoinedCampaignsLoading } = - useWeb3Auth(); - const { data: enrolledExchanges, isLoading: isEnrolledExchangesLoading } = - useGetEnrolledExchanges(); - const { exchangesMap } = useExchangesContext(); - const { mutateAsync: joinCampaign, isPending: isJoining } = useJoinCampaign(); - const { showError } = useNotification(); - - const isMobile = useIsMobile(); - - const isLoading = - isEnrolledExchangesLoading || isJoinedCampaignsLoading || isJoining; - - const isAlreadyJoined = useMemo( - () => - !!joinedCampaigns?.results.some( - (joinedCampaign) => - joinedCampaign.address.toLowerCase() === - campaign.address.toLowerCase() - ), - [joinedCampaigns?.results, campaign.address] - ); - const exchangeInfo = exchangesMap.get(campaign.exchange_name); - - const handleOverlayClose = () => { - setIsOverlayOpen(false); - }; - - const handleJoinCampaign = async () => { - if (!campaign || !exchangeInfo) return; - - const hasEnrolledApiKey = (enrolledExchanges || []).includes( - campaign.exchange_name - ); - if (exchangeInfo.type === ExchangeType.CEX && !hasEnrolledApiKey) { - navigate(ROUTES.MANAGE_API_KEYS); - return; - } - try { - await joinCampaign({ - chainId: campaign.chain_id, - address: campaign.address, - }); - } catch (error) { - console.error('Failed to join campaign', error); - showError( - errorUtils.getMessageFromError(error) || - 'Failed to join campaign. Please try again.' - ); - } - }; - - const handleButtonClick = async () => { - if (isLoading || !campaign) return; - - if (!isConnected || !isAuthenticated) { - setIsOverlayOpen(true); - setStartStep(isConnected ? 'auth' : 'connect'); - return; - } - - await handleJoinCampaign(); - }; - - const isCampaignFinished = - campaign.end_date < new Date().toISOString() || - campaign.status !== CampaignStatus.ACTIVE; - - if ( - !campaign || - !exchangeInfo?.enabled || - isAlreadyJoined || - isCampaignFinished - ) { - return null; - } - - return ( - <> - - - - ); -}; - -export default JoinCampaign; diff --git a/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts b/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts index 4399cc6f4..74edbace7 100644 --- a/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts +++ b/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts @@ -7,7 +7,6 @@ import * as errorUtils from '@/utils/error'; export const useGetEnrolledExchanges = () => { const { isAuthenticated } = useWeb3Auth(); - return useQuery({ queryKey: [QUERY_KEYS.ENROLLED_EXCHANGES, AUTHED_QUERY_TAG], queryFn: () => recordingApi.getEnrolledExchanges(), diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 25f0a3812..2c05c1db0 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -1,21 +1,52 @@ -import { type FC, useMemo } from 'react'; +import { type FC, type PropsWithChildren, useMemo } from 'react'; +import { Box } from '@mui/material'; import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; import CampaignStats from '@/components/CampaignStats'; +import JoinCampaignButton from '@/components/JoinCampaignButton'; +import { useReserveLayoutBottomOffset } from '@/components/Layout'; import PageWrapper from '@/components/PageWrapper'; +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; +import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; -import { useWeb3Auth } from '@/providers/Web3AuthProvider'; -import { type EvmAddress } from '@/types'; +import { useAuthedUserData } from '@/providers/AuthedUserData'; +import { useExchangesContext } from '@/providers/ExchangesProvider'; +import { CampaignStatus, 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 { joinedCampaigns } = useWeb3Auth(); + const { joinedCampaigns } = useAuthedUserData(); + const { exchangesMap } = useExchangesContext(); + const isMobile = useIsMobile(); const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); @@ -44,19 +75,40 @@ const CampaignDetails: FC = () => { ); }, [joinedCampaigns?.results, campaign?.address]); + const exchangeInfo = exchangesMap.get(campaign?.exchange_name || ''); + const campaignData = campaign || parsedData; + const isCampaignFinished = + (campaignData && campaignData.end_date < new Date().toISOString()) || + campaignData?.status !== CampaignStatus.ACTIVE; + + const showJoinCampaignButton = + isMobile && + !!campaignData && + !isJoined && + !isCampaignFinished && + !!exchangeInfo?.enabled; + + useReserveLayoutBottomOffset(showJoinCampaignButton); + return ( + {showJoinCampaignButton && ( + + + + )} ); }; From 768d8b139577ccf35477f7aa3d33dea6a3e320ec Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 30 Mar 2026 17:31:34 +0300 Subject: [PATCH 08/27] feat: apply some new designs highlighting most important props --- .../src/components/CampaignInfo/index.tsx | 41 ++-- .../src/components/CampaignStats/index.tsx | 201 ++++++++++++------ .../components/CampaignStatusLabel/index.tsx | 5 +- campaign-launcher/client/src/icons/index.tsx | 34 ++- 4 files changed, 176 insertions(+), 105 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 044163daa..9036c39c6 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -11,11 +11,11 @@ import { import CampaignAddress from '@/components/CampaignAddress'; import CampaignStatusLabel from '@/components/CampaignStatusLabel'; -import CampaignSymbol from '@/components/CampaignSymbol'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useActiveAccount } from '@/providers/ActiveAccountProvider'; import type { CampaignDetails } from '@/types'; -import { getChainIcon, getNetworkName, mapTypeToLabel } from '@/utils'; +import { getChainIcon, getNetworkName } from '@/utils'; import dayjs from '@/utils/dayjs'; const formatDate = (dateString: string): string => { @@ -36,6 +36,10 @@ type Props = { const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { const isMobile = useIsMobile(); + const { activeAddress } = useActiveAccount(); + + const isHosted = + campaign?.launcher?.toLowerCase() === activeAddress?.toLowerCase(); if (isCampaignLoading) { if (isMobile) { @@ -45,10 +49,10 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { mx={-2} px={2} pb={4} - gap={1.5} + gap={2} borderBottom="1px solid #473C74" > - + ); @@ -75,20 +79,23 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { mx={{ xs: -2, md: 0 }} px={{ xs: 2, md: 0 }} pb={{ xs: 4, md: 0 }} - gap={{ xs: 1.5, md: 3.5 }} + gap={{ xs: 2, md: 3.5 }} borderBottom={{ xs: '1px solid #473C74', md: 'none' }} > - + + Campaign Details + = ({ campaign, isCampaignLoading, isJoined }) => { withCopy /> - - {mapTypeToLabel(campaign.type)} - - - {isJoined && ( + {(isJoined || isHosted) && ( <> = ({ campaign, isCampaignLoading, isJoined }) => { fontWeight={500} lineHeight="100%" letterSpacing={0} + textTransform="uppercase" > - Joined + {isJoined ? 'Joined' : 'Hosted'} diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 6f1563669..59df36929 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -1,11 +1,13 @@ import { type FC } from 'react'; -import { Box, Skeleton, styled, Typography, Grid } from '@mui/material'; +import { Box, Skeleton, Stack, styled, Typography, Grid } from '@mui/material'; import CampaignResultsWidget from '@/components/CampaignResultsWidget'; +import CampaignSymbol from '@/components/CampaignSymbol'; import FormattedNumber from '@/components/FormattedNumber'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignTimeline } from '@/hooks/useCampaignTimeline'; +import { CancelIcon } from '@/icons'; import { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; import { CampaignStatus, type CampaignDetails } from '@/types'; @@ -14,6 +16,7 @@ import { getDailyTargetTokenSymbol, getTargetInfo, getTokenInfo, + mapTypeToLabel, } from '@/utils'; type Props = { @@ -29,16 +32,16 @@ const StatsCard = styled(Box)(({ theme }) => ({ height: '175px', padding: '32px', gap: '45px', - backgroundColor: '#251D47', - borderRadius: '16px', - border: '1px solid rgba(255, 255, 255, 0.1)', + //backgroundColor: '#251D47', + //borderRadius: '16px', + //border: '1px solid rgba(255, 255, 255, 0.1)', [theme.breakpoints.down('md')]: { flexDirection: 'column-reverse', height: '100px', padding: '12px', gap: '16px', - borderRadius: '8px', + //borderRadius: '8px', }, })); @@ -104,6 +107,8 @@ const CampaignStats: FC = ({ if (!campaign) return null; + const isCancelled = campaign.status === CampaignStatus.CANCELLED; + const isOngoingCampaign = campaign.status === CampaignStatus.ACTIVE && now >= campaign.start_date && @@ -133,67 +138,137 @@ const CampaignStats: FC = ({ const { label: targetTokenSymbol } = getTokenInfo(targetToken); return ( - - - - - {isMobile ? 'Reward Pool' : 'Total Reward Pool'} - - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - - - {targetInfo.label} - - - - - - - - Exchange - {exchangeName} - - - {showUserPerformance && ( - <> - - - Ranking - 14 / 81 - - - - - My Volume - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - + + + Details + + {isCancelled && ( + + + + + Campaign Cancelled + + + {/* TODO: use cancelled_at and format date */} + + Cancelled on {campaign.end_date} + + )} - - + + + + Exchange + {exchangeName} + + + + + Campaign Type + {mapTypeToLabel(campaign.type)} + + + + + Symbol + + + - - - {campaignTimeline.label} - {campaignTimeline.value} - + + + + + {isMobile ? 'Reward Pool' : 'Total Reward Pool'} + + + {formattedTokenAmount} {campaign.fund_token_symbol} + + + + + + {targetInfo.label} + + + + + + + + Exchange + {exchangeName} + + + {showUserPerformance && ( + <> + + + Ranking + 14 / 81 + + + + + My Volume + + {formattedTokenAmount} {campaign.fund_token_symbol} + + + + + )} + + + + + + {campaignTimeline.label} + {campaignTimeline.value} + + - + ); }; diff --git a/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx b/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx index 6ac40ea0d..212d21e01 100644 --- a/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStatusLabel/index.tsx @@ -69,12 +69,11 @@ const CampaignStatusLabel: FC = ({ endDate ); return ( - + diff --git a/campaign-launcher/client/src/icons/index.tsx b/campaign-launcher/client/src/icons/index.tsx index 0c1efe13a..df2be6de5 100644 --- a/campaign-launcher/client/src/icons/index.tsx +++ b/campaign-launcher/client/src/icons/index.tsx @@ -371,23 +371,6 @@ export const ConnectWalletIcon: FC = (props) => { ); }; -export const CopyIcon: FC = (props) => { - return ( - - - - - - ); -}; - export const MobileBottomNavIcon: FC = (props) => { return ( @@ -518,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 ( + + ); From 6c5de8b7545f0e4a6191cf9ee959d5d295d35212 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 2 Apr 2026 11:32:06 +0300 Subject: [PATCH 09/27] feat: add leaderboard query logic --- .../client/src/api/recordingApiClient.ts | 16 +++++++++++++ .../client/src/constants/queryKeys.ts | 1 + .../src/hooks/recording-oracle/campaign.ts | 23 +++++++++++++++++++ .../src/pages/CampaignDetails/index.tsx | 15 ++++++++++-- campaign-launcher/client/src/types/index.ts | 10 ++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/campaign-launcher/client/src/api/recordingApiClient.ts b/campaign-launcher/client/src/api/recordingApiClient.ts index 13d4ce4ef..7479fb952 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, + LeaderboardResponse, } from '@/types'; import { HttpClient, HttpError } from '@/utils/HttpClient'; import type { TokenData, TokenManager } from '@/utils/TokenManager'; @@ -219,4 +220,19 @@ 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`, + { + params: { + rank_by: 'current_progress', + }, + } + ); + return response; + } } diff --git a/campaign-launcher/client/src/constants/queryKeys.ts b/campaign-launcher/client/src/constants/queryKeys.ts index 7899bb294..788516e3a 100644 --- a/campaign-launcher/client/src/constants/queryKeys.ts +++ b/campaign-launcher/client/src/constants/queryKeys.ts @@ -15,4 +15,5 @@ export const QUERY_KEYS = { 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..6832e9d7f 100644 --- a/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts +++ b/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts @@ -75,3 +75,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) => ({ + ...data, + data: data.data.map((entry, idx) => ({ + ...entry, + rank: idx + 1, + })), + }), + }); +}; diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 2c05c1db0..38b93ef99 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -9,6 +9,7 @@ import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useReserveLayoutBottomOffset } from '@/components/Layout'; import PageWrapper from '@/components/PageWrapper'; import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; +import { useGetLeaderboard } from '@/hooks/recording-oracle/campaign'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; import { useAuthedUserData } from '@/providers/AuthedUserData'; @@ -42,12 +43,22 @@ const BottomButtonWrapper: FC = ({ children }) => { const CampaignDetails: FC = () => { const { address } = useParams() as { address: EvmAddress }; const [searchParams] = useSearchParams(); - const { data: campaign, isFetching: isCampaignLoading } = - useCampaignDetails(address); + const { joinedCampaigns } = useAuthedUserData(); const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); + const { data: campaign, isFetching: isCampaignLoading } = + useCampaignDetails(address); + + const { data: leaderboard } = useGetLeaderboard({ + address: campaign?.address || '', + enabled: campaign?.status === CampaignStatus.ACTIVE, + }); + + // eslint-disable-next-line no-console + console.log('leaderboard', leaderboard); + const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); if (!encodedData) return undefined; diff --git a/campaign-launcher/client/src/types/index.ts b/campaign-launcher/client/src/types/index.ts index 8ed803ac9..a6517bc03 100644 --- a/campaign-launcher/client/src/types/index.ts +++ b/campaign-launcher/client/src/types/index.ts @@ -131,6 +131,16 @@ export type JoinedCampaignsResponse = { has_more: boolean; }; +export type LeaderboardEntry = { + address: EvmAddress; + result: number; + rank: number; +}; + +export type LeaderboardResponse = { + data: LeaderboardEntry[]; +}; + type BaseManifestDto = { exchange: string; start_date: string; From 7e546804a9f620faa24da6787a9186867ecf38c2 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 2 Apr 2026 18:37:26 +0300 Subject: [PATCH 10/27] feat: add cycle info section, misc styles changes --- .../src/components/CampaignInfo/index.tsx | 12 +- .../src/components/CampaignStats/index.tsx | 139 +++++++------- .../src/components/CycleInfoSection/index.tsx | 169 ++++++++++++++++++ .../src/pages/CampaignDetails/index.tsx | 7 +- 4 files changed, 237 insertions(+), 90 deletions(-) create mode 100644 campaign-launcher/client/src/components/CycleInfoSection/index.tsx diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 9036c39c6..9e4ef1b1f 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -44,14 +44,7 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { if (isCampaignLoading) { if (isMobile) { return ( - + @@ -59,7 +52,7 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { } return ( - + @@ -75,7 +68,6 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { return ( ({ +export const StatsCard = styled(Box, { + shouldForwardProp: (prop) => prop !== 'withBorder', +})<{ withBorder?: boolean }>(({ theme, withBorder }) => ({ display: 'flex', flexDirection: 'column', justifyContent: 'start', height: '175px', padding: '32px', + flex: 1, gap: '45px', - //backgroundColor: '#251D47', - //borderRadius: '16px', - //border: '1px solid rgba(255, 255, 255, 0.1)', + ...(withBorder && { + backgroundColor: '#251D47', + borderRadius: '16px', + border: '1px solid rgba(255, 255, 255, 0.1)', + }), [theme.breakpoints.down('md')]: { - flexDirection: 'column-reverse', - height: '100px', + //flexDirection: 'column-reverse', + //height: '100px', + height: 'auto', + minHeight: '90px', padding: '12px', - gap: '16px', - //borderRadius: '8px', + gap: '8px', + ...(withBorder && { + borderRadius: '8px', + }), }, })); -const CardName = styled(Typography)(({ theme }) => ({ +export const CardName = styled(Typography)(({ theme }) => ({ color: theme.palette.text.secondary, fontSize: '16px', fontWeight: 600, @@ -62,7 +69,7 @@ const CardName = styled(Typography)(({ theme }) => ({ }, })); -const CardValue = styled(Typography)(({ theme }) => ({ +export const CardValue = styled(Typography)(({ theme }) => ({ color: 'white', fontSize: '36px', fontWeight: 800, @@ -97,11 +104,11 @@ const CampaignStats: FC = ({ campaign, isJoined, isCampaignLoading, + totalParticipants, }) => { const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); const { isAuthenticated } = useWeb3Auth(); - const campaignTimeline = useCampaignTimeline(campaign); if (isCampaignLoading) return renderSkeletonBlocks(isMobile); @@ -127,31 +134,33 @@ const CampaignStats: FC = ({ exchangesMap.get(campaign.exchange_name)?.display_name || campaign.exchange_name; - const formattedTokenAmount = +formatTokenAmount( - campaign.fund_amount, - campaign.fund_token_decimals - ); - const targetInfo = getTargetInfo(campaign); const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); const { label: targetTokenSymbol } = getTokenInfo(targetToken); return ( - + Details {isCancelled && ( = ({ - + Exchange {exchangeName} - + Campaign Type {mapTypeToLabel(campaign.type)} - + Symbol - - - - - {isMobile ? 'Reward Pool' : 'Total Reward Pool'} - - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - + - + {targetInfo.label} = ({ - - - Exchange - {exchangeName} - - - {showUserPerformance && ( - <> - - - Ranking - 14 / 81 - - - - - My Volume - - {formattedTokenAmount} {campaign.fund_token_symbol} - - - - + {showUserPerformance ? ( + + + Ranking + 14 / 81 + + + ) : ( + + + Total Participants + {totalParticipants} + + )} - - - - - - {campaignTimeline.label} - {campaignTimeline.value} - - ); 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..f27c21e0a --- /dev/null +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -0,0 +1,169 @@ +import { type FC, useEffect, useMemo, useState } from 'react'; + +import { Box, Grid, Stack, Typography } from '@mui/material'; + +import { CardName, CardValue, StatsCard } from '@/components/CampaignStats'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { type Campaign } from '@/types'; +import { formatTokenAmount } from '@/utils'; + +type Props = { + campaign: Campaign; +}; + +const CYCLE_DURATION_MS = 24 * 60 * 60 * 1000; + +const formatDuration = (milliseconds: number) => { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}h:${minutes + .toString() + .padStart(2, '0')}m:${seconds.toString().padStart(2, '0')}s`; + } + + if (minutes > 0) { + return `${minutes.toString().padStart(2, '0')}m:${seconds + .toString() + .padStart(2, '0')}s`; + } + + return `${seconds}s`; +}; + +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 = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + + const totalCycles = Math.ceil((end - start) / CYCLE_DURATION_MS); + const effectiveNow = Math.min(Math.max(now, start), end); + const elapsedSinceStart = effectiveNow - start; + const currentCycle = Math.min( + totalCycles, + Math.floor(elapsedSinceStart / CYCLE_DURATION_MS) + 1 + ); + + const currentCycleStart = start + (currentCycle - 1) * CYCLE_DURATION_MS; + const currentCycleEnd = Math.min( + currentCycleStart + CYCLE_DURATION_MS, + end + ); + const remainingMs = Math.max(0, currentCycleEnd - now); + + return { + currentCycle, + totalCycles, + remainingTime: formatDuration(remainingMs), + }; + }, [startDate, endDate, now]); + + return cycleTimeInfo; +}; + +const CycleInfoSection: FC = ({ campaign }) => { + const isMobile = useIsMobile(); + + const cycleTimeline = useCycleTimeline( + campaign.start_date, + campaign.end_date + ); + const rewardPool = +formatTokenAmount( + campaign.fund_amount, + campaign.fund_token_decimals + ); + + return ( + + + + Cycle Info + + + + {`Cycle ${cycleTimeline.currentCycle} of ${cycleTimeline.totalCycles}`} + + + + Resets every 24h + + + + + + + Cycle Reward Pool + + {rewardPool} {campaign.fund_token_symbol} + + + + + + Ends in + {cycleTimeline.remainingTime} + + + + + Current Cycle + + {cycleTimeline.currentCycle} / {cycleTimeline.totalCycles} + + + + + + ); +}; + +export default CycleInfoSection; diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 38b93ef99..92a1f4e1f 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; import CampaignStats from '@/components/CampaignStats'; +import CycleInfoSection from '@/components/CycleInfoSection'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useReserveLayoutBottomOffset } from '@/components/Layout'; import PageWrapper from '@/components/PageWrapper'; @@ -92,7 +93,7 @@ const CampaignDetails: FC = () => { const isCampaignFinished = (campaignData && campaignData.end_date < new Date().toISOString()) || - campaignData?.status !== CampaignStatus.ACTIVE; + (campaignData && campaignData?.status !== CampaignStatus.ACTIVE); const showJoinCampaignButton = isMobile && @@ -114,7 +115,11 @@ const CampaignDetails: FC = () => { campaign={campaignData} isCampaignLoading={isCampaignLoading} isJoined={isJoined} + totalParticipants={leaderboard?.data.length || 0} /> + {!isCampaignFinished && !!campaignData && ( + + )} {showJoinCampaignButton && ( From 394e1e011ee861328d7a088ffca6dd94f909adb8 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 3 Apr 2026 15:01:52 +0300 Subject: [PATCH 11/27] chore: adjust loading states for campaign details child components; misc styles improvements --- .../src/components/CampaignStats/index.tsx | 53 +++++++++++-------- .../src/pages/CampaignDetails/index.tsx | 21 +++----- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 2fc19a2ee..5999ad62e 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -40,8 +40,6 @@ export const StatsCard = styled(Box, { }), [theme.breakpoints.down('md')]: { - //flexDirection: 'column-reverse', - //height: '100px', height: 'auto', minHeight: '90px', padding: '12px', @@ -85,18 +83,27 @@ export const CardValue = styled(Typography)(({ theme }) => ({ const now = new Date().toISOString(); const renderSkeletonBlocks = (isMobile: boolean) => { - const elements = Array(6).fill(0); return ( - - {elements.map((_, index) => ( - - - - - - - ))} - + + + + + ); }; @@ -194,24 +201,24 @@ const CampaignStats: FC = ({ > - Exchange - {exchangeName} + Symbol + - Campaign Type - {mapTypeToLabel(campaign.type)} + Exchange + {exchangeName} - Symbol - + Campaign Type + {mapTypeToLabel(campaign.type)} diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 92a1f4e1f..bd9f3d4df 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -57,9 +57,6 @@ const CampaignDetails: FC = () => { enabled: campaign?.status === CampaignStatus.ACTIVE, }); - // eslint-disable-next-line no-console - console.log('leaderboard', leaderboard); - const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); if (!encodedData) return undefined; @@ -91,16 +88,14 @@ const CampaignDetails: FC = () => { const campaignData = campaign || parsedData; - const isCampaignFinished = - (campaignData && campaignData.end_date < new Date().toISOString()) || - (campaignData && campaignData?.status !== CampaignStatus.ACTIVE); + const isOngoingCampaign = + !!campaignData && + campaignData.status === CampaignStatus.ACTIVE && + campaignData.start_date < new Date().toISOString() && + campaignData.end_date > new Date().toISOString(); const showJoinCampaignButton = - isMobile && - !!campaignData && - !isJoined && - !isCampaignFinished && - !!exchangeInfo?.enabled; + isMobile && !isJoined && isOngoingCampaign && !!exchangeInfo?.enabled; useReserveLayoutBottomOffset(showJoinCampaignButton); @@ -117,9 +112,7 @@ const CampaignDetails: FC = () => { isJoined={isJoined} totalParticipants={leaderboard?.data.length || 0} /> - {!isCampaignFinished && !!campaignData && ( - - )} + {isOngoingCampaign && } {showJoinCampaignButton && ( From 11759c0a785ddc4fb0664cf0c3d0d5c11444ee95 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 6 Apr 2026 11:57:56 +0300 Subject: [PATCH 12/27] fix: remove unused imports --- .../client/src/components/CampaignsTable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/campaign-launcher/client/src/components/CampaignsTable/index.tsx b/campaign-launcher/client/src/components/CampaignsTable/index.tsx index 6a3c66440..184e0a06a 100644 --- a/campaign-launcher/client/src/components/CampaignsTable/index.tsx +++ b/campaign-launcher/client/src/components/CampaignsTable/index.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; -import { Box, Button, IconButton, Typography } from '@mui/material'; +import { Box, IconButton, Typography } from '@mui/material'; import { DataGrid, type GridColDef } from '@mui/x-data-grid'; import { useNavigate } from 'react-router'; From 1db74d77efa14897acdb35b168b0969ef8790b48 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Tue, 7 Apr 2026 18:14:50 +0300 Subject: [PATCH 13/27] chore: add new fields according to the backend changes --- campaign-launcher/client/src/api/recordingApiClient.ts | 7 +------ .../client/src/pages/CampaignDetails/index.tsx | 2 +- campaign-launcher/client/src/types/index.ts | 4 ++++ 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/campaign-launcher/client/src/api/recordingApiClient.ts b/campaign-launcher/client/src/api/recordingApiClient.ts index 7479fb952..0d618f2d2 100644 --- a/campaign-launcher/client/src/api/recordingApiClient.ts +++ b/campaign-launcher/client/src/api/recordingApiClient.ts @@ -226,12 +226,7 @@ export class RecordingApiClient extends HttpClient { campaign_address: string ): Promise { const response = await this.get( - `/campaigns/${chain_id}-${campaign_address}/leaderboard`, - { - params: { - rank_by: 'current_progress', - }, - } + `/campaigns/${chain_id}-${campaign_address}/leaderboard` ); return response; } diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index bd9f3d4df..2fd044e83 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -110,7 +110,7 @@ const CampaignDetails: FC = () => { campaign={campaignData} isCampaignLoading={isCampaignLoading} isJoined={isJoined} - totalParticipants={leaderboard?.data.length || 0} + totalParticipants={leaderboard?.total || 0} /> {isOngoingCampaign && } {showJoinCampaignButton && ( diff --git a/campaign-launcher/client/src/types/index.ts b/campaign-launcher/client/src/types/index.ts index a6517bc03..7b5ada62f 100644 --- a/campaign-launcher/client/src/types/index.ts +++ b/campaign-launcher/client/src/types/index.ts @@ -134,11 +134,15 @@ export type JoinedCampaignsResponse = { export type LeaderboardEntry = { address: EvmAddress; result: number; + score: number; + estimated_reward: number; rank: number; }; export type LeaderboardResponse = { data: LeaderboardEntry[]; + total: number; + updated_at: string; }; type BaseManifestDto = { From 02177a8f2151a1033b37dd40d1e36a701d76a310 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Tue, 7 Apr 2026 18:24:01 +0300 Subject: [PATCH 14/27] fix: revert totalParticipants --- campaign-launcher/client/src/pages/CampaignDetails/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 2fd044e83..bd9f3d4df 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -110,7 +110,7 @@ const CampaignDetails: FC = () => { campaign={campaignData} isCampaignLoading={isCampaignLoading} isJoined={isJoined} - totalParticipants={leaderboard?.total || 0} + totalParticipants={leaderboard?.data.length || 0} /> {isOngoingCampaign && } {showJoinCampaignButton && ( From 8b272e99113cbc4abaf4e4f73e53a9f2e20b6756 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 8 Apr 2026 11:34:07 +0300 Subject: [PATCH 15/27] chore: minor ui changes, add the total generated widget --- .../src/components/CampaignStats/index.tsx | 6 +++-- .../src/components/CycleInfoSection/index.tsx | 22 ++++++++++++++----- .../src/pages/CampaignDetails/index.tsx | 7 +++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 5999ad62e..4cb04eab5 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -67,8 +67,10 @@ export const CardName = styled(Typography)(({ theme }) => ({ }, })); -export const CardValue = styled(Typography)(({ theme }) => ({ - color: 'white', +export const CardValue = styled(Typography, { + shouldForwardProp: (prop) => prop !== 'color', +})<{ color?: string }>(({ theme, color = 'white' }) => ({ + color, fontSize: '36px', fontWeight: 800, lineHeight: '100%', diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index f27c21e0a..b162927fa 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -5,10 +5,15 @@ import { Box, Grid, Stack, Typography } from '@mui/material'; import { CardName, CardValue, StatsCard } from '@/components/CampaignStats'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { type Campaign } from '@/types'; -import { formatTokenAmount } from '@/utils'; +import { + formatTokenAmount, + getDailyTargetTokenSymbol, + getTokenInfo, +} from '@/utils'; type Props = { campaign: Campaign; + totalGenerated: number; }; const CYCLE_DURATION_MS = 24 * 60 * 60 * 1000; @@ -76,7 +81,7 @@ const useCycleTimeline = (startDate: string, endDate: string) => { return cycleTimeInfo; }; -const CycleInfoSection: FC = ({ campaign }) => { +const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { const isMobile = useIsMobile(); const cycleTimeline = useCycleTimeline( @@ -88,6 +93,9 @@ const CycleInfoSection: FC = ({ campaign }) => { campaign.fund_token_decimals ); + const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); + const { label: targetTokenSymbol } = getTokenInfo(targetToken); + return ( = ({ campaign }) => { Cycle Reward Pool - + {rewardPool} {campaign.fund_token_symbol} @@ -150,14 +158,16 @@ const CycleInfoSection: FC = ({ campaign }) => { Ends in - {cycleTimeline.remainingTime} + + {cycleTimeline.remainingTime} + - Current Cycle + Total Generated Volume - {cycleTimeline.currentCycle} / {cycleTimeline.totalCycles} + {totalGenerated} {targetTokenSymbol} diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index bd9f3d4df..35301e66c 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -112,7 +112,12 @@ const CampaignDetails: FC = () => { isJoined={isJoined} totalParticipants={leaderboard?.data.length || 0} /> - {isOngoingCampaign && } + {isOngoingCampaign && ( + + )} {showJoinCampaignButton && ( From fc006e89ae287ec568557fb3fb1be260c94cccd5 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 8 Apr 2026 14:04:59 +0300 Subject: [PATCH 16/27] feat: complete user widget on details section; utilize cancellation_at field --- .../src/components/CampaignStats/index.tsx | 78 ++++++++++++------- .../src/components/CycleInfoSection/index.tsx | 29 ++++++- .../src/pages/CampaignDetails/index.tsx | 2 +- campaign-launcher/client/src/types/index.ts | 1 + 4 files changed, 80 insertions(+), 30 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 4cb04eab5..1ead854be 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -6,22 +6,22 @@ import CampaignSymbol from '@/components/CampaignSymbol'; import FormattedNumber from '@/components/FormattedNumber'; 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, type CampaignDetails } from '@/types'; import { + CampaignStatus, + type LeaderboardResponse, + type CampaignDetails, +} from '@/types'; +import { + getCompactNumberParts, getDailyTargetTokenSymbol, getTargetInfo, getTokenInfo, mapTypeToLabel, } from '@/utils'; - -type Props = { - campaign: CampaignDetails | null | undefined; - isJoined: boolean; - isCampaignLoading: boolean; - totalParticipants: number; -}; +import dayjs from '@/utils/dayjs'; export const StatsCard = styled(Box, { shouldForwardProp: (prop) => prop !== 'withBorder', @@ -109,21 +109,35 @@ const renderSkeletonBlocks = (isMobile: boolean) => { ); }; +const formatCancellationRequestedAt = (date: number) => { + return dayjs(date).format('Do MMM YYYY HH:mm'); +}; + +type Props = { + campaign: CampaignDetails | null | undefined; + isJoined: boolean; + isCampaignLoading: boolean; + leaderboard?: LeaderboardResponse; +}; + const CampaignStats: FC = ({ campaign, isJoined, isCampaignLoading, - totalParticipants, + leaderboard, }) => { const { exchangesMap } = useExchangesContext(); - const isMobile = useIsMobile(); const { isAuthenticated } = useWeb3Auth(); + const { activeAddress } = useActiveAccount(); + const isMobile = useIsMobile(); if (isCampaignLoading) return renderSkeletonBlocks(isMobile); if (!campaign) return null; - const isCancelled = campaign.status === CampaignStatus.CANCELLED; + const isCancelled = + campaign.status === CampaignStatus.CANCELLED && + !!campaign.cancellation_requested_at; const isOngoingCampaign = campaign.status === CampaignStatus.ACTIVE && @@ -139,11 +153,22 @@ const CampaignStats: FC = ({ isJoined && (isOngoingCampaign || hasProgressBeforeCancel); + const totalParticipants = leaderboard?.data.length || 0; + + const userRank = leaderboard?.data.find( + (entry) => entry.address.toLowerCase() === activeAddress?.toLowerCase() + )?.rank; + const exchangeName = exchangesMap.get(campaign.exchange_name)?.display_name || campaign.exchange_name; 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); @@ -187,9 +212,11 @@ const CampaignStats: FC = ({ Campaign Cancelled - {/* TODO: use cancelled_at and format date */} - Cancelled on {campaign.end_date} + Cancelled on{' '} + {formatCancellationRequestedAt( + campaign.cancellation_requested_at || 0 + )} )} @@ -235,25 +262,24 @@ const CampaignStats: FC = ({ {targetInfo.label} - {showUserPerformance ? ( - - - Ranking - 14 / 81 - - - ) : ( + {isOngoingCampaign && ( - Total Participants - {totalParticipants} + + {showUserPerformance ? 'Ranking' : 'Total Participants'} + + + {showUserPerformance + ? `${userRank} / ${totalParticipants}` + : totalParticipants} + )} diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index b162927fa..f58007c81 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -3,10 +3,12 @@ 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 { type Campaign } from '@/types'; +import { CampaignType, type Campaign } from '@/types'; import { formatTokenAmount, + getCompactNumberParts, getDailyTargetTokenSymbol, getTokenInfo, } from '@/utils'; @@ -81,6 +83,17 @@ const useCycleTimeline = (startDate: string, endDate: string) => { return cycleTimeInfo; }; +const getTotalGeneratedCardTitle = (campaignType: CampaignType) => { + switch (campaignType) { + case CampaignType.MARKET_MAKING: + return 'Total Generated Volume'; + case CampaignType.THRESHOLD: + return 'Total Generated Balance'; + case CampaignType.HOLDING: + return 'Total Held Amount'; + } +}; + const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { const isMobile = useIsMobile(); @@ -96,6 +109,12 @@ const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { const targetToken = getDailyTargetTokenSymbol(campaign.type, campaign.symbol); const { label: targetTokenSymbol } = getTokenInfo(targetToken); + const { + value: totalGeneratedValue, + suffix: totalGeneratedSuffix, + decimals: totalGeneratedDecimals, + } = getCompactNumberParts(totalGenerated); + return ( = ({ campaign, totalGenerated }) => { - Total Generated Volume + {getTotalGeneratedCardTitle(campaign.type)} - {totalGenerated} {targetTokenSymbol} + diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 35301e66c..1c37ca5a4 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -110,7 +110,7 @@ const CampaignDetails: FC = () => { campaign={campaignData} isCampaignLoading={isCampaignLoading} isJoined={isJoined} - totalParticipants={leaderboard?.data.length || 0} + leaderboard={leaderboard} /> {isOngoingCampaign && ( Date: Wed, 8 Apr 2026 14:30:25 +0300 Subject: [PATCH 17/27] fix: show user rank, if user's presented in leaderboard --- .../client/src/components/CampaignStats/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 1ead854be..f81d9e6b6 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -148,17 +148,18 @@ const CampaignStats: FC = ({ campaign.status === CampaignStatus.TO_CANCEL && campaign.reserved_funds !== campaign.balance; - const showUserPerformance = - isAuthenticated && - isJoined && - (isOngoingCampaign || hasProgressBeforeCancel); - const totalParticipants = leaderboard?.data.length || 0; const userRank = leaderboard?.data.find( (entry) => entry.address.toLowerCase() === activeAddress?.toLowerCase() )?.rank; + const showUserPerformance = + isAuthenticated && + isJoined && + !!userRank && + (isOngoingCampaign || hasProgressBeforeCancel); + const exchangeName = exchangesMap.get(campaign.exchange_name)?.display_name || campaign.exchange_name; From 84fa347a7fa307ed5f0fc2526801dc647ac55cd2 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 8 Apr 2026 17:16:23 +0300 Subject: [PATCH 18/27] [Campaign Launcher UI] Leaderboard (#835) --- .../client/src/api/recordingApiClient.ts | 8 +- .../src/components/CampaignStats/index.tsx | 4 +- .../src/components/Leaderboard/List.tsx | 87 +++++ .../components/Leaderboard/MyEntryLabel.tsx | 27 ++ .../src/components/Leaderboard/Overlay.tsx | 135 ++++++++ .../src/components/Leaderboard/index.tsx | 327 ++++++++++++++++++ .../src/hooks/recording-oracle/campaign.ts | 4 +- .../src/pages/CampaignDetails/index.tsx | 17 +- campaign-launcher/client/src/types/index.ts | 13 +- 9 files changed, 612 insertions(+), 10 deletions(-) create mode 100644 campaign-launcher/client/src/components/Leaderboard/List.tsx create mode 100644 campaign-launcher/client/src/components/Leaderboard/MyEntryLabel.tsx create mode 100644 campaign-launcher/client/src/components/Leaderboard/Overlay.tsx create mode 100644 campaign-launcher/client/src/components/Leaderboard/index.tsx diff --git a/campaign-launcher/client/src/api/recordingApiClient.ts b/campaign-launcher/client/src/api/recordingApiClient.ts index 0d618f2d2..414577f74 100644 --- a/campaign-launcher/client/src/api/recordingApiClient.ts +++ b/campaign-launcher/client/src/api/recordingApiClient.ts @@ -11,8 +11,12 @@ import type { ExchangeApiKeyData, UserProgress, CheckCampaignJoinStatusResponse, +<<<<<<< HEAD JoinedCampaignsResponse, LeaderboardResponse, +======= + LeaderboardResponseDto, +>>>>>>> b54af9a7 ([Campaign Launcher UI] Leaderboard (#835)) } from '@/types'; import { HttpClient, HttpError } from '@/utils/HttpClient'; import type { TokenData, TokenManager } from '@/utils/TokenManager'; @@ -224,8 +228,8 @@ export class RecordingApiClient extends HttpClient { async getLeaderboard( chain_id: ChainId, campaign_address: string - ): Promise { - const response = await this.get( + ): Promise { + const response = await this.get( `/campaigns/${chain_id}-${campaign_address}/leaderboard` ); return response; diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index f81d9e6b6..74e02c3e8 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -11,7 +11,7 @@ import { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; import { CampaignStatus, - type LeaderboardResponse, + type Leaderboard, type CampaignDetails, } from '@/types'; import { @@ -117,7 +117,7 @@ type Props = { campaign: CampaignDetails | null | undefined; isJoined: boolean; isCampaignLoading: boolean; - leaderboard?: LeaderboardResponse; + leaderboard?: Leaderboard; }; const CampaignStats: FC = ({ 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..cb9c6a223 --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/List.tsx @@ -0,0 +1,87 @@ +import { memo } from 'react'; + +import { Box, Stack, Typography } from '@mui/material'; + +import FormattedNumber from '@/components/FormattedNumber'; +import { type EvmAddress, type LeaderboardEntry } from '@/types'; +import { formatAddress, getCompactNumberParts } from '@/utils'; + +import MyEntryLabel from './MyEntryLabel'; + +type Props = { + data: LeaderboardEntry[]; + activeAddress: EvmAddress | undefined; +}; + +const LeaderboardList = memo(({ data, activeAddress }: 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.toLowerCase() === activeAddress?.toLowerCase(); + return ( + + + + #{rank} + + + {formatAddress(address)} + + {isMyEntry && } + + + + + + + Score + + + + + + Volume + + + + ); + })} + +)); + +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..7023bc323 --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx @@ -0,0 +1,135 @@ +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 LeaderboardEntry } from '@/types'; + +import LeaderboardList from './List'; + +import { formatActualOnDate } from '.'; + +type Props = { + open: boolean; + onClose: () => void; + data: LeaderboardEntry[]; + updatedAt: string; + symbol: string; +}; + +const LeaderboardOverlay: FC = ({ + open, + onClose, + data, + updatedAt, + symbol, +}) => { + 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..8c73101cc --- /dev/null +++ b/campaign-launcher/client/src/components/Leaderboard/index.tsx @@ -0,0 +1,327 @@ +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 Leaderboard, type Campaign } from '@/types'; +import { formatAddress, getCompactNumberParts } 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.toLowerCase() === activeAddress?.toLowerCase() + ) + : -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}`; +}; + +type Props = { + campaign: Campaign; + leaderboard: Leaderboard; +}; + +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; + + 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.toLowerCase() === activeAddress?.toLowerCase(); + return ( + + + + #{rank} + + + + {formatAddress(address)} + + {isMyEntry && } + + + + + + + + + Volume + + + + + {isMobile ? 'Score:' : 'Score'} + + + + + + + + ); + })} + + + {showList && ( + + )} + {showViewAllButton && ( + setIsOverlayOpen(true)} /> + )} + + + setIsOverlayOpen(false)} + data={leaderboard.data} + updatedAt={leaderboard.updated_at} + symbol={campaign.symbol} + /> + + ); +}; + +export default Leaderboard; diff --git a/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts b/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts index 6832e9d7f..c346bdb19 100644 --- a/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts +++ b/campaign-launcher/client/src/hooks/recording-oracle/campaign.ts @@ -10,7 +10,7 @@ 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, Leaderboard } from '@/types'; type JoinedCampaignsParams = Partial>; @@ -89,7 +89,7 @@ export const useGetLeaderboard = ({ queryKey: [QUERY_KEYS.LEADERBOARD, appChainId, address], queryFn: () => recordingApi.getLeaderboard(appChainId, address), enabled: enabled && !!appChainId && !!address, - select: (data) => ({ + select: (data): Leaderboard => ({ ...data, data: data.data.map((entry, idx) => ({ ...entry, diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 1c37ca5a4..f1db8dc9e 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -8,6 +8,7 @@ import CampaignStats from '@/components/CampaignStats'; import CycleInfoSection from '@/components/CycleInfoSection'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useReserveLayoutBottomOffset } from '@/components/Layout'; +import Leaderboard from '@/components/Leaderboard'; import PageWrapper from '@/components/PageWrapper'; import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; import { useGetLeaderboard } from '@/hooks/recording-oracle/campaign'; @@ -15,7 +16,12 @@ import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; import { useAuthedUserData } from '@/providers/AuthedUserData'; import { useExchangesContext } from '@/providers/ExchangesProvider'; -import { CampaignStatus, type Campaign, type EvmAddress } from '@/types'; +import { + CampaignStatus, + CampaignType, + type Campaign, + type EvmAddress, +} from '@/types'; import { isCampaignDetails } from '@/utils'; const BottomButtonWrapper: FC = ({ children }) => { @@ -99,6 +105,12 @@ const CampaignDetails: FC = () => { useReserveLayoutBottomOffset(showJoinCampaignButton); + const showLeaderboard = + isOngoingCampaign && + campaignData.type !== CampaignType.THRESHOLD && + leaderboard && + leaderboard.data.length > 0; + return ( { totalGenerated={leaderboard?.total || 0} /> )} + {showLeaderboard && ( + + )} {showJoinCampaignButton && ( diff --git a/campaign-launcher/client/src/types/index.ts b/campaign-launcher/client/src/types/index.ts index cf85a55e4..3989a63e2 100644 --- a/campaign-launcher/client/src/types/index.ts +++ b/campaign-launcher/client/src/types/index.ts @@ -132,20 +132,27 @@ export type JoinedCampaignsResponse = { has_more: boolean; }; -export type LeaderboardEntry = { +export type LeaderboardEntryDto = { address: EvmAddress; result: number; score: number; estimated_reward: number; +}; + +export type LeaderboardEntry = LeaderboardEntryDto & { rank: number; }; -export type LeaderboardResponse = { - data: LeaderboardEntry[]; +export type LeaderboardResponseDto = { + data: LeaderboardEntryDto[]; total: number; updated_at: string; }; +export type Leaderboard = Omit & { + data: LeaderboardEntry[]; +}; + type BaseManifestDto = { exchange: string; start_date: string; From fc31671416d3c4d4e5c0ec7fe4bb099e0d3e5a43 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 9 Apr 2026 12:25:46 +0300 Subject: [PATCH 19/27] feat: add the cancel campaign logic --- .../CancelCampaignDialog.tsx | 114 ++++++++++++++++++ .../components/CancelCampaignButton/index.tsx | 57 +++++++++ .../src/components/CycleInfoSection/index.tsx | 6 +- .../src/pages/CampaignDetails/index.tsx | 11 ++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 campaign-launcher/client/src/components/CancelCampaignButton/CancelCampaignDialog.tsx create mode 100644 campaign-launcher/client/src/components/CancelCampaignButton/index.tsx diff --git a/campaign-launcher/client/src/components/CancelCampaignButton/CancelCampaignDialog.tsx b/campaign-launcher/client/src/components/CancelCampaignButton/CancelCampaignDialog.tsx new file mode 100644 index 000000000..55f705ed7 --- /dev/null +++ b/campaign-launcher/client/src/components/CancelCampaignButton/CancelCampaignDialog.tsx @@ -0,0 +1,114 @@ +import { 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 } 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; +}; + +const CancelCampaignDialog: FC = ({ open, onClose, campaign }) => { + const [isCancelling, setIsCancelling] = useState(false); + + const { isSignerReady, signer } = useSignerContext(); + const { showError } = useNotification(); + const queryClient = useQueryClient(); + + const handleCancelCampaign = async () => { + if (!isSignerReady || isCancelling) return; + + setIsCancelling(true); + try { + const client = await EscrowClient.build(signer); + await client.requestCancellation(campaign.address); + await queryClient.invalidateQueries({ + queryKey: [ + QUERY_KEYS.CAMPAIGN_DETAILS, + campaign.chain_id, + campaign.address, + ], + }); + onClose(); + } catch (error) { + console.error('Failed to cancel campaign', error); + showError('Failed to cancel campaign'); + } finally { + setIsCancelling(false); + } + }; + + return ( + + + + + Cancel Campaign + + {isCancelling ? ( + + ) : ( + + 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 + + )} + + + + + + ); +}; + +export default CancelCampaignDialog; diff --git a/campaign-launcher/client/src/components/CancelCampaignButton/index.tsx b/campaign-launcher/client/src/components/CancelCampaignButton/index.tsx new file mode 100644 index 000000000..0ed5759c6 --- /dev/null +++ b/campaign-launcher/client/src/components/CancelCampaignButton/index.tsx @@ -0,0 +1,57 @@ +import { useState, type FC } from 'react'; + +import { Box, Button, Typography } from '@mui/material'; + +import { type Campaign } from '@/types'; + +import CancelCampaignDialog from './CancelCampaignDialog'; + +type Props = { + campaign: Campaign; +}; + +const CancelCampaignButton: FC = ({ campaign }) => { + const [openDialog, setOpenDialog] = useState(false); + + return ( + + + 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 CancelCampaignButton; diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index f58007c81..ba30f3c5d 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -170,7 +170,11 @@ const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { Cycle Reward Pool - {rewardPool} {campaign.fund_token_symbol} + diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index f1db8dc9e..6351af819 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; import CampaignStats from '@/components/CampaignStats'; +import CancelCampaignButton from '@/components/CancelCampaignButton'; import CycleInfoSection from '@/components/CycleInfoSection'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useReserveLayoutBottomOffset } from '@/components/Layout'; @@ -16,6 +17,7 @@ import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; import { useAuthedUserData } from '@/providers/AuthedUserData'; import { useExchangesContext } from '@/providers/ExchangesProvider'; +import { useSignerContext } from '@/providers/SignerProvider'; import { CampaignStatus, CampaignType, @@ -51,6 +53,7 @@ const CampaignDetails: FC = () => { const { address } = useParams() as { address: EvmAddress }; const [searchParams] = useSearchParams(); + const { signer } = useSignerContext(); const { joinedCampaigns } = useAuthedUserData(); const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); @@ -111,6 +114,11 @@ const CampaignDetails: FC = () => { leaderboard && leaderboard.data.length > 0; + const showCancelCampaignButton = + !!campaign && + campaign.status === CampaignStatus.ACTIVE && + campaign.launcher.toLowerCase() === signer?.address?.toLowerCase(); + return ( { {showLeaderboard && ( )} + {showCancelCampaignButton && ( + + )} {showJoinCampaignButton && ( From 9242378df50331e87765e75fa6b83018bd061804 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 9 Apr 2026 14:28:35 +0300 Subject: [PATCH 20/27] feat: add joinedAt block --- .../src/components/CampaignInfo/index.tsx | 50 ++++++++++++++++++- .../src/pages/CampaignDetails/index.tsx | 14 ++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 9e4ef1b1f..3d29d4c1f 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -22,6 +22,10 @@ const formatDate = (dateString: string): string => { return dayjs(dateString).format('Do MMM YYYY'); }; +const formatJoinTime = (dateString: string): string => { + return dayjs(dateString).format('HH:mm'); +}; + const DividerStyled = styled(MuiDivider)({ borderColor: 'rgba(255, 255, 255, 0.3)', height: 16, @@ -32,9 +36,17 @@ type Props = { campaign: CampaignDetails | null | undefined; isCampaignLoading: boolean; isJoined: boolean; + joinedAt: string | undefined; + isJoinStatusLoading: boolean; }; -const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { +const CampaignInfo: FC = ({ + campaign, + isCampaignLoading, + isJoined, + joinedAt, + isJoinStatusLoading, +}) => { const isMobile = useIsMobile(); const { activeAddress } = useActiveAccount(); @@ -47,6 +59,9 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { + {isJoined && ( + + )} ); } @@ -55,6 +70,9 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { + {isJoined && ( + + )} ); } @@ -174,6 +192,36 @@ const CampaignInfo: FC = ({ campaign, isCampaignLoading, isJoined }) => { {oracleFee}% Oracle fees + {!isJoinStatusLoading && joinedAt && ( + + + Joined at + + + {' '} + {formatDate(joinedAt)} + {', '} + {formatJoinTime(joinedAt)} + + + )} ); }; diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 6351af819..5ebe8a225 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -12,7 +12,10 @@ import { useReserveLayoutBottomOffset } from '@/components/Layout'; import Leaderboard from '@/components/Leaderboard'; import PageWrapper from '@/components/PageWrapper'; import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; -import { useGetLeaderboard } from '@/hooks/recording-oracle/campaign'; +import { + useCheckCampaignJoinStatus, + useGetLeaderboard, +} from '@/hooks/recording-oracle/campaign'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useCampaignDetails } from '@/hooks/useCampaigns'; import { useAuthedUserData } from '@/providers/AuthedUserData'; @@ -66,6 +69,9 @@ const CampaignDetails: FC = () => { enabled: campaign?.status === CampaignStatus.ACTIVE, }); + const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = + useCheckCampaignJoinStatus(address); + const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); if (!encodedData) return undefined; @@ -89,9 +95,9 @@ const CampaignDetails: FC = () => { const isJoined = useMemo(() => { return !!joinedCampaigns?.results.some( (joinedCampaign) => - joinedCampaign.address.toLowerCase() === campaign?.address.toLowerCase() + joinedCampaign.address.toLowerCase() === address.toLowerCase() ); - }, [joinedCampaigns?.results, campaign?.address]); + }, [joinedCampaigns?.results, address]); const exchangeInfo = exchangesMap.get(campaign?.exchange_name || ''); @@ -125,6 +131,8 @@ const CampaignDetails: FC = () => { campaign={campaignData} isCampaignLoading={isCampaignLoading} isJoined={isJoined} + joinedAt={joinStatusInfo?.joined_at} + isJoinStatusLoading={isJoinStatusLoading} /> Date: Thu, 9 Apr 2026 16:16:24 +0300 Subject: [PATCH 21/27] feat: add campaign results section; a minor ui adjustments --- .../CampaignResultsSection/index.tsx | 104 ++++++++++++++++ .../CampaignResultsWidget/index.tsx | 117 ------------------ .../src/components/CampaignStats/index.tsx | 5 +- .../CancelCampaignDialog.tsx | 0 .../index.tsx | 6 +- .../src/components/CycleInfoSection/index.tsx | 1 + .../src/components/Leaderboard/index.tsx | 13 +- .../src/hooks/recording-oracle/campaign.ts | 8 +- .../src/pages/CampaignDetails/index.tsx | 6 +- campaign-launcher/client/src/types/index.ts | 2 +- 10 files changed, 132 insertions(+), 130 deletions(-) create mode 100644 campaign-launcher/client/src/components/CampaignResultsSection/index.tsx delete mode 100644 campaign-launcher/client/src/components/CampaignResultsWidget/index.tsx rename campaign-launcher/client/src/components/{CancelCampaignButton => CancelCampaignSection}/CancelCampaignDialog.tsx (100%) rename campaign-launcher/client/src/components/{CancelCampaignButton => CancelCampaignSection}/index.tsx (88%) 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 da643c19c..000000000 --- a/campaign-launcher/client/src/components/CampaignResultsWidget/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { FC } from 'react'; - -import { Box, Link, Stack, Typography } from '@mui/material'; - -import { OpenInNewIcon } from '@/icons'; -import { CampaignStatus } from '@/types'; - -type Props = { - campaignStatus: CampaignStatus; - finalResultsUrl: string | null; - intermediateResultsUrl: string | null; -}; - -const RESULT = { - none: { - label: 'N/A', - bgcolor: 'error.main', - cardBgColor: '#361034', - }, - intermediate: { - label: 'Intermediate', - bgcolor: 'warning.main', - cardBgColor: 'rgba(255, 187, 0, 0.20)', - }, - final: { - label: 'Final', - bgcolor: 'success.main', - cardBgColor: 'rgba(83, 255, 60, 0.15)', - }, -}; - -type ResultType = typeof RESULT; - -const resultStyles = { - color: '#a496c2', - fontSize: { xs: '14px', md: '16px' }, - fontWeight: { xs: 400, md: 600 }, - letterSpacing: { xs: '0px', md: '1.5px' }, - textTransform: { xs: 'none', md: 'uppercase' }, - lineHeight: { xs: '150%', md: '18px' }, -}; - -const CampaignResultsWidget: FC = ({ - campaignStatus, - finalResultsUrl, - intermediateResultsUrl, -}) => { - 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 ( - - {resultUrl ? ( - - Campaign results - - - ) : ( - Campaign results - )} - - - - {result.label} - - - - ); -}; - -export default CampaignResultsWidget; diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 74e02c3e8..6ae68da0c 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -11,7 +11,7 @@ import { useExchangesContext } from '@/providers/ExchangesProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; import { CampaignStatus, - type Leaderboard, + type LeaderboardData, type CampaignDetails, } from '@/types'; import { @@ -117,7 +117,7 @@ type Props = { campaign: CampaignDetails | null | undefined; isJoined: boolean; isCampaignLoading: boolean; - leaderboard?: Leaderboard; + leaderboard?: LeaderboardData; }; const CampaignStats: FC = ({ @@ -176,6 +176,7 @@ const CampaignStats: FC = ({ return ( = ({ campaign }) => { +const CancelCampaignSection: FC = ({ campaign }) => { const [openDialog, setOpenDialog] = useState(false); return ( - + = ({ campaign }) => { ); }; -export default CancelCampaignButton; +export default CancelCampaignSection; diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index ba30f3c5d..f026f2dd5 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -117,6 +117,7 @@ const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { return ( { type Props = { campaign: Campaign; - leaderboard: Leaderboard; + leaderboard: LeaderboardData; }; const Leaderboard: FC = ({ campaign, leaderboard }) => { @@ -104,7 +104,14 @@ const Leaderboard: FC = ({ campaign, leaderboard }) => { const showViewAllButton = leaderboard.data.length > 8; return ( - + >; @@ -89,7 +93,7 @@ export const useGetLeaderboard = ({ queryKey: [QUERY_KEYS.LEADERBOARD, appChainId, address], queryFn: () => recordingApi.getLeaderboard(appChainId, address), enabled: enabled && !!appChainId && !!address, - select: (data): Leaderboard => ({ + select: (data): LeaderboardData => ({ ...data, data: data.data.map((entry, idx) => ({ ...entry, diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 5ebe8a225..0964364fd 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -4,8 +4,9 @@ import { Box } from '@mui/material'; import { useParams, useSearchParams } from 'react-router'; import CampaignInfo from '@/components/CampaignInfo'; +import CampaignResultsSection from '@/components/CampaignResultsSection'; import CampaignStats from '@/components/CampaignStats'; -import CancelCampaignButton from '@/components/CancelCampaignButton'; +import CancelCampaignSection from '@/components/CancelCampaignSection'; import CycleInfoSection from '@/components/CycleInfoSection'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useReserveLayoutBottomOffset } from '@/components/Layout'; @@ -149,8 +150,9 @@ const CampaignDetails: FC = () => { {showLeaderboard && ( )} + {!!campaign && } {showCancelCampaignButton && ( - + )} {showJoinCampaignButton && ( diff --git a/campaign-launcher/client/src/types/index.ts b/campaign-launcher/client/src/types/index.ts index 3989a63e2..58641adb5 100644 --- a/campaign-launcher/client/src/types/index.ts +++ b/campaign-launcher/client/src/types/index.ts @@ -149,7 +149,7 @@ export type LeaderboardResponseDto = { updated_at: string; }; -export type Leaderboard = Omit & { +export type LeaderboardData = Omit & { data: LeaderboardEntry[]; }; From c288dde257a664fa82df786518c3ae35e9594f5a Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 9 Apr 2026 16:24:24 +0300 Subject: [PATCH 22/27] feat: special treatment of threshold campaign's cycle info --- .../src/components/CycleInfoSection/index.tsx | 38 ++++++++++++------- .../src/pages/CampaignDetails/index.tsx | 7 +--- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index f026f2dd5..b7fd0b289 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -5,7 +5,7 @@ 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 Campaign } from '@/types'; +import { CampaignType, type LeaderboardData, type Campaign } from '@/types'; import { formatTokenAmount, getCompactNumberParts, @@ -15,7 +15,7 @@ import { type Props = { campaign: Campaign; - totalGenerated: number; + leaderboard: LeaderboardData; }; const CYCLE_DURATION_MS = 24 * 60 * 60 * 1000; @@ -87,16 +87,16 @@ const getTotalGeneratedCardTitle = (campaignType: CampaignType) => { switch (campaignType) { case CampaignType.MARKET_MAKING: return 'Total Generated Volume'; - case CampaignType.THRESHOLD: - return 'Total Generated Balance'; case CampaignType.HOLDING: - return 'Total Held Amount'; + return 'Total Generated Balance'; } }; -const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { +const CycleInfoSection: FC = ({ campaign, leaderboard }) => { const isMobile = useIsMobile(); + const isThreshold = campaign.type === CampaignType.THRESHOLD; + const cycleTimeline = useCycleTimeline( campaign.start_date, campaign.end_date @@ -113,7 +113,11 @@ const CycleInfoSection: FC = ({ campaign, totalGenerated }) => { value: totalGeneratedValue, suffix: totalGeneratedSuffix, decimals: totalGeneratedDecimals, - } = getCompactNumberParts(totalGenerated); + } = getCompactNumberParts(leaderboard.total); + + const eligibleParticipants = leaderboard.data.filter( + (entry) => entry.score > 0 + ); return ( = ({ campaign, totalGenerated }) => { - {getTotalGeneratedCardTitle(campaign.type)} + + {isThreshold + ? 'Eligible Participants' + : getTotalGeneratedCardTitle(campaign.type)} + - + {isThreshold ? ( + eligibleParticipants.length + ) : ( + + )} diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index 0964364fd..d2816b73a 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -141,11 +141,8 @@ const CampaignDetails: FC = () => { isJoined={isJoined} leaderboard={leaderboard} /> - {isOngoingCampaign && ( - + {isOngoingCampaign && !!leaderboard && ( + )} {showLeaderboard && ( From 92e4e117a7983ab86a5117b6fbd2b233fdc5cab4 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 9 Apr 2026 16:29:25 +0300 Subject: [PATCH 23/27] fix: a minor cosmetic change --- .../client/src/hooks/recording-oracle/exchangeApiKeys.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts b/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts index 74edbace7..4399cc6f4 100644 --- a/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts +++ b/campaign-launcher/client/src/hooks/recording-oracle/exchangeApiKeys.ts @@ -7,6 +7,7 @@ import * as errorUtils from '@/utils/error'; export const useGetEnrolledExchanges = () => { const { isAuthenticated } = useWeb3Auth(); + return useQuery({ queryKey: [QUERY_KEYS.ENROLLED_EXCHANGES, AUTHED_QUERY_TAG], queryFn: () => recordingApi.getEnrolledExchanges(), From 4c7510f2811a18e03319aae3fe944863725bb064 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 10 Apr 2026 13:44:56 +0300 Subject: [PATCH 24/27] chore: address feedback --- .../src/components/CampaignStats/index.tsx | 7 +- .../CancelCampaignDialog.tsx | 73 +++++++-- .../CancelCampaignSection/index.tsx | 73 +++++---- .../src/components/CycleInfoSection/index.tsx | 54 ++----- .../components/LaunchStep.tsx | 2 +- .../src/components/Leaderboard/List.tsx | 148 ++++++++++-------- .../src/components/Leaderboard/Overlay.tsx | 13 +- .../src/components/Leaderboard/index.tsx | 31 +++- .../client/src/constants/queryKeys.ts | 1 - .../client/src/hooks/useCampaigns.ts | 11 +- .../src/pages/CampaignDetails/index.tsx | 1 - 11 files changed, 237 insertions(+), 177 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 6ae68da0c..3fe54a90b 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -13,6 +13,7 @@ import { CampaignStatus, type LeaderboardData, type CampaignDetails, + CampaignType, } from '@/types'; import { getCompactNumberParts, @@ -135,9 +136,8 @@ const CampaignStats: FC = ({ if (!campaign) return null; - const isCancelled = - campaign.status === CampaignStatus.CANCELLED && - !!campaign.cancellation_requested_at; + const isCancelled = campaign.status === CampaignStatus.CANCELLED; + const isThresholdCampaign = campaign.type === CampaignType.THRESHOLD; const isOngoingCampaign = campaign.status === CampaignStatus.ACTIVE && @@ -157,6 +157,7 @@ const CampaignStats: FC = ({ const showUserPerformance = isAuthenticated && isJoined && + !isThresholdCampaign && !!userRank && (isOngoingCampaign || hasProgressBeforeCancel); diff --git a/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx b/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx index 55f705ed7..eba55484b 100644 --- a/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx +++ b/campaign-launcher/client/src/components/CancelCampaignSection/CancelCampaignDialog.tsx @@ -1,10 +1,10 @@ -import { useState, type FC } from 'react'; +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 } from '@/components/ModalState'; +import { ModalLoading, ModalSuccess } from '@/components/ModalState'; import ResponsiveOverlay from '@/components/ResponsiveOverlay'; import { QUERY_KEYS } from '@/constants/queryKeys'; import { useNotification } from '@/hooks/useNotification'; @@ -18,13 +18,17 @@ type Props = { 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; @@ -32,14 +36,14 @@ const CancelCampaignDialog: FC = ({ open, onClose, campaign }) => { try { const client = await EscrowClient.build(signer); await client.requestCancellation(campaign.address); - await queryClient.invalidateQueries({ + setIsCancelled(true); + queryClient.invalidateQueries({ queryKey: [ QUERY_KEYS.CAMPAIGN_DETAILS, campaign.chain_id, campaign.address, ], }); - onClose(); } catch (error) { console.error('Failed to cancel campaign', error); showError('Failed to cancel campaign'); @@ -48,13 +52,20 @@ const CancelCampaignDialog: FC = ({ open, onClose, campaign }) => { } }; + const handleClose = useCallback(() => { + if (isCancelled) { + setIsCancelled(false); + } + onClose(); + }, [onClose, isCancelled]); + return ( = ({ open, onClose, campaign }) => { /> Cancel Campaign - {isCancelling ? ( - - ) : ( + {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 && ( = ({ open, onClose, campaign }) => { px={{ xs: 2, md: 4 }} borderTop="1px solid #3a2e6f" > - + {isCancelled && ( + + )} + {!isCancelled && ( + + )} ); diff --git a/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx index 04bb7bec6..c7786ff25 100644 --- a/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx +++ b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx @@ -2,7 +2,7 @@ import { useState, type FC } from 'react'; import { Box, Button, Typography } from '@mui/material'; -import { type Campaign } from '@/types'; +import { CampaignStatus, type Campaign } from '@/types'; import CancelCampaignDialog from './CancelCampaignDialog'; @@ -10,47 +10,56 @@ type Props = { campaign: Campaign; }; +// TODO: Update the copy const CancelCampaignSection: FC = ({ campaign }) => { const [openDialog, setOpenDialog] = useState(false); + const isActive = campaign.status === CampaignStatus.ACTIVE; + return ( - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. Ut - - + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + + + setOpenDialog(false)} campaign={campaign} /> - + ); }; diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index b7fd0b289..bca8c5889 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -12,35 +12,13 @@ import { getDailyTargetTokenSymbol, getTokenInfo, } from '@/utils'; +import dayjs from '@/utils/dayjs'; type Props = { campaign: Campaign; leaderboard: LeaderboardData; }; -const CYCLE_DURATION_MS = 24 * 60 * 60 * 1000; - -const formatDuration = (milliseconds: number) => { - const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours.toString().padStart(2, '0')}h:${minutes - .toString() - .padStart(2, '0')}m:${seconds.toString().padStart(2, '0')}s`; - } - - if (minutes > 0) { - return `${minutes.toString().padStart(2, '0')}m:${seconds - .toString() - .padStart(2, '0')}s`; - } - - return `${seconds}s`; -}; - const useCycleTimeline = (startDate: string, endDate: string) => { const [now, setNow] = useState(() => Date.now()); @@ -55,28 +33,30 @@ const useCycleTimeline = (startDate: string, endDate: string) => { }, []); const cycleTimeInfo = useMemo(() => { - const start = new Date(startDate).getTime(); - const end = new Date(endDate).getTime(); + const start = dayjs(startDate); + const end = dayjs(endDate); + const nowDate = dayjs(now); - const totalCycles = Math.ceil((end - start) / CYCLE_DURATION_MS); - const effectiveNow = Math.min(Math.max(now, start), end); - const elapsedSinceStart = effectiveNow - start; + const totalCycles = Math.max(1, Math.ceil(end.diff(start, 'day', true))); + const effectiveNow = dayjs( + Math.min(Math.max(nowDate.valueOf(), start.valueOf()), end.valueOf()) + ); const currentCycle = Math.min( totalCycles, - Math.floor(elapsedSinceStart / CYCLE_DURATION_MS) + 1 - ); - - const currentCycleStart = start + (currentCycle - 1) * CYCLE_DURATION_MS; - const currentCycleEnd = Math.min( - currentCycleStart + CYCLE_DURATION_MS, - end + Math.floor(effectiveNow.diff(start, 'day', true)) + 1 ); - const remainingMs = Math.max(0, currentCycleEnd - now); + 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: formatDuration(remainingMs), + remainingTime: dayjs + .duration(Math.max(0, remainingMs)) + .format('HH[h]:mm[m]:ss[s]'), }; }, [startDate, endDate, now]); diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx index fc75a3346..4047a9cb1 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx @@ -89,7 +89,7 @@ const LaunchStep: FC = ({ }; const payload = constructCampaignDetails({ chainId, - address: escrowAddress, + address: escrowAddress.toLowerCase(), data: formData, tokenDecimals, fees, diff --git a/campaign-launcher/client/src/components/Leaderboard/List.tsx b/campaign-launcher/client/src/components/Leaderboard/List.tsx index cb9c6a223..cd4eabd2b 100644 --- a/campaign-launcher/client/src/components/Leaderboard/List.tsx +++ b/campaign-launcher/client/src/components/Leaderboard/List.tsx @@ -3,85 +3,97 @@ import { memo } from 'react'; import { Box, Stack, Typography } from '@mui/material'; import FormattedNumber from '@/components/FormattedNumber'; -import { type EvmAddress, type LeaderboardEntry } from '@/types'; +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 }: 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.toLowerCase() === activeAddress?.toLowerCase(); - return ( - - - - #{rank} - - - {formatAddress(address)} - - {isMyEntry && } - - - - - +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.toLowerCase() === activeAddress?.toLowerCase(); + return ( + + + + #{rank} - Score - - - - + + {formatAddress(address)} - Volume - + {isMyEntry && } + + + + + + + Score + + + + + + + {getTargetLabel(campaignType)} + + + - - ); - })} - -)); + ); + })} + + ) +); export default LeaderboardList; diff --git a/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx b/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx index 7023bc323..12f4be48d 100644 --- a/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx +++ b/campaign-launcher/client/src/components/Leaderboard/Overlay.tsx @@ -18,7 +18,7 @@ import { import ResponsiveOverlay from '@/components/ResponsiveOverlay'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useActiveAccount } from '@/providers/ActiveAccountProvider'; -import { type LeaderboardEntry } from '@/types'; +import { type CampaignType, type LeaderboardEntry } from '@/types'; import LeaderboardList from './List'; @@ -30,6 +30,8 @@ type Props = { data: LeaderboardEntry[]; updatedAt: string; symbol: string; + campaignType: CampaignType; + tokenSymbol: string; }; const LeaderboardOverlay: FC = ({ @@ -38,6 +40,8 @@ const LeaderboardOverlay: FC = ({ data, updatedAt, symbol, + campaignType, + tokenSymbol, }) => { const [search, setSearch] = useState(''); const deferredSearch = useDeferredValue(search); @@ -126,7 +130,12 @@ const LeaderboardOverlay: FC = ({ }} /> - + ); diff --git a/campaign-launcher/client/src/components/Leaderboard/index.tsx b/campaign-launcher/client/src/components/Leaderboard/index.tsx index 50c7d5640..da87ba768 100644 --- a/campaign-launcher/client/src/components/Leaderboard/index.tsx +++ b/campaign-launcher/client/src/components/Leaderboard/index.tsx @@ -5,8 +5,13 @@ 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 } from '@/types'; -import { formatAddress, getCompactNumberParts } from '@/utils'; +import { type LeaderboardData, type Campaign, CampaignType } from '@/types'; +import { + formatAddress, + getCompactNumberParts, + getDailyTargetTokenSymbol, + getTokenInfo, +} from '@/utils'; import dayjs from '@/utils/dayjs'; import LeaderboardList from './List'; @@ -84,6 +89,16 @@ export const formatActualOnDate = (date: string) => { 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; @@ -103,6 +118,9 @@ const Leaderboard: FC = ({ campaign, leaderboard }) => { 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 ( = ({ campaign, leaderboard }) => { = ({ campaign, leaderboard }) => { fontWeight={500} lineHeight={1} > - Volume + {getTargetLabel(campaign.type)} = ({ campaign, leaderboard }) => { )} {showViewAllButton && ( @@ -326,6 +345,8 @@ const Leaderboard: FC = ({ campaign, leaderboard }) => { data={leaderboard.data} updatedAt={leaderboard.updated_at} symbol={campaign.symbol} + campaignType={campaign.type} + tokenSymbol={targetTokenSymbol || ''} /> ); diff --git a/campaign-launcher/client/src/constants/queryKeys.ts b/campaign-launcher/client/src/constants/queryKeys.ts index 788516e3a..e80cc18e2 100644 --- a/campaign-launcher/client/src/constants/queryKeys.ts +++ b/campaign-launcher/client/src/constants/queryKeys.ts @@ -14,6 +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/useCampaigns.ts b/campaign-launcher/client/src/hooks/useCampaigns.ts index 4586d7f6d..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], diff --git a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx index d2816b73a..fd3a8fedc 100644 --- a/campaign-launcher/client/src/pages/CampaignDetails/index.tsx +++ b/campaign-launcher/client/src/pages/CampaignDetails/index.tsx @@ -123,7 +123,6 @@ const CampaignDetails: FC = () => { const showCancelCampaignButton = !!campaign && - campaign.status === CampaignStatus.ACTIVE && campaign.launcher.toLowerCase() === signer?.address?.toLowerCase(); return ( From 6e7a8f55d7b02ae17324d2a31ee9478a3c86e4e7 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 10 Apr 2026 18:08:30 +0300 Subject: [PATCH 25/27] refactor: normalize address and remove toLowerCase; improve the cycles info calculation --- .../src/components/CampaignInfo/index.tsx | 3 +- .../src/components/CampaignStats/index.tsx | 2 +- .../CancelCampaignSection/index.tsx | 77 ++++++++++--------- .../src/components/CycleInfoSection/index.tsx | 9 +-- .../components/JoinCampaignButton/index.tsx | 4 +- .../components/LaunchStep.tsx | 2 +- .../src/components/Leaderboard/List.tsx | 3 +- .../src/components/Leaderboard/index.tsx | 7 +- .../src/pages/CampaignDetails/index.tsx | 20 ++--- 9 files changed, 57 insertions(+), 70 deletions(-) diff --git a/campaign-launcher/client/src/components/CampaignInfo/index.tsx b/campaign-launcher/client/src/components/CampaignInfo/index.tsx index 3d29d4c1f..9712165ea 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -50,8 +50,7 @@ const CampaignInfo: FC = ({ const isMobile = useIsMobile(); const { activeAddress } = useActiveAccount(); - const isHosted = - campaign?.launcher?.toLowerCase() === activeAddress?.toLowerCase(); + const isHosted = campaign?.launcher === activeAddress; if (isCampaignLoading) { if (isMobile) { diff --git a/campaign-launcher/client/src/components/CampaignStats/index.tsx b/campaign-launcher/client/src/components/CampaignStats/index.tsx index 3fe54a90b..941099fbd 100644 --- a/campaign-launcher/client/src/components/CampaignStats/index.tsx +++ b/campaign-launcher/client/src/components/CampaignStats/index.tsx @@ -151,7 +151,7 @@ const CampaignStats: FC = ({ const totalParticipants = leaderboard?.data.length || 0; const userRank = leaderboard?.data.find( - (entry) => entry.address.toLowerCase() === activeAddress?.toLowerCase() + (entry) => entry.address === activeAddress )?.rank; const showUserPerformance = diff --git a/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx index c7786ff25..0523e5875 100644 --- a/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx +++ b/campaign-launcher/client/src/components/CancelCampaignSection/index.tsx @@ -2,6 +2,7 @@ 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'; @@ -14,46 +15,48 @@ type Props = { const CancelCampaignSection: FC = ({ campaign }) => { const [openDialog, setOpenDialog] = useState(false); - const isActive = campaign.status === CampaignStatus.ACTIVE; + const { signer } = useSignerContext(); + + const showSection = + campaign.status === CampaignStatus.ACTIVE && + campaign.launcher === signer?.address; return ( <> - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut - - - + {showSection && ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + + + + )} setOpenDialog(false)} diff --git a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx index bca8c5889..5e39e60f9 100644 --- a/campaign-launcher/client/src/components/CycleInfoSection/index.tsx +++ b/campaign-launcher/client/src/components/CycleInfoSection/index.tsx @@ -38,13 +38,8 @@ const useCycleTimeline = (startDate: string, endDate: string) => { const nowDate = dayjs(now); const totalCycles = Math.max(1, Math.ceil(end.diff(start, 'day', true))); - const effectiveNow = dayjs( - Math.min(Math.max(nowDate.valueOf(), start.valueOf()), end.valueOf()) - ); - const currentCycle = Math.min( - totalCycles, - Math.floor(effectiveNow.diff(start, 'day', true)) + 1 - ); + 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 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/LaunchCampaignForm/components/LaunchStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx index 4047a9cb1..fc75a3346 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/LaunchStep.tsx @@ -89,7 +89,7 @@ const LaunchStep: FC = ({ }; const payload = constructCampaignDetails({ chainId, - address: escrowAddress.toLowerCase(), + address: escrowAddress, data: formData, tokenDecimals, fees, diff --git a/campaign-launcher/client/src/components/Leaderboard/List.tsx b/campaign-launcher/client/src/components/Leaderboard/List.tsx index cd4eabd2b..84056bf37 100644 --- a/campaign-launcher/client/src/components/Leaderboard/List.tsx +++ b/campaign-launcher/client/src/components/Leaderboard/List.tsx @@ -36,8 +36,7 @@ const LeaderboardList = memo( suffix: scoreSuffix, decimals: scoreDecimals, } = getCompactNumberParts(score); - const isMyEntry = - address.toLowerCase() === activeAddress?.toLowerCase(); + const isMyEntry = address === activeAddress; return ( entry.address.toLowerCase() === activeAddress?.toLowerCase() - ) + ? leaderboard.findIndex((entry) => entry.address === activeAddress) : -1; if (userIndex === -1) { @@ -188,8 +186,7 @@ const Leaderboard: FC = ({ campaign, leaderboard }) => { suffix: scoreSuffix, decimals: scoreDecimals, } = getCompactNumberParts(score); - const isMyEntry = - address.toLowerCase() === activeAddress?.toLowerCase(); + const isMyEntry = address === activeAddress; return ( { const { address } = useParams() as { address: EvmAddress }; const [searchParams] = useSearchParams(); - const { signer } = useSignerContext(); + const normalizedAddress = getAddress(address) as EvmAddress; + const { joinedCampaigns } = useAuthedUserData(); const { exchangesMap } = useExchangesContext(); const isMobile = useIsMobile(); const { data: campaign, isFetching: isCampaignLoading } = - useCampaignDetails(address); + useCampaignDetails(normalizedAddress); const { data: leaderboard } = useGetLeaderboard({ address: campaign?.address || '', @@ -71,7 +72,7 @@ const CampaignDetails: FC = () => { }); const { data: joinStatusInfo, isLoading: isJoinStatusLoading } = - useCheckCampaignJoinStatus(address); + useCheckCampaignJoinStatus(normalizedAddress); const parsedData = useMemo(() => { const encodedData = searchParams.get('data'); @@ -95,10 +96,9 @@ const CampaignDetails: FC = () => { const isJoined = useMemo(() => { return !!joinedCampaigns?.results.some( - (joinedCampaign) => - joinedCampaign.address.toLowerCase() === address.toLowerCase() + (joinedCampaign) => joinedCampaign.address === normalizedAddress ); - }, [joinedCampaigns?.results, address]); + }, [joinedCampaigns?.results, normalizedAddress]); const exchangeInfo = exchangesMap.get(campaign?.exchange_name || ''); @@ -121,10 +121,6 @@ const CampaignDetails: FC = () => { leaderboard && leaderboard.data.length > 0; - const showCancelCampaignButton = - !!campaign && - campaign.launcher.toLowerCase() === signer?.address?.toLowerCase(); - return ( { )} {!!campaign && } - {showCancelCampaignButton && ( + {!!campaign && ( )} {showJoinCampaignButton && ( From bb998a1113731ed22d99d9039e5ec0c03a594819 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 13 Apr 2026 20:21:28 +0300 Subject: [PATCH 26/27] fix: merge conflict --- campaign-launcher/client/src/api/recordingApiClient.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/campaign-launcher/client/src/api/recordingApiClient.ts b/campaign-launcher/client/src/api/recordingApiClient.ts index 414577f74..0e0072828 100644 --- a/campaign-launcher/client/src/api/recordingApiClient.ts +++ b/campaign-launcher/client/src/api/recordingApiClient.ts @@ -11,12 +11,8 @@ import type { ExchangeApiKeyData, UserProgress, CheckCampaignJoinStatusResponse, -<<<<<<< HEAD JoinedCampaignsResponse, - LeaderboardResponse, -======= LeaderboardResponseDto, ->>>>>>> b54af9a7 ([Campaign Launcher UI] Leaderboard (#835)) } from '@/types'; import { HttpClient, HttpError } from '@/utils/HttpClient'; import type { TokenData, TokenManager } from '@/utils/TokenManager'; From fe1bb506d63b504e7e7bf3e77f49997a0a899feb Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 13 Apr 2026 20:51:32 +0300 Subject: [PATCH 27/27] chore: add tooltip to campaign start_date and end_date --- .../src/components/CampaignAddress/index.tsx | 2 - .../src/components/CampaignInfo/index.tsx | 68 +++++++++++++++---- 2 files changed, 55 insertions(+), 15 deletions(-) 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 9712165ea..c2b5ed287 100644 --- a/campaign-launcher/client/src/components/CampaignInfo/index.tsx +++ b/campaign-launcher/client/src/components/CampaignInfo/index.tsx @@ -11,6 +11,7 @@ import { import CampaignAddress from '@/components/CampaignAddress'; import CampaignStatusLabel from '@/components/CampaignStatusLabel'; +import CustomTooltip from '@/components/CustomTooltip'; import JoinCampaignButton from '@/components/JoinCampaignButton'; import { useIsMobile } from '@/hooks/useBreakpoints'; import { useActiveAccount } from '@/providers/ActiveAccountProvider'; @@ -22,6 +23,11 @@ const formatDate = (dateString: string): string => { return dayjs(dateString).format('Do MMM YYYY'); }; +const formatTime = (dateString: string): string => { + const date = dayjs(dateString); + return date.format('HH:mm [GMT]Z'); +}; + const formatJoinTime = (dateString: string): string => { return dayjs(dateString).format('HH:mm'); }; @@ -168,19 +174,55 @@ const CampaignInfo: FC = ({ )} - - {formatDate(campaign.start_date)} - {' > '} - {formatDate(campaign.end_date)} - + + + + {formatDate(campaign.start_date)} + + + + > + + + + {formatDate(campaign.end_date)} + + +