From fc5a4c35b4eaed1fc6d747d79c7eea676b400f9d Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 22 May 2024 11:23:40 +0200 Subject: [PATCH 1/6] feat: points page (#176) --- package.json | 2 +- pages/index.tsx | 4 +- pages/points.tsx | 8 + public/images/horizontal_barcode.svg | 28 ++ public/images/neon-star.svg | 31 ++ src/analytics/navigation.ts | 3 + src/components/BoostCounter/index.tsx | 2 +- src/components/PageLayout/index.tsx | 11 +- src/components/Points/ActivityList.tsx | 31 ++ src/components/Points/ActivityPointsFeed.tsx | 133 +++++++++ src/components/Points/CampaignInfo.tsx | 81 +++++ src/components/Points/CampaignLeaderboard.tsx | 282 ++++++++++++++++++ src/components/Points/CampaignSelector.tsx | 31 ++ src/components/Points/CampaignTabs.tsx | 43 +++ src/components/Points/TotalCampaignStats.tsx | 37 +++ src/components/Points/index.tsx | 102 +++++++ src/components/Points/styles.module.css | 20 ++ src/components/PointsCounter/index.tsx | 76 +++++ src/components/SafePassDisclaimer/index.tsx | 36 +++ .../TockenUnlocking/WithdrawWidget.tsx | 5 +- .../TokenLocking/ActivityRewardsInfo.tsx | 4 +- .../TokenLocking/BoostBreakdown.tsx | 4 +- src/components/TokenLocking/Leaderboard.tsx | 14 +- src/components/TokenLocking/index.tsx | 30 +- src/config/constants.ts | 5 + src/config/routes.ts | 1 + src/hooks/useCampaigns.ts | 60 ++++ src/hooks/useGatewayBaseUrl.ts | 7 + src/hooks/useGlobalCampaignId.ts | 7 + src/hooks/useLeaderboard.ts | 87 ++++-- src/hooks/useLockHistory.ts | 2 +- src/utils/date.ts | 11 +- 32 files changed, 1133 insertions(+), 65 deletions(-) create mode 100644 pages/points.tsx create mode 100644 public/images/horizontal_barcode.svg create mode 100644 public/images/neon-star.svg create mode 100644 src/components/Points/ActivityList.tsx create mode 100644 src/components/Points/ActivityPointsFeed.tsx create mode 100644 src/components/Points/CampaignInfo.tsx create mode 100644 src/components/Points/CampaignLeaderboard.tsx create mode 100644 src/components/Points/CampaignSelector.tsx create mode 100644 src/components/Points/CampaignTabs.tsx create mode 100644 src/components/Points/TotalCampaignStats.tsx create mode 100644 src/components/Points/index.tsx create mode 100644 src/components/Points/styles.module.css create mode 100644 src/components/PointsCounter/index.tsx create mode 100644 src/components/SafePassDisclaimer/index.tsx create mode 100644 src/hooks/useCampaigns.ts create mode 100644 src/hooks/useGlobalCampaignId.ts diff --git a/package.json b/package.json index 7d4d4388..27ccf07c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safe-dao-governance-app", "homepage": "https://github.com/safe-global/safe-dao-governance-app", "license": "GPL-3.0", - "version": "2.0.4", + "version": "2.1.0", "scripts": { "build": "next build && next export", "lint": "tsc && next lint", diff --git a/pages/index.tsx b/pages/index.tsx index 4bf45eca..4705d215 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,9 +1,9 @@ import type { NextPage } from 'next' -import TokenLocking from '@/components/TokenLocking' +import PointsPage from './points' const IndexPage: NextPage = () => { - return + return } export default IndexPage diff --git a/pages/points.tsx b/pages/points.tsx new file mode 100644 index 00000000..f6c90b7c --- /dev/null +++ b/pages/points.tsx @@ -0,0 +1,8 @@ +import Points from '@/components/Points' +import type { NextPage } from 'next' + +const PointsPage: NextPage = () => { + return +} + +export default PointsPage diff --git a/public/images/horizontal_barcode.svg b/public/images/horizontal_barcode.svg new file mode 100644 index 00000000..b977f6b0 --- /dev/null +++ b/public/images/horizontal_barcode.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/neon-star.svg b/public/images/neon-star.svg new file mode 100644 index 00000000..86b3ee6f --- /dev/null +++ b/public/images/neon-star.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/analytics/navigation.ts b/src/analytics/navigation.ts index b7f63108..5a306cc2 100644 --- a/src/analytics/navigation.ts +++ b/src/analytics/navigation.ts @@ -20,4 +20,7 @@ export const NAVIGATION_EVENTS = { OPEN_ACTIVITY_INFO: { action: 'Open activity info', }, + OPEN_POINTS: { + action: 'Open points page', + }, } diff --git a/src/components/BoostCounter/index.tsx b/src/components/BoostCounter/index.tsx index b1beea67..da0da42d 100644 --- a/src/components/BoostCounter/index.tsx +++ b/src/components/BoostCounter/index.tsx @@ -99,7 +99,7 @@ const BoostCounter = ({ > {digit} - + {decimals !== '' ? `${localeSeparator}${decimals}x` : 'x'} diff --git a/src/components/PageLayout/index.tsx b/src/components/PageLayout/index.tsx index b8e3350c..c9875c40 100644 --- a/src/components/PageLayout/index.tsx +++ b/src/components/PageLayout/index.tsx @@ -12,7 +12,7 @@ import { AppRoutes } from '@/config/routes' import { useRouter } from 'next/router' import { NAVIGATION_EVENTS } from '@/analytics/navigation' -const RoutesWithNavigation = [AppRoutes.index, AppRoutes.activity, AppRoutes.governance] +const RoutesWithNavigation = [AppRoutes.index, AppRoutes.points, AppRoutes.activity, AppRoutes.governance] export const PageLayout = ({ children, @@ -34,14 +34,19 @@ export const PageLayout = ({ - + {showNavigation && ( { + const { activitiesMetadata } = campaign ?? {} + return ( + + {activitiesMetadata?.map((activity) => ( + + `1px solid ${palette.border.light}`, + padding: 2, + borderRadius: '6px', + }} + > + {activity.name} + + + + + + ))} + + ) +} diff --git a/src/components/Points/ActivityPointsFeed.tsx b/src/components/Points/ActivityPointsFeed.tsx new file mode 100644 index 00000000..54f79592 --- /dev/null +++ b/src/components/Points/ActivityPointsFeed.tsx @@ -0,0 +1,133 @@ +import { GLOBAL_CAMPAIGN_IDS } from '@/config/constants' +import { Campaign } from '@/hooks/useCampaigns' +import { useChainId } from '@/hooks/useChainId' +import { useOwnCampaignRank } from '@/hooks/useLeaderboard' +import { formatDatetime } from '@/utils/date' +import { Divider, Skeleton, Stack, Typography } from '@mui/material' +import Box from '@mui/material/Box' +import { useEffect, useMemo, useState } from 'react' +import PointsCounter from '../PointsCounter' +import Barcode from '@/public/images/horizontal_barcode.svg' +import css from './styles.module.css' +const HiddenValue = () => ( + + + +) + +export const ActivityPointsFeed = ({ campaign }: { campaign?: Campaign }) => { + const { data: ownEntry, isLoading } = useOwnCampaignRank(campaign?.resourceId) + + const data = useMemo(() => { + return { + activityPoints: ownEntry?.totalPoints ?? 0, + boostedPoints: ownEntry ? ownEntry.totalBoostedPoints - ownEntry.totalPoints : 0, + totalPoints: ownEntry?.totalBoostedPoints ?? 0, + } + }, [ownEntry]) + + const [showBoostPoints, setShowBoostPoints] = useState(false) + const [showTotalPoints, setShowTotalPoints] = useState(false) + + useEffect(() => { + if (ownEntry !== undefined || !isLoading) { + const showBoostPointsTimeout = setTimeout(() => setShowBoostPoints(true), 1000) + const showTotalPointsTimeout = setTimeout(() => setShowTotalPoints(true), 2000) + + return () => { + clearTimeout(showBoostPointsTimeout) + clearTimeout(showTotalPointsTimeout) + } + } + }, [ownEntry, isLoading]) + + const chainId = useChainId() + + const globalCampaignId = GLOBAL_CAMPAIGN_IDS[chainId] + + const isGlobal = campaign?.resourceId === globalCampaignId + + if (!campaign) { + return null + } + + if (isLoading) { + return ( + `1px solid ${palette.border.light}`, + borderRadius: '6px', + p: '24px 32px', + display: 'flex', + flexDirection: 'column', + gap: 2, + position: 'relative', + }} + > + + Activity points + + + + Boost points + + + + + {!isGlobal && 'Campaign'} Total + + + + + + ) + } + + return ( + `1px solid ${palette.border.light}`, + borderRadius: '6px', + p: '24px 32px', + display: 'flex', + flexDirection: 'column', + gap: 2, + position: 'relative', + }} + > + + Activity points + + points + + + + Boost points + {showBoostPoints ? ( + + points + + ) : ( + + )} + + + + {!isGlobal && 'Campaign'} Total + {showTotalPoints ? ( + + points + + ) : ( + + )} + + + Last updated {formatDatetime(new Date(campaign.lastUpdated))} + + + + ) +} diff --git a/src/components/Points/CampaignInfo.tsx b/src/components/Points/CampaignInfo.tsx new file mode 100644 index 00000000..8a8fd2ae --- /dev/null +++ b/src/components/Points/CampaignInfo.tsx @@ -0,0 +1,81 @@ +import { Skeleton, SvgIcon, Typography } from '@mui/material' +import PaperContainer from '../PaperContainer' + +import css from './styles.module.css' + +import NeonStart from '@/public/images/neon-star.svg' +import { ActivityList } from './ActivityList' +import { Campaign } from '@/hooks/useCampaigns' +import { formatDate } from '@/utils/date' +import { useGlobalCampaignId } from '@/hooks/useGlobalCampaignId' + +export const CampaignInfo = ({ campaign }: { campaign: Campaign | undefined }) => { + const startDateFormatted = formatDate(new Date(campaign?.startDate ?? '0')) + const endDateFormatted = formatDate(new Date(campaign?.endDate ?? '0')) + + const globalCampaignId = useGlobalCampaignId() + const isGlobal = campaign?.resourceId === globalCampaignId + + if (!campaign) { + return ( + + + + Current campaign + + + + + + + + + + + + ) + } + + return ( + + + + Current campaign + + + {campaign?.name} + + {!isGlobal && ( + + {startDateFormatted} - {endDateFormatted} + + )} + + {campaign.description} + + + + ) +} diff --git a/src/components/Points/CampaignLeaderboard.tsx b/src/components/Points/CampaignLeaderboard.tsx new file mode 100644 index 00000000..e557c0db --- /dev/null +++ b/src/components/Points/CampaignLeaderboard.tsx @@ -0,0 +1,282 @@ +import { + Box, + Link, + Paper, + Skeleton, + SvgIcon, + Table, + TableBody, + TableContainer, + TableHead, + TableRow, + Typography, + useMediaQuery, +} from '@mui/material' +import { useTheme } from '@mui/material/styles' + +import { styled } from '@mui/material/styles' +import TableCell, { tableCellClasses } from '@mui/material/TableCell' +import { Identicon } from '../Identicon' +import FirstPlaceIcon from '@/public/images/leaderboard-first-place.svg' +import SecondPlaceIcon from '@/public/images/leaderboard-second-place.svg' +import ThirdPlaceIcon from '@/public/images/leaderboard-third-place.svg' +import TitleStar from '@/public/images/leaderboard-title-star.svg' +import { CampaignLeaderboardEntry, useGlobalCampaignLeaderboardPage, useOwnCampaignRank } from '@/hooks/useLeaderboard' +import { ReactElement, useState } from 'react' +import { useEnsLookup } from '@/hooks/useEnsLookup' +import Track from '../Track' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import { useChainId } from '@/hooks/useChainId' +import { CHAIN_SHORT_NAME, SAFE_URL } from '@/config/constants' +import { ExternalLink } from '../ExternalLink' +import { Campaign } from '@/hooks/useCampaigns' +import { floorNumber } from '@/utils/boost' +import { useGlobalCampaignId } from '@/hooks/useGlobalCampaignId' + +const PAGE_SIZE = 10 + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: 'none', + color: theme.palette.common.white, + border: 0, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + borderTop: `1px ${theme.palette.background.paper} solid`, + borderBottom: `1px ${theme.palette.background.paper} solid`, + }, +})) + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + // hide last border + '&:hover td': { + backgroundColor: theme.palette.background.main, + }, + '& td:first-child': { + borderRadius: '6px 0 0 6px', + }, + '& td:last-child': { + borderRadius: '0 6px 6px 0', + }, +})) + +const HighlightedTableRow = styled(TableRow)(({ theme }) => ({ + '& td:first-child': { + borderRadius: '6px 0 0 6px', + }, + '& td:last-child': { + borderRadius: '0 6px 6px 0', + }, + '& td': { + backgroundColor: theme.palette.background.light, + background: + 'linear-gradient(var(--mui-palette-background-light), var(--mui-palette-background-light)) padding-box,linear-gradient(to bottom, #5FDDFF 12.5%, #12FF80 88.07%) border-box', + border: '1px solid transparent !important', + }, + '& td:not(:first-child)': { + borderLeft: 'none !important', + }, + '& td:not(:last-child)': { + borderRight: 'none !important', + }, +})) + +const StyledTable = styled(Table)(({ theme }) => ({ + borderCollapse: 'separate', +})) + +export const shortenAddress = (address: string, length = 4): string => { + if (!address) { + return '' + } + + return `${address.slice(0, length + 2)}...${address.slice(-length)}` +} + +const LookupAddress = ({ address }: { address: string }) => { + const name = useEnsLookup(address) + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')) + const displayAddress = isSmallScreen ? shortenAddress(address) : address + const chainId = useChainId() + const shortName = CHAIN_SHORT_NAME[chainId] + + return ( + <> + + + {shortName ? ( + + {name ? name : displayAddress} + + ) : ( + {name ? name : displayAddress} + )} + + + ) +} + +const Ranking = ({ position }: { position: number }) => { + return position <= 3 ? ( + + ) : ( + <>{position} + ) +} + +const OwnEntry = ({ entry }: { entry: CampaignLeaderboardEntry | undefined }) => { + if (entry) { + return ( + + + + + + + + {floorNumber(entry.totalBoostedPoints, 0)} + + ) + } + + return null +} + +const LeaderboardPage = ({ + index, + resourceId, + onLoadMore, + ownEntry, +}: { + index: number + resourceId: string | undefined + onLoadMore?: () => void + ownEntry: CampaignLeaderboardEntry | undefined +}): ReactElement => { + const leaderboardPage = useGlobalCampaignLeaderboardPage(resourceId, PAGE_SIZE, index * PAGE_SIZE) + const rows = leaderboardPage?.results ?? [] + const isLeaderboardEmpty = index === 0 && (!rows || rows.length === 0) + + if (leaderboardPage === undefined) { + return ( + <> + + + + + + + + + + + + + ) + } + if (isLeaderboardEmpty) { + return ( + <> + + + No entries + + + + + ) + } + return ( + <> + {rows.map((row) => { + return row.holder === ownEntry?.holder ? ( + + ) : ( + + + + + + + + {floorNumber(row.totalBoostedPoints, 0)} + + ) + })} + {onLoadMore && leaderboardPage?.next && ( + + + + + Show more + + + + + )} + + ) +} + +export const CampaignLeaderboard = ({ campaign }: { campaign?: Campaign }) => { + const [pages, setPages] = useState(1) + const { data: ownEntry } = useOwnCampaignRank(campaign?.resourceId) + + const globalCampaignId = useGlobalCampaignId() + + const isGlobal = campaign?.resourceId === globalCampaignId + + return ( + + + + + + {isGlobal ? 'Global' : campaign?.name} Leaderboard + + + + + + + + + + + + Points gained + + + + + {ownEntry && ownEntry?.position > PAGE_SIZE && } + {Array.from(new Array(pages)).map((_, index) => ( + setPages((prev) => prev + 1) : undefined} + ownEntry={ownEntry} + /> + ))} + + + + + ) +} diff --git a/src/components/Points/CampaignSelector.tsx b/src/components/Points/CampaignSelector.tsx new file mode 100644 index 00000000..3a18d0c6 --- /dev/null +++ b/src/components/Points/CampaignSelector.tsx @@ -0,0 +1,31 @@ +import { Campaign } from '@/hooks/useCampaigns' +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material' + +export const CampaignSelector = ({ + campaigns, + selectedCampaignId, + setSelectedCampaignId, +}: { + campaigns: Campaign[] + selectedCampaignId: string + setSelectedCampaignId: (id: string) => void +}) => { + return ( + + Campaign + + + ) +} diff --git a/src/components/Points/CampaignTabs.tsx b/src/components/Points/CampaignTabs.tsx new file mode 100644 index 00000000..480bda66 --- /dev/null +++ b/src/components/Points/CampaignTabs.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import Tabs from '@mui/material/Tabs' +import Tab from '@mui/material/Tab' +import { Box, Chip, Typography, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +const CAMPAIGN_TABS = [ + { + label: 'Global', + disabled: false, + }, + { + label: ( + + Campaigns + + ), + disabled: true, + }, +] as const + +const CampaignTabs = ({ onChange, selectedTabIdx }: { onChange: (tab: number) => void; selectedTabIdx: number }) => { + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')) + return ( + + onChange(value)} + > + {CAMPAIGN_TABS.map((tab, tabIdx) => ( + + ))} + + + ) +} + +export default CampaignTabs diff --git a/src/components/Points/TotalCampaignStats.tsx b/src/components/Points/TotalCampaignStats.tsx new file mode 100644 index 00000000..ed28a3de --- /dev/null +++ b/src/components/Points/TotalCampaignStats.tsx @@ -0,0 +1,37 @@ +import { InfoOutlined } from '@mui/icons-material' +import { Stack, Typography } from '@mui/material' +import BoostCounter from '../BoostCounter' +import PointsCounter from '../PointsCounter' + +export const TotalCampaignStats = ({ boost, points }: { boost: number; points: number }) => { + return ( + + + + Total points + + + + + + Current Boost + + + + + ) +} diff --git a/src/components/Points/index.tsx b/src/components/Points/index.tsx new file mode 100644 index 00000000..69d75ea4 --- /dev/null +++ b/src/components/Points/index.tsx @@ -0,0 +1,102 @@ +import { GLOBAL_CAMPAIGN_IDS } from '@/config/constants' +import { useCampaignInfo, useCampaignPage } from '@/hooks/useCampaigns' +import { useChainId } from '@/hooks/useChainId' +import { Grid, Typography, Stack, Box } from '@mui/material' +import { useEffect, useState } from 'react' +import { ExternalLink } from '../ExternalLink' +import PaperContainer from '../PaperContainer' +import SafePassDisclaimer from '../SafePassDisclaimer' +import { ActivityPointsFeed } from './ActivityPointsFeed' +import { CampaignInfo } from './CampaignInfo' +import { CampaignLeaderboard } from './CampaignLeaderboard' +import { CampaignSelector } from './CampaignSelector' +import CampaignTabs from './CampaignTabs' +import css from './styles.module.css' + +const Points = () => { + const campaignPage = useCampaignPage(20) + + const chainId = useChainId() + + const globalCampaign = useCampaignInfo(GLOBAL_CAMPAIGN_IDS[chainId]) + + const [selectedCampaignId, setSelectedCampaignId] = useState(GLOBAL_CAMPAIGN_IDS[chainId]) + const campaign = campaignPage?.results.find((c) => c.resourceId === selectedCampaignId) + + const campaigns = campaignPage?.results ?? [] + + const [selectedTab, setSelectedTab] = useState(0) + + useEffect(() => { + setSelectedCampaignId(GLOBAL_CAMPAIGN_IDS[chainId]) + }, [chainId]) + + const onTabChange = (index: number) => { + setSelectedTab(index) + if (index === 1) { + setSelectedCampaignId(campaignPage?.results[1]?.resourceId ?? '') + } + if (index === 0) { + setSelectedCampaignId(globalCampaign?.resourceId ?? '') + } + } + + return ( + + + {'Get rewards with Safe{Pass}'} + + {'What is Safe{Pass}'} + + + + + + + + + + + + + Points activity feed + + Your points are updated weekly. + + {selectedTab > 0 && ( + + )} + + + + + + + + + + + + + + + + + + + + ) +} + +export default Points diff --git a/src/components/Points/styles.module.css b/src/components/Points/styles.module.css new file mode 100644 index 00000000..23cfa8cc --- /dev/null +++ b/src/components/Points/styles.module.css @@ -0,0 +1,20 @@ +@media (max-width: 899px) { + .pageTitle { + margin: 16px; + } +} + +.gradientText { + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); + background-clip: text; + color: transparent; +} + +.barcode { + position: absolute; + bottom: 0; + left: 50%; + z-index: 1; + transform: translate(-50%); + pointer-events: none; +} diff --git a/src/components/PointsCounter/index.tsx b/src/components/PointsCounter/index.tsx new file mode 100644 index 00000000..16b7fd73 --- /dev/null +++ b/src/components/PointsCounter/index.tsx @@ -0,0 +1,76 @@ +import { floorNumber } from '@/utils/boost' +import { NorthRounded, SouthRounded } from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import { TypographyProps } from '@mui/material/Typography' +import BezierEasing from 'bezier-easing' +import { useState, useEffect, useRef } from 'react' +const easeInOut = BezierEasing(0.42, 0, 0.58, 1) +const DURATION = 1000 + +const PointsCounter = ({ + value, + direction, + children, + ...props +}: TypographyProps & { value: number; direction?: 'north' | 'south' }) => { + const [isAnimating, setIsAnimating] = useState(false) + const [start, setStart] = useState(0) + const [target, setTarget] = useState(0) + + const targetRef = useRef(1) + + const [currentNumber, setCurrentNumber] = useState(target) + + const rotationRef = useRef(0) + + useEffect(() => { + if (targetRef.current === target) { + // No new target + return + } + + targetRef.current = target + const startTime = new Date().getTime() + const offset = target - start + + const tick = () => { + setIsAnimating(true) + const elapsed = new Date().getTime() - startTime + + // If the browser window is in the background / minimized it will optimize and not call the requestAnimationFrame until in foreground causing wrong numbers for progress. + const progress = Math.min(elapsed / DURATION, 1) + const easedProgress = easeInOut(progress) + + const newNumber = start + easedProgress * offset + setCurrentNumber(floorNumber(newNumber, 3)) + + if (elapsed < DURATION && targetRef.current === target) { + requestAnimationFrame(tick) + } else { + setIsAnimating(false) + rotationRef.current = 0 + } + } + + tick() + }, [target, currentNumber, start]) + + useEffect(() => { + setStart(target) + setTarget(value) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return ( + + {direction === 'north' && } + {direction === 'south' && } + + + {Math.floor(currentNumber)} {children} + + + ) +} + +export default PointsCounter diff --git a/src/components/SafePassDisclaimer/index.tsx b/src/components/SafePassDisclaimer/index.tsx new file mode 100644 index 00000000..ab4d6a18 --- /dev/null +++ b/src/components/SafePassDisclaimer/index.tsx @@ -0,0 +1,36 @@ +import { SAFE_TERMS_AND_CONDITIONS_URL } from '@/config/constants' +import { Box, Typography } from '@mui/material' +import { ExternalLink } from '../ExternalLink' + +const SafePassDisclaimer = () => { + return ( + + + + LEGAL DISCLAIMER + + + Please note that residents in{' '} + certain jurisdictions (including the United + States) may not be eligible for the boost and rewards. This means that your boost might not be applied to + certain reward types, e.g. token rewards such as Safe, and you might not be eligible to receive certain types + of rewards. +
+ PLEASE NOTE THAT SOLELY LOCKING YOUR SAFE TOKEN WITHOUT ACTIVELY PARTICIPATING IN ACTIVITIES DOES NOT QUALIFY + YOU TO RECEIVE REWARDS. +
+ + Please note that participating in the Safe{'{'}Pass{'}'} Program, collecting points and completing + activities{' '} + + DOES NOT GRANT YOU ANY CLAIM, CONTRACTUAL OR OTHERWISE, TO RECEIVE REWARDS. +
+
+ + For more information, see Terms and Conditions + +
+ ) +} + +export default SafePassDisclaimer diff --git a/src/components/TockenUnlocking/WithdrawWidget.tsx b/src/components/TockenUnlocking/WithdrawWidget.tsx index 78a91e8d..facb8cd4 100644 --- a/src/components/TockenUnlocking/WithdrawWidget.tsx +++ b/src/components/TockenUnlocking/WithdrawWidget.tsx @@ -15,7 +15,7 @@ import { useTxSender } from '@/hooks/useTxSender' import { useChainId } from '@/hooks/useChainId' import { UnlockEvent } from '@/hooks/useLockHistory' import { formatAmount } from '@/utils/formatters' -import { DAY_IN_MS, formatDate } from '@/utils/date' +import { DAY_IN_MS, formatDatetime } from '@/utils/date' export const WithdrawWidget = ({ totalWithdrawable, @@ -96,7 +96,8 @@ export const WithdrawWidget = ({ {formatAmount(formatUnits(nextUnlock.amount, 18), 0)} SAFE {' '} - will be withdrawable starting {formatDate(new Date(Date.parse(nextUnlock.executionDate) + DAY_IN_MS))}. + will be withdrawable starting{' '} + {formatDatetime(new Date(Date.parse(nextUnlock.executionDate) + DAY_IN_MS))}.
))} diff --git a/src/components/TokenLocking/ActivityRewardsInfo.tsx b/src/components/TokenLocking/ActivityRewardsInfo.tsx index f0f0b55b..51352c9e 100644 --- a/src/components/TokenLocking/ActivityRewardsInfo.tsx +++ b/src/components/TokenLocking/ActivityRewardsInfo.tsx @@ -2,7 +2,7 @@ import { Box, Divider, Link, SvgIcon, Tooltip, Typography } from '@mui/material' import css from './styles.module.css' import clsx from 'clsx' import StarIcon from '@/public/images/star.svg' -import { useOwnRank } from '@/hooks/useLeaderboard' +import { useOwnLockingRank } from '@/hooks/useLeaderboard' import Asterix from '@/public/images/asterix.svg' import { AccordionContainer } from '@/components/AccordionContainer' import NextLink from 'next/link' @@ -33,7 +33,7 @@ const Step = ({ active, title, description }: { active: boolean; title?: ReactNo } export const ActivityRewardsInfo = () => { - const ownRankResult = useOwnRank() + const ownRankResult = useOwnLockingRank() const { data: ownRank } = ownRankResult return ( diff --git a/src/components/TokenLocking/BoostBreakdown.tsx b/src/components/TokenLocking/BoostBreakdown.tsx index 4c963419..b6174ee1 100644 --- a/src/components/TokenLocking/BoostBreakdown.tsx +++ b/src/components/TokenLocking/BoostBreakdown.tsx @@ -1,7 +1,7 @@ import { floorNumber } from '@/utils/boost' import { SignalCellularAlt, SignalCellularAlt1Bar, SignalCellularAlt2Bar } from '@mui/icons-material' import { Stack, Typography, Box } from '@mui/material' -import BoostCounter from '../BoostCounter' +import NumberCounter from '../BoostCounter' import { BoostMeter } from './BoostMeter' import EmptyBreakdown from '@/public/images/empty-breakdown.svg' @@ -85,7 +85,7 @@ export const BoostBreakdown = ({ ) : ( - { ) } -const OwnEntry = ({ entry }: { entry: LeaderboardEntry | undefined }) => { +const OwnEntry = ({ entry }: { entry: LockingLeaderboardEntry | undefined }) => { if (entry) { return ( @@ -156,9 +160,9 @@ const LeaderboardPage = ({ }: { index: number onLoadMore?: () => void - ownEntry: LeaderboardEntry | undefined + ownEntry: LockingLeaderboardEntry | undefined }): ReactElement => { - const leaderboardPage = useGlobalLeaderboardPage(PAGE_SIZE, index * PAGE_SIZE) + const leaderboardPage = useGlobalLockingLeaderboardPage(PAGE_SIZE, index * PAGE_SIZE) const rows = leaderboardPage?.results ?? [] const isLeaderboardEmpty = index === 0 && (!rows || rows.length === 0) @@ -232,7 +236,7 @@ const LeaderboardPage = ({ export const Leaderboard = () => { const [pages, setPages] = useState(1) - const ownLeaderboardEntry = useOwnRank() + const ownLeaderboardEntry = useOwnLockingRank() const { data: ownEntry } = ownLeaderboardEntry return ( diff --git a/src/components/TokenLocking/index.tsx b/src/components/TokenLocking/index.tsx index 5be6141f..54227d7c 100644 --- a/src/components/TokenLocking/index.tsx +++ b/src/components/TokenLocking/index.tsx @@ -1,4 +1,4 @@ -import { Box, Grid, Stack, Typography } from '@mui/material' +import { Grid, Stack, Typography } from '@mui/material' import { useSafeTokenBalance } from '@/hooks/useSafeTokenBalance' import { Leaderboard } from './Leaderboard' import { CurrentStats } from './CurrentStats' @@ -11,7 +11,7 @@ import { useLockHistory } from '@/hooks/useLockHistory' import css from './styles.module.css' import { ExternalLink } from '../ExternalLink' -import { SAFE_TERMS_AND_CONDITIONS_URL } from '@/config/constants' +import SafePassDisclaimer from '../SafePassDisclaimer' const TokenLocking = () => { const { isLoading: safeBalanceLoading, data: safeBalance } = useSafeTokenBalance() @@ -49,31 +49,7 @@ const TokenLocking = () => { - - - LEGAL DISCLAIMER - - - Please note that residents in{' '} - certain jurisdictions (including the - United States) may not be eligible for the boost and rewards. This means that your boost might not be - applied to certain reward types, e.g. token rewards such as Safe, and you might not be eligible to receive - certain types of rewards. -
- PLEASE NOTE THAT SOLELY LOCKING YOUR SAFE TOKEN WITHOUT ACTIVELY PARTICIPATING IN ACTIVITIES DOES NOT - QUALIFY YOU TO RECEIVE REWARDS. -
- - Please note that participating in the Safe{'{'}Pass{'}'} Program, collecting points and completing - activities{' '} - - DOES NOT GRANT YOU ANY CLAIM, CONTRACTUAL OR OTHERWISE, TO RECEIVE REWARDS. -
-
- - For more information, see{' '} - Terms and Conditions - +
diff --git a/src/config/constants.ts b/src/config/constants.ts index b8f63a1a..a856aad0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -115,3 +115,8 @@ export const UNLIMITED_APPROVAL_AMOUNT = BigNumber.from(2).pow(256).sub(1) export const SEASON2_START = 160 export const SEASON1_START = 33 + +export const GLOBAL_CAMPAIGN_IDS: ChainConfig = { + [Chains.SEPOLIA]: 'fa9f462b-8e8c-4122-aa41-2464e919b721', + [Chains.MAINNET]: '9ed78b8b-178d-4e25-9ef2-1517865991ee', +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 18e389e6..b536663b 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -2,6 +2,7 @@ export const AppRoutes = { '404': '/404', widgets: '/widgets', unlock: '/unlock', + points: '/points', index: '/', governance: '/governance', delegate: '/delegate', diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts new file mode 100644 index 00000000..0d197d7c --- /dev/null +++ b/src/hooks/useCampaigns.ts @@ -0,0 +1,60 @@ +import { PaginatedResult, useGatewayBaseUrl } from './useGatewayBaseUrl' +import useSWR from 'swr' +import { toCursorParam } from '@/utils/gateway' + +export type Campaign = { + resourceId: string + name: string + description: string + startDate: string + endDate: string + lastUpdated: string + activitiesMetadata: { + resourceId: string + name: string + description: string + maxPoints: string + }[] +} + +export const useCampaignPage = (limit: number, offset?: number) => { + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (limit: number, offset?: number) => { + // Load next page + return `${gatewayBaseUrl}/v1/community/campaigns?${toCursorParam(limit, offset)}` + } + + const { data } = useSWR(getKey(limit, offset), async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise> + } else { + throw new Error('Error fetching campaigns.') + } + }) + }) + + return data +} + +export const useCampaignInfo = (resourceId: string) => { + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (resourceId: string) => { + // Load next page + return `${gatewayBaseUrl}/v1/community/campaigns/${resourceId}` + } + + const { data } = useSWR(getKey(resourceId), async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise + } else { + throw new Error('Error fetching campaigns.') + } + }) + }) + + return data +} diff --git a/src/hooks/useGatewayBaseUrl.ts b/src/hooks/useGatewayBaseUrl.ts index 51bdcca0..01a1421a 100644 --- a/src/hooks/useGatewayBaseUrl.ts +++ b/src/hooks/useGatewayBaseUrl.ts @@ -1,6 +1,13 @@ import { CGW_BASE_URL } from '@/config/constants' import { useChainId } from '@/hooks/useChainId' +export type PaginatedResult = { + count: number + next: string | null + previous: string | null + results: T[] +} + export const useGatewayBaseUrl = (): string => { const chainId = useChainId() diff --git a/src/hooks/useGlobalCampaignId.ts b/src/hooks/useGlobalCampaignId.ts new file mode 100644 index 00000000..f41d6db0 --- /dev/null +++ b/src/hooks/useGlobalCampaignId.ts @@ -0,0 +1,7 @@ +import { GLOBAL_CAMPAIGN_IDS } from '@/config/constants' +import { useChainId } from './useChainId' + +export const useGlobalCampaignId = () => { + const chainId = useChainId() + return GLOBAL_CAMPAIGN_IDS[chainId] +} diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index 857c1085..50b0628d 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -2,9 +2,9 @@ import { useAddress } from './useAddress' import useSWR from 'swr' import { POLLING_INTERVAL } from '@/config/constants' import { toCursorParam } from '@/utils/gateway' -import { useGatewayBaseUrl } from './useGatewayBaseUrl' +import { PaginatedResult, useGatewayBaseUrl } from './useGatewayBaseUrl' -export type LeaderboardEntry = { +export type LockingLeaderboardEntry = { holder: string position: number lockedAmount: string @@ -12,26 +12,27 @@ export type LeaderboardEntry = { withdrawnAmount: string } -type LeaderboardPage = { - count: number - next: string | null - previous: string | null - results: LeaderboardEntry[] +export type CampaignLeaderboardEntry = { + holder: string + position: number + boost: string + totalPoints: number + totalBoostedPoints: number } -export const useOwnRank = () => { +export const useOwnLockingRank = () => { const address = useAddress() const gatewayBaseUrl = useGatewayBaseUrl() return useSWR( - address ? `${gatewayBaseUrl}/v1/locking/leaderboard/rank/${address}` : null, + address ? `${gatewayBaseUrl}/v1/community/locking/${address}/rank` : null, async (url: string | null) => { if (!url) { return undefined } return await fetch(url).then((resp) => { if (resp.ok) { - return resp.json() as Promise + return resp.json() as Promise } else { throw new Error('Error fetching own ranking.') } @@ -41,17 +42,12 @@ export const useOwnRank = () => { ) } -export const useGlobalLeaderboardPage = (limit: number, offset?: number) => { +export const useGlobalLockingLeaderboardPage = (limit: number, offset?: number) => { const gatewayBaseUrl = useGatewayBaseUrl() const getKey = (limit: number, offset?: number) => { - if (!offset) { - // Load first page - return `${gatewayBaseUrl}/v1/locking/leaderboard?${toCursorParam(limit)}` - } - // Load next page - return `${gatewayBaseUrl}/v1/locking/leaderboard?${toCursorParam(limit, offset)}` + return `${gatewayBaseUrl}/v1/community/locking/leaderboard?${toCursorParam(limit, offset)}` } const { data } = useSWR( @@ -59,7 +55,7 @@ export const useGlobalLeaderboardPage = (limit: number, offset?: number) => { async (url: string) => { return await fetch(url).then((resp) => { if (resp.ok) { - return resp.json() as Promise + return resp.json() as Promise> } else { throw new Error('Error fetching leaderboard.') } @@ -70,3 +66,58 @@ export const useGlobalLeaderboardPage = (limit: number, offset?: number) => { return data } + +export const useOwnCampaignRank = (resourceId: string | undefined) => { + const address = useAddress() + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (resourceId: string | undefined) => { + if (!resourceId) { + return null + } + // Load next page + return `${gatewayBaseUrl}/v1/community/campaigns/${resourceId}/leaderboard/${address}` + } + + const { data, isLoading } = useSWR( + getKey(resourceId), + async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise + } else { + throw new Error('Error fetching leaderboard.') + } + }) + }, + { + errorRetryCount: 1, + }, + ) + + return { data, isLoading } +} + +export const useGlobalCampaignLeaderboardPage = (resourceId: string | undefined, limit: number, offset?: number) => { + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (resourceId: string | undefined, limit: number, offset?: number) => { + if (!resourceId) { + return null + } + // Load next page + return `${gatewayBaseUrl}/v1/community/campaigns/${resourceId}/leaderboard?${toCursorParam(limit, offset)}` + } + + const { data } = useSWR(getKey(resourceId, limit, offset), async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise> + } else { + throw new Error('Error fetching leaderboard.') + } + }) + }) + + return data +} diff --git a/src/hooks/useLockHistory.ts b/src/hooks/useLockHistory.ts index 7351c075..d7334183 100644 --- a/src/hooks/useLockHistory.ts +++ b/src/hooks/useLockHistory.ts @@ -57,7 +57,7 @@ export const useLockHistory = () => { } if (!previousPageData) { // Load first page - return `${gatewayBaseUrl}/v1/locking/${address}/history?${toCursorParam(LIMIT)}` + return `${gatewayBaseUrl}/v1/community/locking/${address}/history?${toCursorParam(LIMIT)}` } if (previousPageData && !previousPageData.next) return null // reached the end diff --git a/src/utils/date.ts b/src/utils/date.ts index 2448ff3c..0d25322b 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -54,7 +54,7 @@ export const toDaysSinceStart = (timestamp: number, start: number) => { export const getCurrentDays = (startTime: number) => toDaysSinceStart(Date.now(), startTime) -export const formatDate = (date: Date) => { +export const formatDatetime = (date: Date) => { const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', @@ -65,3 +65,12 @@ export const formatDate = (date: Date) => { return `${date.toLocaleString(undefined, options)}h` } + +export const formatDate = (date: Date) => { + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: '2-digit', + } + + return `${date.toLocaleString(undefined, options)}` +} From 01b013008e83226fa79a169ba556296056df6f57 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 22 May 2024 11:59:06 +0200 Subject: [PATCH 2/6] fix: make leaderboard lowercase and activate second step (#183) --- src/components/Points/CampaignLeaderboard.tsx | 2 +- src/components/SplashScreen/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Points/CampaignLeaderboard.tsx b/src/components/Points/CampaignLeaderboard.tsx index e557c0db..c605fa2b 100644 --- a/src/components/Points/CampaignLeaderboard.tsx +++ b/src/components/Points/CampaignLeaderboard.tsx @@ -247,7 +247,7 @@ export const CampaignLeaderboard = ({ campaign }: { campaign?: Campaign }) => { - {isGlobal ? 'Global' : campaign?.name} Leaderboard + {isGlobal ? 'Global' : campaign?.name} leaderboard
diff --git a/src/components/SplashScreen/index.tsx b/src/components/SplashScreen/index.tsx index 50f00f8f..46480825 100644 --- a/src/components/SplashScreen/index.tsx +++ b/src/components/SplashScreen/index.tsx @@ -114,8 +114,8 @@ export const SplashScreen = (): ReactElement => { How it works
- - + + From 6d770961e701aee06d8c9e0d87165cd89434aece Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 11 Jun 2024 10:19:51 +0200 Subject: [PATCH 3/6] fix: do not fetch leaderboard if no wallet connected (#185) --- src/hooks/useLeaderboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index 50b0628d..22c18052 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -72,7 +72,7 @@ export const useOwnCampaignRank = (resourceId: string | undefined) => { const gatewayBaseUrl = useGatewayBaseUrl() const getKey = (resourceId: string | undefined) => { - if (!resourceId) { + if (!resourceId || !address) { return null } // Load next page From def4c1941f86257b9ad438f1539bc6f08e6b2181 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 24 Jun 2024 15:46:16 +0200 Subject: [PATCH 4/6] feat: show weekly update separately, redesign some components (#186) --- src/components/Points/ActivityPointsFeed.tsx | 243 ++++++++++++------ src/components/Points/CampaignTabs.tsx | 47 +++- src/components/Points/TotalPoints.tsx | 37 +++ src/components/Points/index.tsx | 40 ++- src/components/Points/styles.module.css | 9 + .../TokenLocking/ActivityRewardsInfo.tsx | 2 +- src/hooks/useLatestCampaignUpdate.ts | 51 ++++ 7 files changed, 329 insertions(+), 100 deletions(-) create mode 100644 src/components/Points/TotalPoints.tsx create mode 100644 src/hooks/useLatestCampaignUpdate.ts diff --git a/src/components/Points/ActivityPointsFeed.tsx b/src/components/Points/ActivityPointsFeed.tsx index 54f79592..19799d3e 100644 --- a/src/components/Points/ActivityPointsFeed.tsx +++ b/src/components/Points/ActivityPointsFeed.tsx @@ -2,44 +2,83 @@ import { GLOBAL_CAMPAIGN_IDS } from '@/config/constants' import { Campaign } from '@/hooks/useCampaigns' import { useChainId } from '@/hooks/useChainId' import { useOwnCampaignRank } from '@/hooks/useLeaderboard' -import { formatDatetime } from '@/utils/date' +import { formatDate } from '@/utils/date' import { Divider, Skeleton, Stack, Typography } from '@mui/material' import Box from '@mui/material/Box' -import { useEffect, useMemo, useState } from 'react' +import { ReactNode, useEffect, useMemo, useState } from 'react' import PointsCounter from '../PointsCounter' import Barcode from '@/public/images/horizontal_barcode.svg' import css from './styles.module.css' +import { useLatestCampaignUpdate } from '@/hooks/useLatestCampaignUpdate' + +const BorderedBox = ({ children }: { children: ReactNode }) => { + return ( + `1px solid ${palette.border.light}`, + borderRadius: '6px', + p: '24px 32px', + display: 'flex', + flexDirection: 'column', + gap: 2, + position: 'relative', + }} + > + {children} + + ) +} + const HiddenValue = () => ( ) +const DataWrapper = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ) +} + export const ActivityPointsFeed = ({ campaign }: { campaign?: Campaign }) => { const { data: ownEntry, isLoading } = useOwnCampaignRank(campaign?.resourceId) + const { data: latestUpdate, isLoading: isLatestUpdateLoading } = useLatestCampaignUpdate(campaign?.resourceId) const data = useMemo(() => { return { - activityPoints: ownEntry?.totalPoints ?? 0, - boostedPoints: ownEntry ? ownEntry.totalBoostedPoints - ownEntry.totalPoints : 0, - totalPoints: ownEntry?.totalBoostedPoints ?? 0, + activityPoints: latestUpdate?.totalPoints ?? 0, + boostedPoints: latestUpdate ? latestUpdate.totalBoostedPoints - latestUpdate.totalPoints : 0, + totalPoints: latestUpdate?.totalBoostedPoints ?? 0, + overallPoints: ownEntry?.totalBoostedPoints ?? 0, } - }, [ownEntry]) + }, [latestUpdate, ownEntry]) const [showBoostPoints, setShowBoostPoints] = useState(false) const [showTotalPoints, setShowTotalPoints] = useState(false) + const [showOverallPoints, setShowOverallPoints] = useState(false) useEffect(() => { - if (ownEntry !== undefined || !isLoading) { - const showBoostPointsTimeout = setTimeout(() => setShowBoostPoints(true), 1000) - const showTotalPointsTimeout = setTimeout(() => setShowTotalPoints(true), 2000) + if (isLoading || isLatestUpdateLoading) { + return + } + const showBoostPointsTimeout = setTimeout(() => setShowBoostPoints(true), 1000) + const showTotalPointsTimeout = setTimeout(() => setShowTotalPoints(true), 2000) + const showOverallPointsTimeout = setTimeout(() => setShowOverallPoints(true), 3000) - return () => { - clearTimeout(showBoostPointsTimeout) - clearTimeout(showTotalPointsTimeout) - } + return () => { + clearTimeout(showBoostPointsTimeout) + clearTimeout(showTotalPointsTimeout) + clearTimeout(showOverallPointsTimeout) } - }, [ownEntry, isLoading]) + }, [ownEntry, isLoading, isLatestUpdateLoading]) const chainId = useChainId() @@ -53,81 +92,117 @@ export const ActivityPointsFeed = ({ campaign }: { campaign?: Campaign }) => { if (isLoading) { return ( - `1px solid ${palette.border.light}`, - borderRadius: '6px', - p: '24px 32px', - display: 'flex', - flexDirection: 'column', - gap: 2, - position: 'relative', - }} - > - - Activity points - - - - Boost points - - - - - {!isGlobal && 'Campaign'} Total - - - - - + <> + + + Last drop + + + + + Activity points + + + + Boost points + + + + + {!isGlobal && 'Campaign'} Week total + + + + + {!isGlobal && 'Campaign'} Overall + + + + Your points are updated weekly. + + + + {!isGlobal && ( + + + Campaign total + + + + )} + ) } return ( - `1px solid ${palette.border.light}`, - borderRadius: '6px', - p: '24px 32px', - display: 'flex', - flexDirection: 'column', - gap: 2, - position: 'relative', - }} - > - - Activity points - - points - - - - Boost points - {showBoostPoints ? ( - - points - - ) : ( - - )} - - - - {!isGlobal && 'Campaign'} Total - {showTotalPoints ? ( - + <> + + + Last drop + {latestUpdate && ( + + {formatDate(new Date(latestUpdate.startDate))} - {formatDate(new Date(latestUpdate.endDate))} + + )} + + + + Activity points + points - ) : ( - - )} - - - Last updated {formatDatetime(new Date(campaign.lastUpdated))} - - - + + + Boost points + {showBoostPoints ? ( + + points + + ) : ( + + )} + + + + {!isGlobal && 'Campaign'} Week total + {showTotalPoints ? ( + + points + + ) : ( + + )} + + + Your points are updated weekly. + + + + + {!isGlobal && ( + + + Campaign total + {showOverallPoints ? ( + + points + + ) : ( + + )} + + + )} + ) } diff --git a/src/components/Points/CampaignTabs.tsx b/src/components/Points/CampaignTabs.tsx index 480bda66..6229c34d 100644 --- a/src/components/Points/CampaignTabs.tsx +++ b/src/components/Points/CampaignTabs.tsx @@ -1,18 +1,44 @@ -import * as React from 'react' import Tabs from '@mui/material/Tabs' import Tab from '@mui/material/Tab' -import { Box, Chip, Typography, useMediaQuery } from '@mui/material' +import { Box, Chip, Tooltip, Typography } from '@mui/material' import { useTheme } from '@mui/material/styles' +import css from './styles.module.css' const CAMPAIGN_TABS = [ { - label: 'Global', + label: ( + + + palette.primary.main, + minWidth: '6px', + minHeight: '6px', + flexShrink: 0, + marginRight: 1, + }} + /> + + Global + + ), disabled: false, }, { label: ( - - Campaigns + + palette.text.disabled, + minWidth: '6px', + minHeight: '6px', + flexShrink: 0, + marginRight: 1, + }} + /> + Campaigns ), disabled: true, @@ -21,19 +47,22 @@ const CAMPAIGN_TABS = [ const CampaignTabs = ({ onChange, selectedTabIdx }: { onChange: (tab: number) => void; selectedTabIdx: number }) => { const theme = useTheme() - const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')) return ( onChange(value)} > {CAMPAIGN_TABS.map((tab, tabIdx) => ( - + ))} diff --git a/src/components/Points/TotalPoints.tsx b/src/components/Points/TotalPoints.tsx new file mode 100644 index 00000000..57fe11bf --- /dev/null +++ b/src/components/Points/TotalPoints.tsx @@ -0,0 +1,37 @@ +import { useGlobalCampaignId } from '@/hooks/useGlobalCampaignId' +import { useOwnCampaignRank } from '@/hooks/useLeaderboard' +import { InfoOutlined } from '@mui/icons-material' +import { Box, Skeleton, Stack, Tooltip, Typography } from '@mui/material' +import { useMemo } from 'react' +import PaperContainer from '../PaperContainer' +import PointsCounter from '../PointsCounter' + +export const TotalPoints = () => { + const globalCampaignId = useGlobalCampaignId() + const ownRank = useOwnCampaignRank(globalCampaignId) + + const totalPoints = useMemo( + () => (ownRank.isLoading ? undefined : ownRank.data?.totalBoostedPoints ?? 0), + [ownRank.data?.totalBoostedPoints, ownRank.isLoading], + ) + + return ( + + + {totalPoints !== undefined ? ( + + ) : ( + + + + )} + + Your total points + + palette.text.secondary }} /> + + + + + ) +} diff --git a/src/components/Points/index.tsx b/src/components/Points/index.tsx index 69d75ea4..7d9cafb9 100644 --- a/src/components/Points/index.tsx +++ b/src/components/Points/index.tsx @@ -1,8 +1,8 @@ import { GLOBAL_CAMPAIGN_IDS } from '@/config/constants' import { useCampaignInfo, useCampaignPage } from '@/hooks/useCampaigns' import { useChainId } from '@/hooks/useChainId' -import { Grid, Typography, Stack, Box } from '@mui/material' -import { useEffect, useState } from 'react' +import { Grid, Typography, Stack, Box, SvgIcon, Divider, useMediaQuery } from '@mui/material' +import { useEffect, useMemo, useState } from 'react' import { ExternalLink } from '../ExternalLink' import PaperContainer from '../PaperContainer' import SafePassDisclaimer from '../SafePassDisclaimer' @@ -11,7 +11,10 @@ import { CampaignInfo } from './CampaignInfo' import { CampaignLeaderboard } from './CampaignLeaderboard' import { CampaignSelector } from './CampaignSelector' import CampaignTabs from './CampaignTabs' +import StarIcon from '@/public/images/leaderboard-title-star.svg' import css from './styles.module.css' +import { TotalPoints } from './TotalPoints' +import { useTheme } from '@mui/material/styles' const Points = () => { const campaignPage = useCampaignPage(20) @@ -41,6 +44,13 @@ const Points = () => { } } + const isGlobalCampaign = useMemo( + () => globalCampaign?.resourceId === selectedCampaignId, + [globalCampaign?.resourceId, selectedCampaignId], + ) + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')) + return ( @@ -52,8 +62,26 @@ const Points = () => { + {isSmallScreen && } - + + {!isSmallScreen && } + + + Points activity feed + + + {!isSmallScreen && ( + + Earn points by engaging with Safe and our campaign partners. Depending on the campaign, you'll + be rewarded for various activities suggested by our partners. You can also earn points for regular + Safe activities. + + )} + + + + { minHeight="60px" > - - Points activity feed + + {isGlobalCampaign ? 'Global' : campaign?.name} - Your points are updated weekly. {selectedTab > 0 && ( { + {!isSmallScreen && } diff --git a/src/components/Points/styles.module.css b/src/components/Points/styles.module.css index 23cfa8cc..d977f6a7 100644 --- a/src/components/Points/styles.module.css +++ b/src/components/Points/styles.module.css @@ -18,3 +18,12 @@ transform: translate(-50%); pointer-events: none; } + +.comingSoon { + background-color: #303033; + border-radius: 4px; + padding: 4px 8px; + margin-left: 8px; + border: none; + font-weight: 400; +} diff --git a/src/components/TokenLocking/ActivityRewardsInfo.tsx b/src/components/TokenLocking/ActivityRewardsInfo.tsx index 51352c9e..9c3ecd5b 100644 --- a/src/components/TokenLocking/ActivityRewardsInfo.tsx +++ b/src/components/TokenLocking/ActivityRewardsInfo.tsx @@ -75,7 +75,7 @@ export const ActivityRewardsInfo = () => { description="Lock your tokens early to increase your earning power. The earlier and more you lock, the bigger your points multiplier. Geographic & other limitations apply (see disclaimer below)." /> diff --git a/src/hooks/useLatestCampaignUpdate.ts b/src/hooks/useLatestCampaignUpdate.ts new file mode 100644 index 00000000..582233d8 --- /dev/null +++ b/src/hooks/useLatestCampaignUpdate.ts @@ -0,0 +1,51 @@ +import { useAddress } from './useAddress' +import { PaginatedResult, useGatewayBaseUrl } from './useGatewayBaseUrl' +import useSWR from 'swr' +import { useMemo } from 'react' + +type CampaignUpdate = { + startDate: string + endDate: string + holder: string + boost: number + totalPoints: number + totalBoostedPoints: number +} + +export const useLatestCampaignUpdate = (resourceId: string | undefined) => { + const address = useAddress() + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (resourceId: string | undefined) => { + if (!resourceId || !address) { + return null + } + // Load newest page only + return `${gatewayBaseUrl}/v1/community/campaigns/${resourceId}/activities/?holder=${address}` + } + + const { data, isLoading } = useSWR( + getKey(resourceId), + async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise> + } else { + throw new Error('Error fetching campaign update.') + } + }) + }, + { + errorRetryCount: 1, + }, + ) + + // Only return newest entry + const newestUpdate = useMemo(() => { + if (data && data.results.length > 0) { + return data.results[0] + } + }, [data]) + + return { data: newestUpdate, isLoading } +} From 23eb668ef072f5f2c90b46385800a4263dbb15c8 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 24 Jun 2024 16:59:14 +0200 Subject: [PATCH 5/6] fix: show latest campaign update (#187) --- src/components/Points/ActivityPointsFeed.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Points/ActivityPointsFeed.tsx b/src/components/Points/ActivityPointsFeed.tsx index 19799d3e..c28f3e59 100644 --- a/src/components/Points/ActivityPointsFeed.tsx +++ b/src/components/Points/ActivityPointsFeed.tsx @@ -143,7 +143,7 @@ export const ActivityPointsFeed = ({ campaign }: { campaign?: Campaign }) => { <> - Last drop + Your last drop {latestUpdate && ( {formatDate(new Date(latestUpdate.startDate))} - {formatDate(new Date(latestUpdate.endDate))} @@ -184,7 +184,10 @@ export const ActivityPointsFeed = ({ campaign }: { campaign?: Campaign }) => { )} - Your points are updated weekly. + Points are updated weekly. + + + Last update: {formatDate(new Date(campaign.lastUpdated))} From 8058f22791a4ecea6c04cb82226f486e21b25a33 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 25 Jun 2024 13:11:48 +0200 Subject: [PATCH 6/6] chore: bump version (#188) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27ccf07c..f5af2cfc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safe-dao-governance-app", "homepage": "https://github.com/safe-global/safe-dao-governance-app", "license": "GPL-3.0", - "version": "2.1.0", + "version": "2.2.0", "scripts": { "build": "next build && next export", "lint": "tsc && next lint",