diff --git a/cypress/assertions/funding.ts b/cypress/assertions/funding.ts index ab0e6c4b3..b3a10c2e5 100644 --- a/cypress/assertions/funding.ts +++ b/cypress/assertions/funding.ts @@ -4,7 +4,7 @@ export const fundingAmountScreenIsVisible = () => { } export const commentScreenIsVisible = () => { - cy.get('h1').contains('Public message').should('be.visible') + cy.get('h1').contains('Public comment').should('be.visible') } export const lightningQrScreenIsVisible = () => { diff --git a/language/translations/en.json b/language/translations/en.json index 1f6c64851..df10d6e62 100644 --- a/language/translations/en.json +++ b/language/translations/en.json @@ -1248,7 +1248,6 @@ "Select rewards": "Select rewards", "Email will be sent to": "Email will be sent to", "members.": "members.", - "Completed Goals": "Completed Goals", "Welcome to the Bitcoin Crowdfunding Platform": "Welcome to the Bitcoin Crowdfunding Platform", "Projects exposing new stories through film and documentaries.": "Projects exposing new stories through film and documentaries.", "Projects building or bringing about Nostr adoption around the world.": "Projects building or bringing about Nostr adoption around the world.", @@ -1260,10 +1259,19 @@ "Successfully deleted post!": "Successfully deleted post!", "Back to posts": "Back to posts", "Failed to fetch featured project": "Failed to fetch featured project", - "Post saved successfully!": "Post saved successfully!", - "failed to upload image": "failed to upload image", - "Contribute without login": "Contribute without login", + "Completed Goals": "Completed Goals", "Provide your Lightning Address for a full or partial refund": "Provide your Lightning Address for a full or partial refund", + "Download and securely store your Refund File; if in doubt, re-download to ensure its safety.": "Download and securely store your Refund File; if in doubt, re-download to ensure its safety.", + "Transaction is being processed": "Transaction is being processed", + "Get notified via email (optional)": "Get notified via email (optional)", + "Enter notified by email (optional)": "Enter notified by email (optional)", + "Wallets are marked as unstable when a transaction fails due to a transaction failure, not enough inbound liquidity, or other. Consider making a small transaction to set your project back to active, or change your wallet.": "Wallets are marked as unstable when a transaction fails due to a transaction failure, not enough inbound liquidity, or other. Consider making a small transaction to set your project back to active, or change your wallet.", + "Your project cannot receive contributions but is visible to the public. To reactivate your project go to Setting.": "Your project cannot receive contributions but is visible to the public. To reactivate your project go to Setting.", + "Your wallet is not functional. Please change your wallet to receive contributions. ": "Your wallet is not functional. Please change your wallet to receive contributions. ", + "Your project is in review and therefore cannot receive contributions": "Your project is in review and therefore cannot receive contributions", + "Your project is live and can receive contributions. Share your project to get more visibility.": "Your project is live and can receive contributions. Share your project to get more visibility.", + "Your project is in review and therefore cannot receive contributions, and is not visible by the public.": "Your project is in review and therefore cannot receive contributions, and is not visible by the public.", + "You project has been flagged for violating our Terms & Conditions. You should have received an email with further detail on how to proceed. Your project is currently not visible to the public.": "You project has been flagged for violating our Terms & Conditions. You should have received an email with further detail on how to proceed. Your project is currently not visible to the public.", "To a goal": "To a goal", "Email and Updates": "Email and Updates", "Rewards total": "Rewards total", @@ -1307,5 +1315,44 @@ "Receive One Time Password": "Receive One Time Password", "We sent you an OTP code to": "We sent you an OTP code to", "Paste (or type) it below to continue.": "Paste (or type) it below to continue.", - "Resend code": "Resend code" + "No contributions have been made to this project.": "No contributions have been made to this project.", + "Banner": "Banner", + "Spread the word": "Spread the word", + "Hall of fame": "Hall of fame", + "Hero Card": "Hero Card", + "This month": "This month", + "Hall of Fame": "Hall of Fame", + "The Projects and Heroes bringing Bitcoin closer to mass adoption": "The Projects and Heroes bringing Bitcoin closer to mass adoption", + "Top Projects": "Top Projects", + "Top Heroes": "Top Heroes", + "Creators": "Creators", + "Ambassadors": "Ambassadors", + "Discover the top projects making a significant impact on Bitcoin’s mass adoption": "Discover the top projects making a significant impact on Bitcoin’s mass adoption", + "Raised <1>{{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions from <5>{{numberOfFunders}} users": "Raised <1>{{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions from <5>{{numberOfFunders}} users", + "Those bringing the most successful projects to life": "Those bringing the most successful projects to life", + "Those whose contributions power projects on Geyser, driving Bitcoin adoption": "Those whose contributions power projects on Geyser, driving Bitcoin adoption", + "Heroes Hall of Fame": "Heroes Hall of Fame", + "Those spreading the word about valuable projects and enabling contributions to happen": "Those spreading the word about valuable projects and enabling contributions to happen", + "Share the Heroes Hall of Fame to showcase the top Contributors, Creators, and Ambassadors of the Bitcoin ecosystem!": "Share the Heroes Hall of Fame to showcase the top Contributors, Creators, and Ambassadors of the Bitcoin ecosystem!", + "Hero link:": "Hero link:", + "Top Contributors": "Top Contributors", + "Contributed <1>{{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions to <5>{{numberOfProjects}} projects": "Contributed <1>{{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions to <5>{{numberOfProjects}} projects", + "Top Creators": "Top Creators", + "Raised <1>{{usdAmount}} ({{satsAmount}} sats) across <3>{{numberOfProjects}} projects": "Raised <1>{{usdAmount}} ({{satsAmount}} sats) across <3>{{numberOfProjects}} projects", + "Top Ambassadors": "Top Ambassadors", + "made in contributions": "made in contributions", + "raised in contributions": "raised in contributions", + "Hero Rank": "Hero Rank", + "Ambassador": "Ambassador", + "of enabled contributions": "of enabled contributions", + "Error fetching user profile": "Error fetching user profile", + "Copy card": "Copy card", + "Hero cards are a summary of your activity in the Bitcoin space. You can share them with your friends!": "Hero cards are a summary of your activity in the Bitcoin space. You can share them with your friends!", + "Creator Ranking": "Creator Ranking", + "Contributor Ranking": "Contributor Ranking", + "Ambassador Ranking": "Ambassador Ranking", + "Current month": "Current month", + "Current week": "Current week", + "Resend code": "Resend code", + "Geyser Manifesto": "Geyser Manifesto" } \ No newline at end of file diff --git a/package.json b/package.json index 4dbe1782c..e88b6a8dd 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "react-icons": "^5.2.1", "react-jss": "^10.9.2", "react-otp-input": "^3.1.1", + "react-parallax-tilt": "^1.7.257", "react-player": "^2.16.0", "react-qr-code": "^2.0.11", "react-qrcode-logo": "^3.0.0", @@ -107,6 +108,7 @@ "react-router-dom": "^6.25.1", "react-simple-pull-to-refresh": "^1.3.3", "react-swipeable": "^7.0.1", + "react-truncate-inside": "^1.0.3", "react-tweet-embed": "^2.0.0", "react-use-websocket": "^4.8.1", "recharts": "^2.12.7", diff --git a/src/api/bitcoin.ts b/src/api/bitcoin.ts index b8566c02a..febf67a43 100644 --- a/src/api/bitcoin.ts +++ b/src/api/bitcoin.ts @@ -30,3 +30,10 @@ const getUsdQuote = async (): Promise => { } export const fetchBitcoinRates = ({ currency: _ }: { currency: 'usd' }) => getUsdQuote() + +export const getBlockHeight = async (): Promise => { + const response = await fetch('https://blockstream.info/api/blocks/tip/height') + const data = await response.json() + + return data +} diff --git a/src/components/molecules/ShareView.tsx b/src/components/molecules/ShareView.tsx new file mode 100644 index 000000000..5eff9ddfc --- /dev/null +++ b/src/components/molecules/ShareView.tsx @@ -0,0 +1,108 @@ +import { Box, Button, HStack, Icon, Link, useClipboard, VStack } from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { PiCopy, PiShareFat } from 'react-icons/pi' +import Truncate from 'react-truncate-inside' + +import { FlowingGifBackground } from '@/modules/discovery/pages/hallOfFame/components/FlowingGifBackground' +import { Body } from '@/shared/components/typography' +import { lightModeColors } from '@/shared/styles' +import { useNotification } from '@/utils' + +export const ShareView = ({ + shareOnXUrl, + shareUrl, + shareUrlLabel, + children, +}: { + shareOnXUrl: string + shareUrl: string + shareUrlLabel: string + children: React.ReactNode +}) => { + const { onCopy } = useClipboard(shareUrl) + const toast = useNotification() + const { t } = useTranslation() + + const handleCopy = () => { + onCopy() + toast.success({ + title: t('Copied!'), + description: t('Hero link copied to clipboard'), + }) + } + + return ( + + + + + {children} + + + + + + {shareUrlLabel} + + + <> + {shareUrlLabel || shareUrl.replace('https://', '').length > 40 ? ( + + + + ) : ( + {shareUrl.replace('https://', '')} + )} + + + + + + + + + + ) +} diff --git a/src/components/molecules/projectActivity/ProjectFundingContributorsItem.tsx b/src/components/molecules/projectActivity/ProjectFundingContributorsItem.tsx index 2e282bbed..884df51d5 100644 --- a/src/components/molecules/projectActivity/ProjectFundingContributorsItem.tsx +++ b/src/components/molecules/projectActivity/ProjectFundingContributorsItem.tsx @@ -23,7 +23,7 @@ export const ProjectFundingContributorsItem = ({ contributor, project, ...rest } return ( dropdownIndicator?: React.ReactNode width?: ResponsiveValue fontSize?: string + dropdownIndicatorPosition?: 'left' | 'right' } export function CustomSelect({ customChakraStyles, dropdownIndicator, width, + dropdownIndicatorPosition = 'right', ...props }: CustomSelectProps) { const chakraStyles: ChakraStylesConfig = { @@ -64,6 +66,7 @@ export function CustomSelect({ control: (provided) => ({ ...provided, borderColor: 'neutral1.6', + flexDirection: dropdownIndicatorPosition === 'left' ? 'row-reverse' : 'row', _hover: { borderColor: 'neutral1.7', }, diff --git a/src/config/GlobalStyles.tsx b/src/config/GlobalStyles.tsx index 0ec9cead4..52671ac7a 100644 --- a/src/config/GlobalStyles.tsx +++ b/src/config/GlobalStyles.tsx @@ -9,7 +9,8 @@ const GlobalStyles = () => ( @import url('https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Livvic:wght@400;500;600;700&display=swap'); - @import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap'); + @import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&family=Hubot+Sans:ital,wght@0,200..900;1,200..900&display=swap'); + @font-face { font-family: "Figtree", sans-serif; diff --git a/src/config/routes/routeGroups.ts b/src/config/routes/routeGroups.ts index 48742fcdb..852aeba1e 100644 --- a/src/config/routes/routeGroups.ts +++ b/src/config/routes/routeGroups.ts @@ -128,6 +128,11 @@ export const discoveryRoutes = [ getPath('discoveryLeaderboard'), getPath('discoveryGrants'), getPath('discoveryGrant', PathName.grantId), + getPath('discoveryHallOfFame'), + getPath('hallOfFameProjects'), + getPath('hallOfFameHeroesCreator'), + getPath('hallOfFameHeroesAmbassador'), + getPath('hallOfFameHeroesContributor'), ] export const profileSettingsRoutes = [ @@ -136,6 +141,13 @@ export const profileSettingsRoutes = [ getPath('userProfileSettingsNotifications', PathName.userId), ] +export const heroProfileSettingsRoutes = [ + getPath('heroProfileSettings', PathName.heroId), + getPath('heroProfileSettingsGeneral', PathName.heroId), + getPath('heroProfileSettingsNotifications', PathName.heroId), +] + export const profileRoutes = [getPath('userProfile', PathName.userId), ...profileSettingsRoutes] +export const heroProfileRoutes = [getPath('heroProfile', PathName.heroId), ...heroProfileSettingsRoutes] export const fallBackRoutes = [getPath('notFound'), getPath('notAuthorized')] diff --git a/src/config/routes/routes.tsx b/src/config/routes/routes.tsx index 17799bedc..e7718f10a 100644 --- a/src/config/routes/routes.tsx +++ b/src/config/routes/routes.tsx @@ -25,6 +25,8 @@ const CreatorPost = () => import('../../modules/project/pages1/projectView/views const Discovery = () => import('../../modules/discovery') +const HallOfFame = () => import('../../modules/discovery/pages/hallOfFame') + const Refund = () => import('../../modules/project/pages1/projectFunding/views/refund') const ProfilePage = () => import('../../modules/profile') @@ -128,6 +130,9 @@ export const platformRoutes: RouteObject[] = [ ], }, + /* + Deprecate for hero routes + */ { path: getPath('userProfile', PathName.userId), async lazy() { @@ -177,6 +182,55 @@ export const platformRoutes: RouteObject[] = [ ], }, + { + path: getPath('heroProfile', PathName.heroId), + async lazy() { + const ProfileMain = await ProfilePage().then((m) => m.ProfileMain) + return { Component: ProfileMain } + }, + children: [ + { + index: true, + async lazy() { + const Profile = await ProfilePage().then((m) => m.Profile) + return { Component: Profile } + }, + }, + { + path: getPath('heroProfileSettings', PathName.heroId), + async lazy() { + const ProfileSettings = await ProfileSettingsIndex().then((m) => m.ProfileSettings) + return { element: renderPrivateRoute(ProfileSettings) } + }, + children: [ + { + index: true, + async lazy() { + const ProfileSettingsMain = await ProfileSettingsIndex().then((m) => m.ProfileSettingsMain) + return { Component: ProfileSettingsMain } + }, + }, + { + path: getPath('heroProfileSettingsGeneral', PathName.heroId), + async lazy() { + const ProfileSettingsGeneral = await ProfileSettingsIndex().then((m) => m.ProfileSettingsGeneral) + return { Component: ProfileSettingsGeneral } + }, + }, + { + path: getPath('heroProfileSettingsNotifications', PathName.heroId), + async lazy() { + const ProfileSettingsNotifications = await ProfileSettingsIndex().then( + (m) => m.ProfileSettingsNotifications, + ) + return { Component: ProfileSettingsNotifications } + }, + }, + ], + }, + ], + }, + { path: getPath('project', PathName.projectName), async lazy() { @@ -572,6 +626,41 @@ export const platformRoutes: RouteObject[] = [ }, ], }, + { + path: getPath('discoveryHallOfFame'), + async lazy() { + const HallOfFamePage = await HallOfFame().then((m) => m.HallOfFame) + return { Component: HallOfFamePage } + }, + }, + { + path: getPath('hallOfFameProjects'), + async lazy() { + const ProjectLeaderboard = await HallOfFame().then((m) => m.ProjectLeaderboard) + return { Component: ProjectLeaderboard } + }, + }, + { + path: getPath('hallOfFameHeroesAmbassador'), + async lazy() { + const Heroes = await HallOfFame().then((m) => m.Heroes) + return { Component: Heroes } + }, + }, + { + path: getPath('hallOfFameHeroesCreator'), + async lazy() { + const Heroes = await HallOfFame().then((m) => m.Heroes) + return { Component: Heroes } + }, + }, + { + path: getPath('hallOfFameHeroesContributor'), + async lazy() { + const Heroes = await HallOfFame().then((m) => m.Heroes) + return { Component: Heroes } + }, + }, { path: getPath('discoveryLeaderboard'), async lazy() { diff --git a/src/defaults/user.ts b/src/defaults/user.ts index 0cea2cc87..b3157d86f 100644 --- a/src/defaults/user.ts +++ b/src/defaults/user.ts @@ -1,10 +1,11 @@ import { User } from '../types/generated/graphql' -export const defaultUser: User = { +export const defaultUser: Omit = { __typename: 'User', id: 0, email: '', username: '', + heroId: '', imageUrl: '', externalAccounts: [], contributions: [], diff --git a/src/graphqlBase/fragments/user.ts b/src/graphqlBase/fragments/user.ts index 48319f9a8..0f2ebec5c 100644 --- a/src/graphqlBase/fragments/user.ts +++ b/src/graphqlBase/fragments/user.ts @@ -34,6 +34,7 @@ export const FRAGMENT_USER_ME = gql` fragment UserMe on User { id username + heroId imageUrl email ranking @@ -69,6 +70,7 @@ export const FRAGMENT_FUNDER_WITH_USER = gql` user { id username + heroId hasSocialAccount externalAccounts { externalId diff --git a/src/modules/discovery/pages/hallOfFame/HallOfFame.tsx b/src/modules/discovery/pages/hallOfFame/HallOfFame.tsx new file mode 100644 index 000000000..5374f0190 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/HallOfFame.tsx @@ -0,0 +1,15 @@ +import { VStack } from '@chakra-ui/react' + +import { GeyserHeroes } from './components/GeyserHeroes' +import { HallOfFameTitle } from './components/HallOfFameTitle' +import { TopProjects } from './components/TopProjects' + +export const HallOfFame = () => { + return ( + + + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/FlowingGifBackground.tsx b/src/modules/discovery/pages/hallOfFame/components/FlowingGifBackground.tsx new file mode 100644 index 000000000..04fde1af9 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/FlowingGifBackground.tsx @@ -0,0 +1,22 @@ +import { Box, BoxProps } from '@chakra-ui/react' + +import { HallOfFameAnimatedGifUrl } from '@/shared/constants' + +export const FlowingGifBackground = (props: BoxProps) => { + return ( + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/GeyserHeroes.tsx b/src/modules/discovery/pages/hallOfFame/components/GeyserHeroes.tsx new file mode 100644 index 000000000..472323e2b --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/GeyserHeroes.tsx @@ -0,0 +1,152 @@ +import { HStack, Stack, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useState } from 'react' +import { Link } from 'react-router-dom' + +import { ImageWithReload } from '@/components/ui' +import { RankMedal } from '@/shared/components/display/RankMedal' +import { CardLayout, SkeletonLayout } from '@/shared/components/layouts' +import { Body } from '@/shared/components/typography' +import { getPath } from '@/shared/constants' +import { useCurrencyFormatter } from '@/shared/utils/hooks' +import { FormatCurrencyType } from '@/shared/utils/hooks/useCurrencyFormatter' +import { LeaderboardPeriod } from '@/types' +import { getShortAmountLabel } from '@/utils' + +import { useTopContributors } from '../hooks' +import { useTopAmbassadors } from '../hooks/useTopAmbassadors' +import { StandardOption } from '../types' +import { TitleWithPeriod } from './TitleWithPeriod' + +const MAX_HEROES = 5 + +const HeroListLabels = { username: 'username', amount: 'contributionsTotal', usdAmount: 'contributionsTotalUsd' } + +export const GeyserHeroes = () => { + const [period, setPeriod] = useState(LeaderboardPeriod.Month) + + const handlePeriodChange = (selectedOption: StandardOption | null) => { + if (selectedOption) { + setPeriod(selectedOption.value) + } + } + + const { contributors, loading: contributorsLoading } = useTopContributors(period, MAX_HEROES) + + const { ambassadors, loading: ambassadorsLoading } = useTopAmbassadors(period, MAX_HEROES) + + return ( + + + + + + + + + + + + + + + ) +} + +const HeroSectionWrapper = ({ + title, + description, + children, +}: { + title: string + description: string + children: React.ReactNode +}) => { + return ( + + + + {title} + + {description} + + {children} + + ) +} + +const RenderHeroList = ({ + period, + data, + loading, + labels, +}: { + period: LeaderboardPeriod + data: any[] + loading?: boolean + labels: { username: string; amount: string; usdAmount: string } +}) => { + const { formatAmount } = useCurrencyFormatter() + + return ( + + {loading + ? [...Array(6).keys()].map((key) => ) + : data.map((datum, index) => { + return ( + + + + + + {datum[labels?.username]} + + + {`${formatAmount(datum[labels?.usdAmount], FormatCurrencyType.Usd)} `} + {`(${getShortAmountLabel(datum[labels?.amount])} sats)`} + + + + ) + })} + + ) +} + +const HeroesListitemSkeleton = () => { + return ( + + + + + + + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/HallOfFameTitle.tsx b/src/modules/discovery/pages/hallOfFame/components/HallOfFameTitle.tsx new file mode 100644 index 000000000..1ab7f7b27 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/HallOfFameTitle.tsx @@ -0,0 +1,101 @@ +import { HStack, Icon, Image, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { PiDotOutline } from 'react-icons/pi' + +import { CardLayout, SkeletonLayout } from '@/shared/components/layouts' +import { Body, H2 } from '@/shared/components/typography' +import { HallOfFameIllustrationUrl } from '@/shared/constants' +import { lightModeColors } from '@/shared/styles' +import { commaFormatted, getBitcoinAmount, getShortAmountLabel } from '@/utils' + +import { useSummaryBannerStats } from '../hooks' +import { FlowingGifBackground } from './FlowingGifBackground' + +export const HallOfFameTitle = () => { + const { projectsCount, bitcoinsRaised, contributorsCount, loading: projectStatLoading } = useSummaryBannerStats() + + const bannerItems = [ + { label: 'Contributors', value: getShortAmountLabel(contributorsCount) }, + { label: 'Raised', value: `${getBitcoinAmount(bitcoinsRaised, true)} ₿` }, + { label: 'Projects', value: commaFormatted(projectsCount) }, + ] + const padding = { base: 4, lg: '6' } + + const renderPlatformStats = () => { + if (projectStatLoading) return + + return bannerItems.map((item, index) => ( + <> + + {item.value} {item.label} + + {index < bannerItems.length - 1 && } + + )) + } + + return ( + + + + + Hall of Fame + +

+ {t('Hall of Fame')} +

+ + + {t('The Projects and Heroes bringing Bitcoin closer to mass adoption')} + + + {renderPlatformStats()} + +
+
+ + {renderPlatformStats()} + +
+ ) +} + +const ProjectStatSkeleton = () => { + return [1, 2, 3].map((key) => { + return ( + <> + + {key < 3 && } + + ) + }) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/IndividualHallOfFameTitle.tsx b/src/modules/discovery/pages/hallOfFame/components/IndividualHallOfFameTitle.tsx new file mode 100644 index 000000000..26d677c3c --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/IndividualHallOfFameTitle.tsx @@ -0,0 +1,50 @@ +import { VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import React from 'react' + +import { CardLayout, CardLayoutProps } from '@/shared/components/layouts' +import { Body, H2 } from '@/shared/components/typography' +import { lightModeColors } from '@/shared/styles' + +import { FlowingGifBackground } from './FlowingGifBackground' + +type IndividualHallOfFameTitleProps = { + title?: string + description?: string + background?: string + children?: React.ReactNode +} & CardLayoutProps + +export const IndividualHallOfFameTitle = ({ + title, + description, + background, + children, + ...rest +}: IndividualHallOfFameTitleProps) => { + return ( + + + + +

+ {title || t('Projects Hall of Fame')} +

+ + + {description || t('Discover the top projects making a significant impact on Bitcoin’s mass adoption')} + + {children} +
+
+ ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/TitleWithPeriod.tsx b/src/modules/discovery/pages/hallOfFame/components/TitleWithPeriod.tsx new file mode 100644 index 000000000..2faf04316 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/TitleWithPeriod.tsx @@ -0,0 +1,78 @@ +import { Button, HStack, StackProps } from '@chakra-ui/react' +import { t } from 'i18next' +import { PiCalendarDots } from 'react-icons/pi' +import { Link } from 'react-router-dom' + +import { CustomSelect } from '@/components/ui/CustomSelect' +import { H3 } from '@/shared/components/typography' +import { LeaderboardPeriod } from '@/types' + +import { StandardOption } from '../types' + +export const periodOptions: StandardOption[] = [ + { value: LeaderboardPeriod.Month, label: t('This month') }, + { value: LeaderboardPeriod.AllTime, label: t('All time') }, +] + +type TitleWithPeriodProps = { + title: string + period: LeaderboardPeriod + seeAllTo?: string + handlePeriodChange: (selectedOption: StandardOption | null) => void +} & StackProps + +export const TitleWithPeriod = ({ title, period, seeAllTo, handlePeriodChange, ...props }: TitleWithPeriodProps) => { + return ( + + +

+ {title} +

+ {seeAllTo && ( + + )} +
+ + {seeAllTo && ( + + )} + option.value === period)} + onChange={handlePeriodChange} + placeholder={t('Select period...')} + dropdownIndicator={} + dropdownIndicatorPosition="left" + fontSize="sm" + customChakraStyles={{ + control: (provided) => ({ + ...provided, + height: '32px', + minHeight: '32px', + }), + }} + /> + +
+ ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/TopHeroes.tsx b/src/modules/discovery/pages/hallOfFame/components/TopHeroes.tsx new file mode 100644 index 000000000..ebf914e67 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/TopHeroes.tsx @@ -0,0 +1,262 @@ +import { HStack, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useState } from 'react' +import { Trans } from 'react-i18next' +import { Link } from 'react-router-dom' + +import { ImageWithReload } from '@/components/ui' +import { RankMedal } from '@/shared/components/display/RankMedal' +import { SkeletonLayout } from '@/shared/components/layouts' +import { Body } from '@/shared/components/typography' +import { getPath } from '@/shared/constants' +import { standardPadding } from '@/shared/styles' +import { useCurrencyFormatter } from '@/shared/utils/hooks' +import { FormatCurrencyType } from '@/shared/utils/hooks/useCurrencyFormatter' +import { LeaderboardPeriod } from '@/types' +import { getShortAmountLabel } from '@/utils' + +import { useTopContributors } from '../hooks' +import { useTopAmbassadors } from '../hooks/useTopAmbassadors' +import { useTopCreators } from '../hooks/useTopCreators' +import { StandardOption } from '../types' +import { TitleWithPeriod } from './TitleWithPeriod' + +export enum HeroType { + Creators = 'Creators', + Contributors = 'Contributors', + Ambassadors = 'Ambassadors', +} + +const MAX_HERO_COUNT = 100 + +const labelsCollection = { + [HeroType.Contributors]: { + amountUsd: 'contributionsTotalUsd', + amount: 'contributionsTotal', + numberOfContributions: 'contributionsCount', + numberOfProjects: 'projectsContributedCount', + }, + [HeroType.Ambassadors]: { + amountUsd: 'contributionsTotalUsd', + amount: 'contributionsTotal', + numberOfContributions: '', + numberOfProjects: 'projectsCount', + }, + [HeroType.Creators]: { + amountUsd: 'contributionsTotalUsd', + amount: 'contributionsTotal', + numberOfContributions: '', + numberOfProjects: 'projectsCount', + }, +} + +export const TopHeroes = ({ heroType }: { heroType: HeroType }) => { + const [period, setPeriod] = useState(LeaderboardPeriod.Month) + + const handlePeriodChange = (selectedOption: StandardOption | null) => { + if (selectedOption) { + setPeriod(selectedOption.value) + } + } + + const { contributors, loading: contributorsLoading } = useTopContributors(period, MAX_HERO_COUNT, { + skip: heroType !== HeroType.Contributors, + }) + + const { ambassadors, loading: ambassadorsLoading } = useTopAmbassadors(period, MAX_HERO_COUNT, { + skip: heroType !== HeroType.Ambassadors, + }) + + const { creators, loading: creatorsLoading } = useTopCreators(period, MAX_HERO_COUNT, { + skip: heroType !== HeroType.Creators, + }) + + const currentData = + heroType === HeroType.Contributors ? contributors : heroType === HeroType.Ambassadors ? ambassadors : creators + + const loading = + heroType === HeroType.Contributors + ? contributorsLoading + : heroType === HeroType.Ambassadors + ? ambassadorsLoading + : creatorsLoading + + return ( + + + + {loading + ? Array.from({ length: 5 }).map((_, index) => ) + : currentData.map((data, index) => { + return ( + + ) + })} + + + ) +} + +const HeroDisplay = ({ + heroType, + rank, + data, + labels, +}: { + heroType: HeroType + rank: number + data: { [key: string]: any } + labels: { amountUsd: string; amount: string; numberOfContributions: string; numberOfProjects: string } +}) => { + const { formatAmount } = useCurrencyFormatter() + + const renderContributorHeroStats = () => { + return ( + + {{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions to <5>{{numberOfProjects}} projects' + } + values={{ + usdAmount: formatAmount(data[labels.amountUsd], FormatCurrencyType.Usdcent), + satsAmount: getShortAmountLabel(data[labels.amount]), + numberOfContributions: data[labels.numberOfContributions], + numberOfProjects: data[labels.numberOfProjects], + }} + > + {'Contributed '} + + {'{{usdAmount}}'} + + {' ({{satsAmount}} sats) with '} + + {'{{numberOfContributions}}'} + + {' contributions to '} + + {'{{numberOfProjects}}'} + + {' projects'} + + + ) + } + + const renderAmbassadorHeroStats = () => { + return ( + + {{usdAmount}} ({{satsAmount}} sats) across <3>{{numberOfProjects}} projects'} + values={{ + usdAmount: formatAmount(data[labels.amountUsd], FormatCurrencyType.Usdcent), + satsAmount: getShortAmountLabel(data[labels.amount]), + numberOfProjects: data[labels.numberOfProjects], + }} + > + {'Enabled '} + + {'{{usdAmount}}'} + + {' ({{satsAmount}} sats) across '} + + {'{{numberOfProjects}}'} + + {' projects'} + + + ) + } + + const renderCreatorHeroStats = () => { + return ( + + {{usdAmount}} ({{satsAmount}} sats) across <3>{{numberOfProjects}} projects'} + values={{ + usdAmount: formatAmount(data[labels.amountUsd], FormatCurrencyType.Usdcent), + satsAmount: getShortAmountLabel(data[labels.amount]), + numberOfProjects: data[labels.numberOfProjects], + }} + > + {'Raised '} + + {'{{usdAmount}}'} + + {' ({{satsAmount}} sats) across '} + + {'{{numberOfProjects}}'} + + {' projects'} + + + ) + } + + const renderDescription = () => { + if (heroType === HeroType.Contributors) { + return renderContributorHeroStats() + } + + if (heroType === HeroType.Ambassadors) { + return renderAmbassadorHeroStats() + } + + if (heroType === HeroType.Creators) { + return renderCreatorHeroStats() + } + } + + return ( + + + + + + + + + {data.username} + + {renderDescription()} + + + ) +} + +const HeroDisplaySkeleton = () => { + return ( + + + + + + + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/TopProjectLeaderboard.tsx b/src/modules/discovery/pages/hallOfFame/components/TopProjectLeaderboard.tsx new file mode 100644 index 000000000..4038f662b --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/TopProjectLeaderboard.tsx @@ -0,0 +1,138 @@ +import { HStack, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useState } from 'react' +import { Trans } from 'react-i18next' +import { Link } from 'react-router-dom' + +import { ImageWithReload } from '@/components/ui' +import { RankMedal } from '@/shared/components/display/RankMedal' +import { SkeletonLayout } from '@/shared/components/layouts' +import { Body } from '@/shared/components/typography' +import { getPath } from '@/shared/constants' +import { standardPadding } from '@/shared/styles' +import { useCurrencyFormatter } from '@/shared/utils/hooks' +import { FormatCurrencyType } from '@/shared/utils/hooks/useCurrencyFormatter' +import { GlobalProjectLeaderboardRow, LeaderboardPeriod } from '@/types' +import { getShortAmountLabel } from '@/utils' + +import { useTopProjects } from '../hooks' +import { StandardOption } from '../types' +import { TitleWithPeriod } from './TitleWithPeriod' + +const MAX_PROJECTS = 100 + +export const TopProjectLeaderboard = () => { + const [period, setPeriod] = useState(LeaderboardPeriod.Month) + + const handlePeriodChange = (selectedOption: StandardOption | null) => { + if (selectedOption) { + setPeriod(selectedOption.value) + } + } + + const { projects, loading } = useTopProjects(period, MAX_PROJECTS) + + return ( + + + + {loading + ? [...Array(9).keys()].map((key) => { + return + }) + : projects.map((project, index) => { + return + })} + + + ) +} + +const ProjectHeroDisplay = ({ project, index }: { project: GlobalProjectLeaderboardRow; index: number }) => { + const { formatAmount } = useCurrencyFormatter() + return ( + + + + + + + + + {project.projectTitle} + + + {{usdAmount}} ({{satsAmount}} sats) with <3>{{numberOfContributions}} contributions from <5>{{numberOfFunders}} users' + } + values={{ + usdAmount: formatAmount(project.contributionsTotalUsd, FormatCurrencyType.Usd), + satsAmount: getShortAmountLabel(project.contributionsTotal), + numberOfContributions: project.contributionsCount, + numberOfFunders: project.contributorsCount, + }} + > + {'Raised '} + + {'{{usdAmount}}'} + + {' ({{satsAmount}} sats) with '} + + {'{{numberOfContributions}}'} + + {' contributions from '} + + {'{{numberOfFunders}}'} + + {' users'} + + + + + + ) +} + +const ProjectHeroDisplaySkeleton = () => { + return ( + + + + + + + + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/components/TopProjects.tsx b/src/modules/discovery/pages/hallOfFame/components/TopProjects.tsx new file mode 100644 index 000000000..26bf95c0a --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/components/TopProjects.tsx @@ -0,0 +1,106 @@ +import { HStack, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useState } from 'react' +import { Link } from 'react-router-dom' + +import { ImageWithReload } from '@/components/ui' +import { RankMedal } from '@/shared/components/display/RankMedal' +import { CardLayout, SkeletonLayout } from '@/shared/components/layouts' +import { Body } from '@/shared/components/typography' +import { getPath } from '@/shared/constants' +import { useCurrencyFormatter } from '@/shared/utils/hooks' +import { FormatCurrencyType } from '@/shared/utils/hooks/useCurrencyFormatter' +import { GlobalProjectLeaderboardRow, LeaderboardPeriod } from '@/types' +import { getShortAmountLabel } from '@/utils' + +import { useTopProjects } from '../hooks' +import { StandardOption } from '../types' +import { TitleWithPeriod } from './TitleWithPeriod' + +const MAX_PROJECTS = 9 + +export const TopProjects = () => { + const [period, setPeriod] = useState(LeaderboardPeriod.Month) + + const handlePeriodChange = (selectedOption: StandardOption | null) => { + if (selectedOption) { + setPeriod(selectedOption.value) + } + } + + const { projects, loading } = useTopProjects(period, MAX_PROJECTS) + + return ( + + + + {loading + ? [...Array(9).keys()].map((key) => { + return + }) + : projects.map((project, index) => { + return + })} + + + ) +} + +const ProjectHeroDisplay = ({ project, index }: { project: GlobalProjectLeaderboardRow; index: number }) => { + const { formatAmount } = useCurrencyFormatter() + return ( + + + + + + + + + {project.projectTitle} + + + {`${formatAmount(project.contributionsTotalUsd, FormatCurrencyType.Usd)} `} + {`(${getShortAmountLabel(project.contributionsTotal)} sats)`} + + + + + ) +} + +const ProjectHeroDisplaySkeleton = () => { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/modules/discovery/pages/leaderboard/graphql/fragments/summaryBannerFragment.ts b/src/modules/discovery/pages/hallOfFame/graphql/fragments/summaryBannerFragment.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/graphql/fragments/summaryBannerFragment.ts rename to src/modules/discovery/pages/hallOfFame/graphql/fragments/summaryBannerFragment.ts diff --git a/src/modules/discovery/pages/hallOfFame/graphql/fragments/topAmbassadorsFragment.ts b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topAmbassadorsFragment.ts new file mode 100644 index 000000000..29d697b3d --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topAmbassadorsFragment.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client' + +export const TOP_AMBASSADORS_FRAGMENT = gql` + fragment TopAmbassadorsFragment on GlobalAmbassadorLeaderboardRow { + contributionsTotal + contributionsTotalUsd + projectsCount + userId + userImageUrl + username + } +` diff --git a/src/modules/discovery/pages/leaderboard/graphql/fragments/topContributorsFragment.ts b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topContributorsFragment.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/graphql/fragments/topContributorsFragment.ts rename to src/modules/discovery/pages/hallOfFame/graphql/fragments/topContributorsFragment.ts diff --git a/src/modules/discovery/pages/hallOfFame/graphql/fragments/topCreatorsFragment.ts b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topCreatorsFragment.ts new file mode 100644 index 000000000..00ccfdff0 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topCreatorsFragment.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client' + +export const TOP_CREATORS_FRAGMENT = gql` + fragment TopCreatorsFragment on GlobalCreatorLeaderboardRow { + contributionsTotal + contributionsTotalUsd + projectsCount + userId + userImageUrl + username + } +` diff --git a/src/modules/discovery/pages/leaderboard/graphql/fragments/topProjectsFragment.ts b/src/modules/discovery/pages/hallOfFame/graphql/fragments/topProjectsFragment.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/graphql/fragments/topProjectsFragment.ts rename to src/modules/discovery/pages/hallOfFame/graphql/fragments/topProjectsFragment.ts diff --git a/src/modules/discovery/pages/hallOfFame/graphql/queries/topAmbassadorsQuery.ts b/src/modules/discovery/pages/hallOfFame/graphql/queries/topAmbassadorsQuery.ts new file mode 100644 index 000000000..9e04fe981 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/graphql/queries/topAmbassadorsQuery.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client' + +import { TOP_AMBASSADORS_FRAGMENT } from '../fragments/topAmbassadorsFragment' + +export const QUERY_LEADERBOARD_GLOBAL_CONTRIBUTORS = gql` + ${TOP_AMBASSADORS_FRAGMENT} + query LeaderboardGlobalAmbassadorsGet($input: LeaderboardGlobalAmbassadorsGetInput!) { + leaderboardGlobalAmbassadorsGet(input: $input) { + ...TopAmbassadorsFragment + } + } +` diff --git a/src/modules/discovery/pages/leaderboard/graphql/queries/topContributorsQuery.ts b/src/modules/discovery/pages/hallOfFame/graphql/queries/topContributorsQuery.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/graphql/queries/topContributorsQuery.ts rename to src/modules/discovery/pages/hallOfFame/graphql/queries/topContributorsQuery.ts diff --git a/src/modules/discovery/pages/hallOfFame/graphql/queries/topCreatorsQuery.ts b/src/modules/discovery/pages/hallOfFame/graphql/queries/topCreatorsQuery.ts new file mode 100644 index 000000000..47f6446db --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/graphql/queries/topCreatorsQuery.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client' + +import { TOP_CREATORS_FRAGMENT } from '../fragments/topCreatorsFragment' + +export const QUERY_LEADERBOARD_GLOBAL_CREATORS = gql` + ${TOP_CREATORS_FRAGMENT} + query LeaderboardGlobalCreatorsGet($input: LeaderboardGlobalCreatorsGetInput!) { + leaderboardGlobalCreatorsGet(input: $input) { + ...TopCreatorsFragment + } + } +` diff --git a/src/modules/discovery/pages/leaderboard/graphql/queries/topProjectsQuery.ts b/src/modules/discovery/pages/hallOfFame/graphql/queries/topProjectsQuery.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/graphql/queries/topProjectsQuery.ts rename to src/modules/discovery/pages/hallOfFame/graphql/queries/topProjectsQuery.ts diff --git a/src/modules/discovery/pages/leaderboard/hooks/index.ts b/src/modules/discovery/pages/hallOfFame/hooks/index.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/hooks/index.ts rename to src/modules/discovery/pages/hallOfFame/hooks/index.ts diff --git a/src/modules/discovery/pages/leaderboard/hooks/useSummaryBanner.ts b/src/modules/discovery/pages/hallOfFame/hooks/useSummaryBanner.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/hooks/useSummaryBanner.ts rename to src/modules/discovery/pages/hallOfFame/hooks/useSummaryBanner.ts diff --git a/src/modules/discovery/pages/hallOfFame/hooks/useTopAmbassadors.ts b/src/modules/discovery/pages/hallOfFame/hooks/useTopAmbassadors.ts new file mode 100644 index 000000000..4c7790091 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/hooks/useTopAmbassadors.ts @@ -0,0 +1,28 @@ +import { QueryHookOptions } from '@apollo/client' +import { useState } from 'react' + +import { + GlobalAmbassadorLeaderboardRow, + LeaderboardGlobalAmbassadorsGetQuery, + LeaderboardGlobalAmbassadorsGetQueryVariables, + LeaderboardPeriod, + useLeaderboardGlobalAmbassadorsGetQuery, +} from '@/types' + +export const useTopAmbassadors = ( + period: LeaderboardPeriod, + top: number, + options?: QueryHookOptions, +) => { + const [ambassadors, setAmbassadors] = useState([]) + const { loading } = useLeaderboardGlobalAmbassadorsGetQuery({ + variables: { input: { period, top } }, + ...options, + onCompleted(data) { + setAmbassadors(data.leaderboardGlobalAmbassadorsGet) + if (options?.onCompleted) options.onCompleted(data) + }, + }) + + return { ambassadors, loading } +} diff --git a/src/modules/discovery/pages/hallOfFame/hooks/useTopContributors.ts b/src/modules/discovery/pages/hallOfFame/hooks/useTopContributors.ts new file mode 100644 index 000000000..826c65167 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/hooks/useTopContributors.ts @@ -0,0 +1,28 @@ +import { QueryHookOptions } from '@apollo/client' +import { useState } from 'react' + +import { + GlobalContributorLeaderboardRow, + LeaderboardGlobalContributorsQuery, + LeaderboardGlobalContributorsQueryVariables, + LeaderboardPeriod, + useLeaderboardGlobalContributorsQuery, +} from '@/types' + +export const useTopContributors = ( + period: LeaderboardPeriod, + top: number, + options?: QueryHookOptions, +) => { + const [contributors, setContributors] = useState([]) + const { loading } = useLeaderboardGlobalContributorsQuery({ + variables: { input: { period, top } }, + ...options, + onCompleted(data) { + setContributors(data.leaderboardGlobalContributorsGet) + if (options?.onCompleted) options.onCompleted(data) + }, + }) + + return { contributors, loading } +} diff --git a/src/modules/discovery/pages/hallOfFame/hooks/useTopCreators.ts b/src/modules/discovery/pages/hallOfFame/hooks/useTopCreators.ts new file mode 100644 index 000000000..ef3d2fcea --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/hooks/useTopCreators.ts @@ -0,0 +1,28 @@ +import { QueryHookOptions } from '@apollo/client' +import { useState } from 'react' + +import { + GlobalCreatorLeaderboardRow, + LeaderboardGlobalCreatorsGetQuery, + LeaderboardGlobalCreatorsGetQueryVariables, + LeaderboardPeriod, + useLeaderboardGlobalCreatorsGetQuery, +} from '@/types' + +export const useTopCreators = ( + period: LeaderboardPeriod, + top: number, + options?: QueryHookOptions, +) => { + const [creators, setCreators] = useState([]) + const { loading } = useLeaderboardGlobalCreatorsGetQuery({ + variables: { input: { period, top } }, + ...options, + onCompleted(data) { + setCreators(data.leaderboardGlobalCreatorsGet) + if (options?.onCompleted) options.onCompleted(data) + }, + }) + + return { creators, loading } +} diff --git a/src/modules/discovery/pages/leaderboard/hooks/useTopProjects.ts b/src/modules/discovery/pages/hallOfFame/hooks/useTopProjects.ts similarity index 100% rename from src/modules/discovery/pages/leaderboard/hooks/useTopProjects.ts rename to src/modules/discovery/pages/hallOfFame/hooks/useTopProjects.ts diff --git a/src/modules/discovery/pages/hallOfFame/index.ts b/src/modules/discovery/pages/hallOfFame/index.ts new file mode 100644 index 000000000..76693ee09 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/index.ts @@ -0,0 +1,5 @@ +import { HallOfFame } from './HallOfFame' +import { Heroes } from './pages/Heroes' +import { ProjectLeaderboard } from './pages/ProjectLeaderboard' + +export { HallOfFame, Heroes, ProjectLeaderboard } diff --git a/src/modules/discovery/pages/hallOfFame/pages/Heroes.tsx b/src/modules/discovery/pages/hallOfFame/pages/Heroes.tsx new file mode 100644 index 000000000..53bc98a0c --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/pages/Heroes.tsx @@ -0,0 +1,132 @@ +import { Button, useDisclosure, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useAtomValue } from 'jotai' +import { PiArrowLeft, PiShare } from 'react-icons/pi' +import { Link } from 'react-router-dom' + +import { ShareView } from '@/components/molecules/ShareView' +import { useAuthContext } from '@/context' +import { TopNavContainerBar } from '@/modules/navigation/components/topNav' +import { Modal } from '@/shared/components/layouts' +import { CardLayout } from '@/shared/components/layouts' +import { AnimatedNavBar, AnimatedNavBarItem } from '@/shared/components/navigation/AnimatedNavBar' +import { dimensions, getPath } from '@/shared/constants' +import { toPx } from '@/utils' + +import { IndividualHallOfFameTitle } from '../components/IndividualHallOfFameTitle' +import { HeroType, TopHeroes } from '../components/TopHeroes' +import { heroTypeFromRoute } from '../states/heroRoute' +import { HallOfFameHeroBackgroundGradient } from '../styles' + +export const Heroes = () => { + const heroType = useAtomValue(heroTypeFromRoute) + const { isOpen, onOpen, onClose } = useDisclosure() + + const { user } = useAuthContext() + + const heroDescriptions = { + [HeroType.Contributors]: t('Those whose contributions power projects on Geyser, driving Bitcoin adoption'), + [HeroType.Creators]: t('Those bringing the most successful projects to life'), + [HeroType.Ambassadors]: t('Those spreading the word about valuable projects and enabling contributions to happen'), + } + + const pathsMap = { + [HeroType.Contributors]: getPath('hallOfFameHeroesContributor'), + [HeroType.Creators]: getPath('hallOfFameHeroesCreator'), + [HeroType.Ambassadors]: getPath('hallOfFameHeroesAmbassador'), + } + + const items = [ + { + name: t('Contributors'), + key: HeroType.Contributors, + render: () => , + path: getPath('hallOfFameHeroesContributor'), + }, + { + name: t('Creators'), + key: HeroType.Creators, + render: () => , + path: getPath('hallOfFameHeroesCreator'), + }, + { + name: t('Ambassadors'), + key: HeroType.Ambassadors, + render: () => , + path: getPath('hallOfFameHeroesAmbassador'), + }, + ] as AnimatedNavBarItem[] + + const animatedNavBarProps = { + items, + activeIndex: heroType === HeroType.Contributors ? 0 : heroType === HeroType.Creators ? 1 : 2, + } + + if (!heroType) return null + + const shareUrl = `${process.env.APP_URL}${pathsMap[heroType]}${user?.heroId ? `?hero=${user?.heroId}` : ''}` + + return ( + + + + + + + + + + + + + + + {t( + 'Share the Heroes Hall of Fame to showcase the top Contributors, Creators, and Ambassadors of the Bitcoin ecosystem!', + )} + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/pages/ProjectLeaderboard.tsx b/src/modules/discovery/pages/hallOfFame/pages/ProjectLeaderboard.tsx new file mode 100644 index 000000000..429cbd9e5 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/pages/ProjectLeaderboard.tsx @@ -0,0 +1,45 @@ +import { Button, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { PiArrowLeft, PiShare } from 'react-icons/pi' +import { Link } from 'react-router-dom' + +import { TopNavContainerBar } from '@/modules/navigation/components/topNav' +import { CardLayout } from '@/shared/components/layouts' +import { dimensions, getPath } from '@/shared/constants' +import { toPx } from '@/utils' + +import { IndividualHallOfFameTitle } from '../components/IndividualHallOfFameTitle' +import { TopProjectLeaderboard } from '../components/TopProjectLeaderboard' + +export const ProjectLeaderboard = () => { + return ( + + + + + + + + + + + ) +} diff --git a/src/modules/discovery/pages/hallOfFame/states/heroRoute.ts b/src/modules/discovery/pages/hallOfFame/states/heroRoute.ts new file mode 100644 index 000000000..d83936696 --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/states/heroRoute.ts @@ -0,0 +1,28 @@ +import { atom } from 'jotai' + +import { routeMatchForAtom } from '@/config/routes/routeGroups' +import { getPath } from '@/shared/constants' + +import { HeroType } from '../components/TopHeroes' + +export const isAmbassadorHeroRouteAtom = atom(routeMatchForAtom([getPath('hallOfFameHeroesAmbassador')])) +export const isCreatorHeroRouteAtom = atom(routeMatchForAtom([getPath('hallOfFameHeroesCreator')])) +export const isContributorHeroRouteAtom = atom(routeMatchForAtom([getPath('hallOfFameHeroesContributor')])) + +export const heroTypeFromRoute = atom((get) => { + const isAmbassadorHeroRoute = get(isAmbassadorHeroRouteAtom) + const isCreatorHeroRoute = get(isCreatorHeroRouteAtom) + const isContributorHeroRoute = get(isContributorHeroRouteAtom) + + if (isAmbassadorHeroRoute) { + return HeroType.Ambassadors + } + + if (isCreatorHeroRoute) { + return HeroType.Creators + } + + if (isContributorHeroRoute) { + return HeroType.Contributors + } +}) diff --git a/src/modules/discovery/pages/hallOfFame/styles.ts b/src/modules/discovery/pages/hallOfFame/styles.ts new file mode 100644 index 000000000..ef497c64d --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/styles.ts @@ -0,0 +1,7 @@ +import { HeroType } from './components/TopHeroes' + +export const HallOfFameHeroBackgroundGradient = { + [HeroType.Ambassadors]: 'linear-gradient(81deg, #FFF7F7 -9.6%, #FFEED3 109.2%);', + [HeroType.Creators]: 'linear-gradient(81deg, #FFF7EC -9.6%, #FFE8F6 109.2%); ', + [HeroType.Contributors]: 'linear-gradient(81deg, #F4FBF5 -9.6%, #E3F5FF 109.2%);', +} diff --git a/src/modules/discovery/pages/hallOfFame/types.ts b/src/modules/discovery/pages/hallOfFame/types.ts new file mode 100644 index 000000000..9d696861d --- /dev/null +++ b/src/modules/discovery/pages/hallOfFame/types.ts @@ -0,0 +1,4 @@ +export interface StandardOption { + value: x | any + label: string +} diff --git a/src/modules/discovery/pages/leaderboard/Leaderboard.tsx b/src/modules/discovery/pages/leaderboard/Leaderboard.tsx index f6423440a..d3c23097f 100644 --- a/src/modules/discovery/pages/leaderboard/Leaderboard.tsx +++ b/src/modules/discovery/pages/leaderboard/Leaderboard.tsx @@ -13,8 +13,8 @@ import { dimensions } from '@/shared/constants' import { LeaderboardPeriod } from '@/types' import { getBitcoinAmount, getShortAmountLabel, useMobileMode } from '@/utils' +import { useSummaryBannerStats } from '../hallOfFame/hooks' import { TopContributors, TopProjects } from './components' -import { useSummaryBannerStats } from './hooks' interface PeriodOption { value: LeaderboardPeriod diff --git a/src/modules/discovery/pages/leaderboard/components/TopContributors.tsx b/src/modules/discovery/pages/leaderboard/components/TopContributors.tsx index 93d8e46d4..ff04a78bd 100644 --- a/src/modules/discovery/pages/leaderboard/components/TopContributors.tsx +++ b/src/modules/discovery/pages/leaderboard/components/TopContributors.tsx @@ -9,7 +9,7 @@ import { BronzeMedalUrl, GoldMedalUrl, SilverMedalUrl } from '@/shared/constants import { GlobalContributorLeaderboardRow, LeaderboardPeriod } from '@/types' import { commaFormatted } from '@/utils' -import { useTopContributors } from '../hooks' +import { useTopContributors } from '../../hallOfFame/hooks' import { ScrollableList } from './ScrollableList' interface TopContributorsProps { diff --git a/src/modules/discovery/pages/leaderboard/components/TopProjects.tsx b/src/modules/discovery/pages/leaderboard/components/TopProjects.tsx index ea3aacaf5..3db53d942 100644 --- a/src/modules/discovery/pages/leaderboard/components/TopProjects.tsx +++ b/src/modules/discovery/pages/leaderboard/components/TopProjects.tsx @@ -10,7 +10,7 @@ import { BronzeMedalUrl, GoldMedalUrl, SilverMedalUrl } from '@/shared/constants import { useCurrencyFormatter } from '@/shared/utils/hooks' import { GlobalProjectLeaderboardRow, LeaderboardPeriod } from '@/types' -import { useTopProjects } from '../hooks' +import { useTopProjects } from '../../hallOfFame/hooks' import { ScrollableList } from './ScrollableList' interface TopProjectsProps { diff --git a/src/modules/discovery/pages/leaderboard/hooks/useTopContributors.ts b/src/modules/discovery/pages/leaderboard/hooks/useTopContributors.ts deleted file mode 100644 index 3a4a701dc..000000000 --- a/src/modules/discovery/pages/leaderboard/hooks/useTopContributors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useState } from 'react' - -import { GlobalContributorLeaderboardRow, LeaderboardPeriod, useLeaderboardGlobalContributorsQuery } from '@/types' - -export const useTopContributors = (period: LeaderboardPeriod, top: number) => { - const [contributors, setContributors] = useState([]) - const { loading } = useLeaderboardGlobalContributorsQuery({ - variables: { input: { period, top } }, - onCompleted(data) { - setContributors(data.leaderboardGlobalContributorsGet) - }, - }) - - return { contributors, loading } -} diff --git a/src/modules/discovery/pages/myProjects/hooks/useRewards.ts b/src/modules/discovery/pages/myProjects/hooks/useRewards.ts index 22f55b487..f511e39a8 100644 --- a/src/modules/discovery/pages/myProjects/hooks/useRewards.ts +++ b/src/modules/discovery/pages/myProjects/hooks/useRewards.ts @@ -34,7 +34,9 @@ export const useRewards = (projectId: string) => { name: reward.projectReward.name, image: reward.projectReward.image ?? null, count: reward.count, - isSoldOut: reward.projectReward.sold >= reward.projectReward.maxClaimable, + isSoldOut: reward.projectReward.maxClaimable + ? reward.projectReward.sold >= reward.projectReward.maxClaimable + : false, })), ) }, diff --git a/src/modules/navigation/discoveryNav/discoveryNavData.ts b/src/modules/navigation/discoveryNav/discoveryNavData.ts index 12a7bda45..7e9d2fc06 100644 --- a/src/modules/navigation/discoveryNav/discoveryNavData.ts +++ b/src/modules/navigation/discoveryNav/discoveryNavData.ts @@ -1,5 +1,5 @@ import { IconType } from 'react-icons' -import { PiCompass, PiRanking, PiRocketLaunch, PiTrophy, PiTShirt, PiWaveform } from 'react-icons/pi' +import { PiCompass, PiCrown, PiRocketLaunch, PiTrophy, PiTShirt, PiWaveform } from 'react-icons/pi' import { PathsMap } from '@/shared/constants' @@ -7,7 +7,7 @@ export enum DiscoveryNavItemKey { Discover = 'discover', MyProjects = 'myProjects', Activity = 'activity', - Leaderboard = 'leaderboard', + HallOfFame = 'hallOfFame', Grants = 'grants', Merch = 'merch', } @@ -43,10 +43,10 @@ export const discoveryNavItems: DiscoveryNavItem[] = [ bottomNav: true, }, { - label: 'Leaderboard', - key: DiscoveryNavItemKey.Leaderboard, - path: 'discoveryLeaderboard', - icon: PiRanking, + label: 'Hall of fame', + key: DiscoveryNavItemKey.HallOfFame, + path: 'discoveryHallOfFame', + icon: PiCrown, bottomNav: true, }, { diff --git a/src/modules/navigation/platformNavBar/PlatformNavBar.tsx b/src/modules/navigation/platformNavBar/PlatformNavBar.tsx index 3de17d442..1b971f84d 100644 --- a/src/modules/navigation/platformNavBar/PlatformNavBar.tsx +++ b/src/modules/navigation/platformNavBar/PlatformNavBar.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect } from 'react' import { Location, useLocation, useNavigate } from 'react-router-dom' import { FilterComponent } from '@/modules/discovery/filters/FilterComponent' +import { HeroCardModal } from '@/modules/profile/pages/profilePage/views/account/views/HeroCardModal' +import { heroCardAtom } from '@/modules/profile/state/heroCardAtom' import { EmailPromptModal } from '@/pages/auth/components/EmailPromptModal' import { NotificationPromptModal } from '@/pages/auth/components/NotificationPromptModal' import { useEmailPromptModal } from '@/pages/auth/hooks/useEmailPromptModal' @@ -27,6 +29,8 @@ export const PlatformNavBar = () => { const { isLoggedIn, logout, queryCurrentUser } = useAuthContext() const { loginIsOpen, loginOnClose, loginModalAdditionalProps } = useAuthModal() + const heroCard = useAtomValue(heroCardAtom) + const isMobileMode = useMobileMode() const shouldShowProjectLogo = useAtomValue(shouldShowProjectLogoAtom) @@ -133,6 +137,7 @@ export const PlatformNavBar = () => { {!dontAskNotificationAgain && ( )} + {heroCard?.isOpen && } ) } diff --git a/src/modules/navigation/platformNavBar/profileNav/ProfileNavContent.tsx b/src/modules/navigation/platformNavBar/profileNav/ProfileNavContent.tsx index 7298c2d70..8c42db6c3 100644 --- a/src/modules/navigation/platformNavBar/profileNav/ProfileNavContent.tsx +++ b/src/modules/navigation/platformNavBar/profileNav/ProfileNavContent.tsx @@ -11,11 +11,13 @@ import { } from '@chakra-ui/react' import { t } from 'i18next' import { useAtomValue } from 'jotai' +import { useMemo } from 'react' import { PiArrowUpRight } from 'react-icons/pi' import { Link } from 'react-router-dom' import { useAuthContext } from '@/context' import { followedActivityDotAtom, myProjectsActivityDotAtom } from '@/modules/discovery/state/activityDotAtom' +import { HeroCardButton } from '@/modules/profile/pages/profilePage/views/account/views/HeroCardButton' import { Body } from '@/shared/components/typography' import { dimensions, @@ -27,6 +29,7 @@ import { GeyserSubscribeUrl, GuideUrl, } from '@/shared/constants' +import { HeroStats, useUserHeroStatsQuery } from '@/types' import { DiscoveryNavItemKey, discoveryNavItems } from '../../discoveryNav/discoveryNavData' import { ProfileNavUserInfo } from './components' @@ -38,6 +41,30 @@ export const ProfileNavContent = () => { const myProjectActivityDot = useAtomValue(myProjectsActivityDotAtom) const followedActivityDot = useAtomValue(followedActivityDotAtom) + const { data, loading } = useUserHeroStatsQuery({ + variables: { + where: { + id: user.id, + }, + }, + }) + + const stats = useMemo(() => { + const defaultStats: HeroStats = { + contributionsCount: 0, + contributionsTotal: 0, + contributionsTotalUsd: 0, + projectsCount: 0, + rank: 0, + } + + return { + ambassadorStats: data?.user?.heroStats?.ambassadorStats ?? defaultStats, + contributorStats: data?.user?.heroStats?.contributorStats ?? defaultStats, + creatorStats: data?.user?.heroStats?.creatorStats ?? defaultStats, + } + }, [data]) + return ( { {user.id && ( <> - + + + )} diff --git a/src/modules/profile/graphql/fragments/userFragment.ts b/src/modules/profile/graphql/fragments/userFragment.ts index acc37241b..aadfd4e45 100644 --- a/src/modules/profile/graphql/fragments/userFragment.ts +++ b/src/modules/profile/graphql/fragments/userFragment.ts @@ -7,6 +7,7 @@ export const FRAGMENT_USER_FOR_PROFILE_PAGE = gql` fragment UserForProfilePage on User { id bio + heroId username imageUrl ranking diff --git a/src/modules/profile/graphql/queries/userQuery.ts b/src/modules/profile/graphql/queries/userQuery.ts index 57ec63342..69bd9b2ba 100644 --- a/src/modules/profile/graphql/queries/userQuery.ts +++ b/src/modules/profile/graphql/queries/userQuery.ts @@ -48,6 +48,36 @@ export const QUERY_USER_CONTRIBUTIONS = gql` } ` +export const QUERY_USER_HERO_STATS = gql` + query UserHeroStats($where: UserGetInput!) { + user(where: $where) { + heroStats { + ambassadorStats { + contributionsCount + contributionsTotalUsd + contributionsTotal + projectsCount + rank + } + contributorStats { + contributionsCount + contributionsTotalUsd + contributionsTotal + projectsCount + rank + } + creatorStats { + contributionsCount + contributionsTotalUsd + contributionsTotal + projectsCount + rank + } + } + } + } +` + export const QUERY_USER_PROFILE_ORDERS = gql` ${FRAGMENT_PROFILE_ORDER} query UserProfileOrders($where: UserGetInput!) { diff --git a/src/modules/profile/hooks/useUserProfile.ts b/src/modules/profile/hooks/useUserProfile.ts index ccc61c247..bc97f81b1 100644 --- a/src/modules/profile/hooks/useUserProfile.ts +++ b/src/modules/profile/hooks/useUserProfile.ts @@ -5,23 +5,29 @@ import { toInt, useNotification } from '@/utils' import { useAuthContext } from '../../../context' import { useUserForProfilePageQuery } from '../../../types' -import { userProfileAtom, userProfileLoadingAtom, useViewingOwnProfileAtomValue } from '../state' +import { defaultUser, userProfileAtom, userProfileLoadingAtom, useViewingOwnProfileAtomValue } from '../state' -export const useUserProfile = (userId?: string) => { +export const useUserProfile = ({ userId, heroId }: { userId?: string; heroId?: string }) => { const toast = useNotification() const isViewingOwnProfile = useViewingOwnProfileAtomValue() const [userProfile, setUserProfile] = useAtom(userProfileAtom) + const [isLoading, setIsLoading] = useAtom(userProfileLoadingAtom) const { user: currentAppUser } = useAuthContext() + const whereVariable = userId ? { id: toInt(userId) } : { heroId } + + useEffect(() => { + setIsLoading(true) + setUserProfile(defaultUser) + }, [userId, heroId, setIsLoading, setUserProfile]) + const { error } = useUserForProfilePageQuery({ variables: { - where: { - id: toInt(userId), - }, + where: whereVariable, }, - skip: !userId, + skip: !userId && !heroId, onCompleted(data) { if (data.user) { setUserProfile(data.user) diff --git a/src/modules/profile/pages/profilePage/Profile.tsx b/src/modules/profile/pages/profilePage/Profile.tsx index 7368994a6..c619e043a 100644 --- a/src/modules/profile/pages/profilePage/Profile.tsx +++ b/src/modules/profile/pages/profilePage/Profile.tsx @@ -1,8 +1,8 @@ import { HStack } from '@chakra-ui/react' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useParams } from 'react-router-dom' -import { dimensions } from '@/shared/constants' +import { dimensions, PathName } from '@/shared/constants' import { useMobileMode } from '../../../../utils' import { ProfileError } from '../../components/ProfileError' @@ -11,14 +11,33 @@ import { Account } from './views/account/Account' import { ProfileTabs } from './views/profileTabs' export const Profile = () => { + const rewriteUrlToHero = (path: string, userId: string, heroId: string) => { + window.history.replaceState( + null, + '', + path.replace(`/${PathName.userProfile}/${userId}`, `/${PathName.heroProfile}/${heroId}`), + ) + } + const isMobile = useMobileMode() - const params = useParams<{ userId: string }>() + const params = useParams<{ userId: string; heroId: string }>() + const userId = useMemo(() => { return params.userId }, [params]) - const { error } = useUserProfile(userId) + const heroId = useMemo(() => { + return params.heroId + }, [params]) + + const { error, userProfile, isLoading } = useUserProfile({ userId, heroId }) + + useEffect(() => { + if (!isLoading && userId && userProfile.heroId && userProfile.id === userId) { + rewriteUrlToHero(window.location.pathname, userId, userProfile.heroId) + } + }, [isLoading, userId, userProfile?.heroId, userProfile?.id]) if (error) { return diff --git a/src/modules/profile/pages/profilePage/views/account/Account.tsx b/src/modules/profile/pages/profilePage/views/account/Account.tsx index ff922dd75..a19f100f3 100644 --- a/src/modules/profile/pages/profilePage/views/account/Account.tsx +++ b/src/modules/profile/pages/profilePage/views/account/Account.tsx @@ -1,5 +1,11 @@ +import { Button } from '@chakra-ui/react' +import { t } from 'i18next' +import { PiGear } from 'react-icons/pi' +import { Link } from 'react-router-dom' + +import { useUserProfileAtom, useViewingOwnProfileAtomValue } from '@/modules/profile/state' import { CardLayout } from '@/shared/components/layouts' -import { dimensions } from '@/shared/constants' +import { dimensions, getPath } from '@/shared/constants' import { AccountInfo } from './views/AccountInfo' import { Badges } from './views/badges' @@ -7,6 +13,9 @@ import { Summary } from './views/Summary' import { UserBio } from './views/UserBio' export const Account = () => { + const { userProfile } = useUserProfileAtom() + + const isViewingOwnProfile = useViewingOwnProfileAtomValue() return ( { > + {isViewingOwnProfile && userProfile && ( + + )} diff --git a/src/modules/profile/pages/profilePage/views/account/views/AccountInfo.tsx b/src/modules/profile/pages/profilePage/views/account/views/AccountInfo.tsx index 0361e1a17..56533b4b4 100644 --- a/src/modules/profile/pages/profilePage/views/account/views/AccountInfo.tsx +++ b/src/modules/profile/pages/profilePage/views/account/views/AccountInfo.tsx @@ -15,9 +15,6 @@ import { } from '@chakra-ui/react' import { t } from 'i18next' import { useSetAtom } from 'jotai' -import { useTranslation } from 'react-i18next' -import { PiGear } from 'react-icons/pi' -import { Link } from 'react-router-dom' import { H1 } from '@/shared/components/typography' import { useModal } from '@/shared/hooks' @@ -29,27 +26,20 @@ import { toInt, useNotification } from '@/utils' import { ConnectAccounts, ExternalAccountType } from '../../../../../../../pages/auth' import { SkeletonLayout } from '../../../../../../../shared/components/layouts' -import { getPath } from '../../../../../../../shared/constants' -import { userProfileAtom, useUserProfileAtom, useViewingOwnProfileAtomValue } from '../../../../../state' +import { userProfileAtom, useUserProfileAtom } from '../../../../../state' import { RemoveExternalAccountModal } from '../../../components/RemoveExternalAccountModal' import { useAccountUnlink } from '../hooks/useAccountUnlink' export const AccountInfo = () => { - const { t } = useTranslation() - const { userProfile, isLoading } = useUserProfileAtom() - const isViewingOwnProfile = useViewingOwnProfileAtomValue() - const userAccountToDisplay = userProfile.externalAccounts const accountButtonProps = getExternalAccountsButtons({ accounts: userAccountToDisplay, }) - if (isLoading) { - return - } + if (isLoading) return return ( @@ -67,18 +57,6 @@ export const AccountInfo = () => { - {isViewingOwnProfile && userProfile && ( - - )} ) } diff --git a/src/modules/profile/pages/profilePage/views/account/views/HeroCard.tsx b/src/modules/profile/pages/profilePage/views/account/views/HeroCard.tsx new file mode 100644 index 000000000..644d82b33 --- /dev/null +++ b/src/modules/profile/pages/profilePage/views/account/views/HeroCard.tsx @@ -0,0 +1,228 @@ +import { Avatar, Box, forwardRef, useDisclosure, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useEffect, useState } from 'react' +import { createUseStyles } from 'react-jss' +import Tilt from 'react-parallax-tilt' + +import { getBlockHeight } from '@/api' +import { Body } from '@/shared/components/typography' +import { + HeroBackCard, + HeroCardContributedEnabledRaised, + HeroCardContributedRaisedEnabled, + HeroCardEmpty, + HeroCardEnabledContributedRaised, + HeroCardEnabledRaisedContributed, + HeroCardRaisedContributedEnabled, + HeroCardRaisedEnabledContributed, +} from '@/shared/constants' +import { flipInRight, flipOutRight, fonts, lightModeColors } from '@/shared/styles' + +import { UserForProfilePageFragment, UserHeroStats } from '../../../../../../../types' +import { commaFormatted, getShortAmountLabel, toInt } from '../../../../../../../utils' +const DEFAULT_BLOCK_HEIGHT = 800345 + +const heroBackgroundMap = { + raisedEnabledContributed: HeroCardRaisedEnabledContributed, + raisedContributedEnabled: HeroCardRaisedContributedEnabled, + enabledRaisedContributed: HeroCardEnabledRaisedContributed, + enabledContributedRaised: HeroCardEnabledContributedRaised, + empty: HeroCardEmpty, + contributedEnabledRaised: HeroCardContributedEnabledRaised, + contributedRaisedEnabled: HeroCardContributedRaisedEnabled, +} + +const useStyles = createUseStyles({ + ...flipInRight, + ...flipOutRight, +}) + +export const HeroCard = forwardRef( + ({ user, stats }: { user: UserForProfilePageFragment; stats: UserHeroStats }, ref) => { + const classes = useStyles() + + const [blockHeight, setBlockHeight] = useState(DEFAULT_BLOCK_HEIGHT) + const { isOpen, onToggle } = useDisclosure() + const { isOpen: tempOpen, onToggle: onTempToggle } = useDisclosure() + const { isOpen: postToggle, onToggle: onPostToggle } = useDisclosure() + + const handleToggle = () => { + onTempToggle() + setTimeout(() => { + onToggle() + setTimeout(() => { + onPostToggle() + }, 300) + }, 300) + } + + const amabassadorRank = stats.ambassadorStats.rank + const creatorRank = stats.creatorStats.rank + const contributorRank = stats.contributorStats.rank + + const ambassadorAmount = stats.ambassadorStats.contributionsTotal + const creatorAmount = stats.creatorStats.contributionsTotal + const contributorAmount = stats.contributorStats.contributionsTotal + + const getHeroBackground = () => { + if ( + stats.ambassadorStats.contributionsTotal > 0 || + stats.creatorStats.contributionsTotal > 0 || + stats.contributorStats.contributionsTotal > 0 + ) { + if (creatorAmount > ambassadorAmount && ambassadorAmount > contributorAmount) { + return heroBackgroundMap.raisedEnabledContributed + } + + if (creatorAmount > contributorAmount && contributorAmount > ambassadorAmount) { + return heroBackgroundMap.raisedContributedEnabled + } + + if (ambassadorAmount > creatorAmount && creatorAmount > contributorAmount) { + return heroBackgroundMap.enabledRaisedContributed + } + + if (ambassadorAmount > contributorAmount && contributorAmount > creatorAmount) { + return heroBackgroundMap.enabledContributedRaised + } + + if (contributorAmount > ambassadorAmount && ambassadorAmount > creatorAmount) { + return heroBackgroundMap.contributedEnabledRaised + } + + if (contributorAmount > creatorAmount && creatorAmount > ambassadorAmount) { + return heroBackgroundMap.contributedRaisedEnabled + } + + return heroBackgroundMap.empty + } + } + + useEffect(() => { + const getBlockHeightInfo = async () => { + try { + const blockHeight = await getBlockHeight() + + setBlockHeight(toInt(`${blockHeight}`)) + } catch (error) {} + } + + getBlockHeightInfo() + }, []) + + const animationDone = isOpen === tempOpen && postToggle === tempOpen + + return ( + + {isOpen ? ( + + ) : ( + + + {`Block: ${commaFormatted(blockHeight)}`} + + + + {/* User info */} + + + + + {user?.username || 'Anonymous Hero'} + + + + + {/* Stats */} + + + + {t('Contributor Ranking')}:{' '} + + {contributorRank || '-'} + + + {contributorAmount && ( + + Contributed {getShortAmountLabel(stats.contributorStats.contributionsTotal)} sats ($ + {getShortAmountLabel(Math.round(stats.contributorStats.contributionsTotalUsd))}) to{' '} + {stats.contributorStats.projectsCount} projects + + )} + + + + + {t('Ambassador Ranking')}:{' '} + + {amabassadorRank || '-'} + + + {ambassadorAmount && ( + + Enabled {getShortAmountLabel(stats.ambassadorStats.contributionsTotal)} sats ($ + {getShortAmountLabel(Math.round(stats.ambassadorStats.contributionsTotalUsd))}) to{' '} + {stats.ambassadorStats.projectsCount} projects + + )} + + + + + {t('Creator Ranking')}:{' '} + + {creatorRank || '-'} + + + {creatorAmount && ( + + Raised {getShortAmountLabel(stats.creatorStats.contributionsTotal)} sats ($ + {getShortAmountLabel(Math.round(stats.creatorStats.contributionsTotalUsd))}) to{' '} + {stats.creatorStats.projectsCount} projects + + )} + + + + )} + + ) + }, +) diff --git a/src/modules/profile/pages/profilePage/views/account/views/HeroCardButton.tsx b/src/modules/profile/pages/profilePage/views/account/views/HeroCardButton.tsx new file mode 100644 index 000000000..23be2143e --- /dev/null +++ b/src/modules/profile/pages/profilePage/views/account/views/HeroCardButton.tsx @@ -0,0 +1,48 @@ +import { Button, ButtonProps } from '@chakra-ui/react' +import { t } from 'i18next' +import { useSetAtom } from 'jotai' +import { PiIdentificationBadge } from 'react-icons/pi' + +import { useProfileSideNavAtom } from '@/modules/navigation/platformNavBar/profileNav/profileSideNavAtom' +import { heroCardAtom } from '@/modules/profile/state/heroCardAtom' +import { HeroButtonBorderColor, HeroButtonGradient, HeroButtonGradientBright } from '@/shared/styles/custom' +import { UserForProfilePageFragment, UserHeroStats } from '@/types' + +type HeroCardButtonProps = { + user: UserForProfilePageFragment + stats: UserHeroStats +} & ButtonProps + +export const HeroCardButton = ({ user, stats, ...rest }: HeroCardButtonProps) => { + const [_, changeProjectSideNavOpen] = useProfileSideNavAtom() + + const setHeroCard = useSetAtom(heroCardAtom) + + const handleHeroCardClick = () => { + changeProjectSideNavOpen(false) + setHeroCard({ + user, + stats, + isOpen: true, + }) + } + + return ( + <> + + + ) +} diff --git a/src/modules/profile/pages/profilePage/views/account/views/HeroCardModal.tsx b/src/modules/profile/pages/profilePage/views/account/views/HeroCardModal.tsx new file mode 100644 index 000000000..b44a87e9f --- /dev/null +++ b/src/modules/profile/pages/profilePage/views/account/views/HeroCardModal.tsx @@ -0,0 +1,104 @@ +import { Button, Link, VStack } from '@chakra-ui/react' +import { t } from 'i18next' +import { useAtom } from 'jotai' +import { useEffect, useRef, useState } from 'react' +import { PiCopy, PiDownloadSimple } from 'react-icons/pi' + +import { heroCardAtom } from '@/modules/profile/state/heroCardAtom' +import { useCreateAndCopyImage } from '@/modules/project/pages1/projectView/hooks' +import { Modal } from '@/shared/components/layouts' +import { Body } from '@/shared/components/typography' +import { useNotification } from '@/utils' + +import { HeroCard } from './HeroCard' + +export const HeroCardModal = () => { + const toast = useNotification() + + const [heroCard, setHeroCard] = useAtom(heroCardAtom) + + const [downloadUrl, setDownloadUrl] = useState('') + const [downloadLoading, setDownloadLoading] = useState(false) + + const user = heroCard?.user + const stats = heroCard?.stats + const isOpen = heroCard?.isOpen + + const ref = useRef(null) + + const { handleGenerateAndCopy, copying, getObjectUrl } = useCreateAndCopyImage() + + const handleCopy = async () => { + await handleGenerateAndCopy({ + element: ref.current, + onSuccess() { + toast.success({ + title: 'Copied!', + description: 'Ready to paste into Social media posts', + }) + }, + onError() { + toast.error({ + title: 'Failed to download image', + description: 'Please try again', + }) + }, + }) + } + + useEffect(() => { + setDownloadLoading(true) + setTimeout(async () => { + const url = await getObjectUrl({ + element: ref.current, + }) + if (url) { + setDownloadUrl(url) + } + + setDownloadLoading(false) + }, 1000) + }, []) + + if (!isOpen || !user || !stats) { + return null + } + + const onClose = () => setHeroCard({ ...heroCard, isOpen: false }) + + return ( + + + + {t('Hero cards are a summary of your activity in the Bitcoin space. You can share them with your friends!')} + + + + + + + ) +} diff --git a/src/modules/profile/pages/profilePage/views/account/views/Summary.tsx b/src/modules/profile/pages/profilePage/views/account/views/Summary.tsx index d2bbfea73..9140c675c 100644 --- a/src/modules/profile/pages/profilePage/views/account/views/Summary.tsx +++ b/src/modules/profile/pages/profilePage/views/account/views/Summary.tsx @@ -2,57 +2,70 @@ import { HStack, VStack } from '@chakra-ui/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { IconType } from 'react-icons' -import { PiLightning, PiMedal, PiRocketLaunch } from 'react-icons/pi' +import { PiLightning, PiMegaphone, PiRocketLaunch } from 'react-icons/pi' import { Body } from '@/shared/components/typography' import { SkeletonLayout } from '../../../../../../../shared/components/layouts' -import { UserProjectContributionsFragment } from '../../../../../../../types' -import { commaFormatted, getShortAmountLabel } from '../../../../../../../utils' +import { HeroStats, useUserHeroStatsQuery } from '../../../../../../../types' +import { getShortAmountLabel } from '../../../../../../../utils' import { useUserProfileAtom } from '../../../../../state' -import { useProfileContributionQuery } from '../../profileTabs/hooks/useProfileContributionQuery' +import { HeroCardButton } from './HeroCardButton' export const Summary = () => { const { userProfile, isLoading: userProfileLoading } = useUserProfileAtom() - const { contributions, isLoading } = useProfileContributionQuery(userProfile.id) + const { data, loading } = useUserHeroStatsQuery({ + variables: { + where: { + id: userProfile.id, + }, + }, + }) - const totalFunded = useMemo(() => { - return contributions.reduce((acc: number, c: UserProjectContributionsFragment) => { - return acc + (c.funder?.amountFunded ?? 0) - }, 0) - }, [contributions]) + const stats = useMemo(() => { + const defaultStats: HeroStats = { + contributionsCount: 0, + contributionsTotal: 0, + contributionsTotalUsd: 0, + projectsCount: 0, + rank: 0, + } - const projectsFunded = useMemo(() => { - return contributions.length - }, [contributions]) + return { + ambassadorStats: data?.user?.heroStats?.ambassadorStats ?? defaultStats, + contributorStats: data?.user?.heroStats?.contributorStats ?? defaultStats, + creatorStats: data?.user?.heroStats?.creatorStats ?? defaultStats, + } + }, [data]) const { t } = useTranslation() const renderSkeleton = (children: React.ReactNode) => { - if (userProfileLoading || isLoading) { - return - } - + if (userProfileLoading || loading) return return children } - const ranking = userProfile.ranking ? Number(userProfile.ranking) : undefined - return ( - {t('Stats')} + {t('Hero Rank')} + + + {renderSkeleton( - {getShortAmountLabel(totalFunded)} - - {' sats'} + + ${getShortAmountLabel(stats.contributorStats.contributionsTotalUsd).toLocaleLowerCase()} + + {' '} + ({getShortAmountLabel(stats.contributorStats.contributionsTotal)} sats) } @@ -60,22 +73,42 @@ export const Summary = () => { )} {renderSkeleton( - {getShortAmountLabel(projectsFunded)} + + ${getShortAmountLabel(stats.creatorStats.contributionsTotalUsd).toLocaleLowerCase()} + + {' '} + ({getShortAmountLabel(stats.creatorStats.contributionsTotal)} sats) + } />, )} {renderSkeleton( - {ranking ? commaFormatted(ranking) : 'N/A'} + + ${getShortAmountLabel(stats.ambassadorStats.contributionsTotalUsd).toLocaleLowerCase()} + + {' '} + ({getShortAmountLabel(stats.ambassadorStats.contributionsTotal)} sats) + } />, @@ -88,17 +121,39 @@ type StatBodyProps = { title: string Icon: IconType value: React.ReactNode + subtitle: string + rank: number + rankPillProps?: { + bgColor?: string + textColor?: string + } } -const StatBody = ({ title, Icon, value }: StatBodyProps) => { +const StatBody = ({ title, Icon, value, subtitle, rank, rankPillProps }: StatBodyProps) => { return ( - - + + - - {title} - + + + {title} + + {`#${rank || 'N/A'}`} + {value} + {subtitle && ( + + {subtitle} + + )} ) diff --git a/src/modules/profile/pages/profilePage/views/account/views/UserBio.tsx b/src/modules/profile/pages/profilePage/views/account/views/UserBio.tsx index 774dd3361..9f9091d98 100644 --- a/src/modules/profile/pages/profilePage/views/account/views/UserBio.tsx +++ b/src/modules/profile/pages/profilePage/views/account/views/UserBio.tsx @@ -1,5 +1,4 @@ import { SkeletonText, VStack } from '@chakra-ui/react' -import { t } from 'i18next' import { useUserProfileAtom } from '@/modules/profile/state' import { Body } from '@/shared/components/typography' @@ -15,9 +14,6 @@ export const UserBio = () => { return ( - - {t('Bio')} - {userProfile.bio} ) @@ -26,9 +22,6 @@ export const UserBio = () => { export const UserBioSkeleton = () => { return ( - - {t('Bio')} - diff --git a/src/modules/profile/pages/profilePage/views/account/views/badges/BadgesBody.tsx b/src/modules/profile/pages/profilePage/views/account/views/badges/BadgesBody.tsx index ba6c163ec..e2492ce37 100644 --- a/src/modules/profile/pages/profilePage/views/account/views/badges/BadgesBody.tsx +++ b/src/modules/profile/pages/profilePage/views/account/views/badges/BadgesBody.tsx @@ -49,9 +49,9 @@ export const BadgesBody = () => { return ( <> - {isEdit && showTopSection && ( + {isEdit && showTopSection && hasBadgeNoNostrForOwn && ( - {hasBadgeNoNostrForOwn && {t('Login with Nostr to claim the badges you earned!')}} + {t('Login with Nostr to claim the badges you earned!')} )} {!hasBadge && ( diff --git a/src/modules/profile/pages/profileSettings/ProfileSettings.tsx b/src/modules/profile/pages/profileSettings/ProfileSettings.tsx index 68f1f1406..5e77ea2d5 100644 --- a/src/modules/profile/pages/profileSettings/ProfileSettings.tsx +++ b/src/modules/profile/pages/profileSettings/ProfileSettings.tsx @@ -19,7 +19,7 @@ export const ProfileSettings = () => { return params.userId }, [params]) - const { error } = useUserProfile(userId) + const { error } = useUserProfile({ userId }) const isMobile = useMobileMode() diff --git a/src/modules/profile/pages/profileSettings/hooks/useUserNotificationSettings.tsx b/src/modules/profile/pages/profileSettings/hooks/useUserNotificationSettings.tsx index 7e3cca53c..7575d497d 100644 --- a/src/modules/profile/pages/profileSettings/hooks/useUserNotificationSettings.tsx +++ b/src/modules/profile/pages/profileSettings/hooks/useUserNotificationSettings.tsx @@ -118,7 +118,6 @@ export const useUserNotificationSettings = (userId: string) => { return prevSettings }) - console.log('error', error) // Show error toast toast.error({ title: 'Update failed', diff --git a/src/modules/profile/pages/profileSettings/views/ProfileSettingsGeneral.tsx b/src/modules/profile/pages/profileSettings/views/ProfileSettingsGeneral.tsx index 8d4ad3d7b..0a035bce5 100644 --- a/src/modules/profile/pages/profileSettings/views/ProfileSettingsGeneral.tsx +++ b/src/modules/profile/pages/profileSettings/views/ProfileSettingsGeneral.tsx @@ -14,7 +14,7 @@ export const ProfileSettingsGeneral = () => { return params.userId }, [params]) - const { isLoading } = useUserProfile(userId) + const { isLoading } = useUserProfile({ userId }) return ( diff --git a/src/modules/profile/state/heroCardAtom.ts b/src/modules/profile/state/heroCardAtom.ts new file mode 100644 index 000000000..40af9859a --- /dev/null +++ b/src/modules/profile/state/heroCardAtom.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai' + +import { UserForProfilePageFragment, UserHeroStats } from '@/types' + +type HeroCardAtomType = { + isOpen: boolean + user: UserForProfilePageFragment + stats: UserHeroStats +} + +export const heroCardAtom = atom(null) diff --git a/src/modules/profile/state/profileAtom.ts b/src/modules/profile/state/profileAtom.ts index 876d28697..eb491cf00 100644 --- a/src/modules/profile/state/profileAtom.ts +++ b/src/modules/profile/state/profileAtom.ts @@ -6,6 +6,7 @@ import { UserForProfilePageFragment } from '../../../types' export const defaultUser: UserForProfilePageFragment = { id: 0, bio: '', + heroId: '', username: '', imageUrl: '', ranking: 0, diff --git a/src/modules/project/funding/hooks/useProjectHeroWithProjectName.ts b/src/modules/project/funding/hooks/useProjectHeroWithProjectName.ts new file mode 100644 index 000000000..2eae44be0 --- /dev/null +++ b/src/modules/project/funding/hooks/useProjectHeroWithProjectName.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from 'jotai' + +import { projectHeroAtom, ProjectHeroAtomType } from '../../pages1/projectView/state/heroAtom' + +export const useProjectHeroWithProjectName = (projectName?: string) => { + const projectHeros = useAtomValue(projectHeroAtom) + + return getHeroIdFromProjectHeroes(projectHeros, projectName) +} + +export const getHeroIdFromProjectHeroes = (heroes: ProjectHeroAtomType[], projectName?: string) => { + if (!projectName) return undefined + + const currentProjectHero = heroes.find((r) => r.projectName === projectName) + + if (!currentProjectHero) return undefined + + return currentProjectHero.heroId +} diff --git a/src/modules/project/funding/state/fundingFormAtom.ts b/src/modules/project/funding/state/fundingFormAtom.ts index 2579be6f1..2d6449706 100644 --- a/src/modules/project/funding/state/fundingFormAtom.ts +++ b/src/modules/project/funding/state/fundingFormAtom.ts @@ -18,8 +18,10 @@ import { import { centsToDollars, commaFormatted, isProjectAnException, toInt, validateEmail } from '@/utils' import { projectAffiliateAtom } from '../../pages1/projectView/state/affiliateAtom' +import { projectHeroAtom } from '../../pages1/projectView/state/heroAtom' import { ProjectState } from '../../state/projectAtom' import { getRefIdFromProjectAffiliates } from '../hooks/useProjectAffiliateWithProjectName' +import { getHeroIdFromProjectHeroes } from '../hooks/useProjectHeroWithProjectName' import { fundingTxAtom, selectedGoalIdAtom } from './fundingTxAtom' export type FundingProject = Pick @@ -353,6 +355,8 @@ export const formattedFundingInputAtom = atom((get) => { const projectGoalId = get(selectedGoalIdAtom) const affiliates = get(projectAffiliateAtom) const affiliateId = getRefIdFromProjectAffiliates(affiliates, fundingProject?.name) + const projectHeroes = get(projectHeroAtom) + const heroId = getHeroIdFromProjectHeroes(projectHeroes, fundingProject?.name) const { donationAmount, @@ -406,6 +410,7 @@ export const formattedFundingInputAtom = atom((get) => { }, projectGoalId, affiliateId, + ambassadorHeroId: heroId, } return input diff --git a/src/modules/project/graphql/fragments/funderFragment.ts b/src/modules/project/graphql/fragments/funderFragment.ts index 661fa893d..27ba505e1 100644 --- a/src/modules/project/graphql/fragments/funderFragment.ts +++ b/src/modules/project/graphql/fragments/funderFragment.ts @@ -29,6 +29,18 @@ export const FRAGMENT_PROJECT_LEADERBOARD_CONTRIBUTORS = gql` } ` +export const FRAGMENT_PROJECT_LEADERBOARD_AMBASSADORS = gql` + ${FRAGMENT_USER_AVATAR} + fragment ProjectLeaderboardAmbassadors on ProjectLeaderboardAmbassadorsRow { + contributionsTotal + contributionsTotalUsd + projectsCount + user { + ...UserAvatar + } + } +` + export const FRAGMENT_PROJECT_USER_CONTRIBUTOR = gql` ${FRAGMENT_USER_AVATAR} fragment UserContributor on Funder { diff --git a/src/modules/project/graphql/queries/ambassadorQuery.ts b/src/modules/project/graphql/queries/ambassadorQuery.ts new file mode 100644 index 000000000..d7703f6b8 --- /dev/null +++ b/src/modules/project/graphql/queries/ambassadorQuery.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client' + +export const QUERY_PROJECT_AMBASSADOR_STATS = gql` + query ProjectAmbassadorStats($where: UniqueProjectQueryInput!) { + projectGet(where: $where) { + ambassadors { + stats { + contributionsCount + contributionsSum + count + } + } + } + } +` diff --git a/src/modules/project/graphql/queries/funderQuery.ts b/src/modules/project/graphql/queries/funderQuery.ts index 49a29b52f..7fe1e0eee 100644 --- a/src/modules/project/graphql/queries/funderQuery.ts +++ b/src/modules/project/graphql/queries/funderQuery.ts @@ -3,6 +3,7 @@ import { gql } from '@apollo/client' import { FRAGMENT_PROJECT_CONTRIBUTOR_CONTRIBUTION_SUMMARY, FRAGMENT_PROJECT_FUNDER, + FRAGMENT_PROJECT_LEADERBOARD_AMBASSADORS, FRAGMENT_PROJECT_LEADERBOARD_CONTRIBUTORS, FRAGMENT_PROJECT_USER_CONTRIBUTOR, } from '../fragments/funderFragment' @@ -25,6 +26,15 @@ export const QUERY_PROJECT_PAGE_LEADERBOARD = gql` } ` +export const QUERY_PROJECT_PAGE_AMBASSADOR = gql` + ${FRAGMENT_PROJECT_LEADERBOARD_AMBASSADORS} + query ProjectLeaderboardAmbassadorsGet($input: ProjectLeaderboardAmbassadorsGetInput!) { + projectLeaderboardAmbassadorsGet(input: $input) { + ...ProjectLeaderboardAmbassadors + } + } +` + export const QUERY_PROJECT_USER_CONTRIBUTOR = gql` ${FRAGMENT_PROJECT_USER_CONTRIBUTOR} ${FRAGMENT_PROJECT_CONTRIBUTOR_CONTRIBUTION_SUMMARY} diff --git a/src/modules/project/pages1/projectFunding/components/ProjectFundingSummary.tsx b/src/modules/project/pages1/projectFunding/components/ProjectFundingSummary.tsx index 56b7fe69f..6a56e890f 100644 --- a/src/modules/project/pages1/projectFunding/components/ProjectFundingSummary.tsx +++ b/src/modules/project/pages1/projectFunding/components/ProjectFundingSummary.tsx @@ -1,5 +1,5 @@ /* eslint-disable complexity */ -import { Button, Divider, HStack, useDisclosure, VStack } from '@chakra-ui/react' +import { Button, HStack, useDisclosure, VStack } from '@chakra-ui/react' import { motion } from 'framer-motion' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' @@ -13,7 +13,6 @@ import { Body, H2 } from '@/shared/components/typography' import { useFundCalc } from '../../../../../helpers' import { toInt, useMobileMode } from '../../../../../utils' -import { Badge } from './Badge' export const ProjectFundingSummary = ({ disableCollapse }: { disableCollapse?: boolean }) => { const { t } = useTranslation() @@ -83,7 +82,7 @@ export const ProjectFundingSummary = ({ disableCollapse }: { disableCollapse?: b transition={{ type: 'spring', stiffness: 900, damping: 40 }} > -

+

{t('Summary')}

+ + + {t('By continuing to checkout you are accepting our T&Cs')} + + + ) diff --git a/src/modules/project/pages1/projectFunding/views/fundingDetails/sections/FundingDetailsUserEmail.tsx b/src/modules/project/pages1/projectFunding/views/fundingDetails/sections/FundingDetailsUserEmail.tsx index 24b1843b5..65f6c1af6 100644 --- a/src/modules/project/pages1/projectFunding/views/fundingDetails/sections/FundingDetailsUserEmail.tsx +++ b/src/modules/project/pages1/projectFunding/views/fundingDetails/sections/FundingDetailsUserEmail.tsx @@ -165,7 +165,7 @@ export const FundingDetailsUserEmailAndUpdates = () => { )} {!followsProject && ( { - + + + {t('By continuing to checkout you are accepting our T&Cs')} + + + ) diff --git a/src/modules/project/pages1/projectFunding/views/fundingSuccess/FundingSuccess.tsx b/src/modules/project/pages1/projectFunding/views/fundingSuccess/FundingSuccess.tsx index 6329cc9fb..c8e5ade00 100644 --- a/src/modules/project/pages1/projectFunding/views/fundingSuccess/FundingSuccess.tsx +++ b/src/modules/project/pages1/projectFunding/views/fundingSuccess/FundingSuccess.tsx @@ -6,9 +6,11 @@ import { Link, useNavigate } from 'react-router-dom' import { useFundingFormAtom } from '@/modules/project/funding/hooks/useFundingFormAtom' import { useFundingTxAtom } from '@/modules/project/funding/state' +import { useRewardsAtom } from '@/modules/project/hooks/useProjectAtom' import { CardLayout } from '@/shared/components/layouts' -import { H1 } from '@/shared/components/typography' +import { H2 } from '@/shared/components/typography' import { getPath } from '@/shared/constants' +import { lightModeColors } from '@/shared/styles' import { FundingStatus } from '@/types' import { ProjectFundingSummary } from '../../components/ProjectFundingSummary' @@ -22,6 +24,7 @@ import { SendEmailToCreator } from './components/SendEmailToCreator' export const FundingSuccess = () => { const { project } = useFundingFormAtom() const { fundingTx } = useFundingTxAtom() + const { rewards } = useRewardsAtom() const navigate = useNavigate() @@ -41,17 +44,36 @@ export const FundingSuccess = () => { } > - + -

- {t('Success')}! -

- + {rewards.length > 0 && ( + +

+ {t('Next Actions')} +

+ + +
+ )} - diff --git a/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/ConfirmationMessage.tsx b/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/ConfirmationMessage.tsx index af4366e78..07edf262a 100644 --- a/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/ConfirmationMessage.tsx +++ b/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/ConfirmationMessage.tsx @@ -42,9 +42,9 @@ const ConfirmationMessage = ({ return ( { return ( - - {t('Send email to the creator')} - - + { )} - diff --git a/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/SuccessImageComponent.tsx b/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/SuccessImageComponent.tsx index ad065cba4..9812377fa 100644 --- a/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/SuccessImageComponent.tsx +++ b/src/modules/project/pages1/projectFunding/views/fundingSuccess/components/SuccessImageComponent.tsx @@ -1,36 +1,48 @@ -import { Avatar, Button, HStack, Image, Link, Tooltip, VStack } from '@chakra-ui/react' -import * as htmlToImage from 'html-to-image' -import { useCallback, useState } from 'react' +import { Avatar, Button, HStack, IconButton, Link, Tooltip, useClipboard, VStack } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { PiCopy, PiShareFat } from 'react-icons/pi' +import { AnonymousAvatar } from '@/components/ui/AnonymousAvatar' +import { useAuthContext } from '@/context' +import { FlowingGifBackground } from '@/modules/discovery/pages/hallOfFame/components/FlowingGifBackground' import { useFundingFlowAtom } from '@/modules/project/funding/hooks/useFundingFlowAtom' import { useProjectAtom } from '@/modules/project/hooks/useProjectAtom' import { CampaignContent, useProjectShare } from '@/modules/project/pages1/projectView/hooks' import { generateTwitterShareUrl } from '@/modules/project/utils' +import { useAuthModal } from '@/pages/auth/hooks' import { Body, H3 } from '@/shared/components/typography' import { lightModeColors } from '@/shared/styles' -import { Badge } from '@/types' +import { SuccessImageBackgroundGradient } from '@/shared/styles/custom' +import { useProjectAmbassadorStatsQuery } from '@/types' import { useNotification } from '@/utils' -import ContributionIcon from './ContributionIcon.svg' - -export const SuccessImageComponent = ({ currentBadge }: { currentBadge?: Badge }) => { +export const SuccessImageComponent = () => { const { t } = useTranslation() - const toast = useNotification() - const [copied, setCopied] = useState(false) - - const [successComponent, setSuccessComponent] = useState(null) - const { project } = useProjectAtom() + const { loginOnOpen } = useAuthModal() + const { user: loggedInUser, isLoggedIn } = useAuthContext() const { fundingInputAfterRequest } = useFundingFlowAtom() - const user = fundingInputAfterRequest?.user + const user = loggedInUser || fundingInputAfterRequest?.user + const heroId = user?.heroId - const ref = useCallback((node: HTMLDivElement | null) => { - setSuccessComponent(node) - }, []) + const heroLink = `https://geyser.fund/project/${project.name}${heroId ? `&hero=${heroId}` : ''}` + + const { data } = useProjectAmbassadorStatsQuery({ variables: { where: { id: project.id } } }) + const ambassadorsCount = data?.projectGet?.ambassadors?.stats?.count + const totalSats = data?.projectGet?.ambassadors?.stats?.contributionsSum + + const { onCopy } = useClipboard(heroLink) + const toast = useNotification() + + const handleCopy = () => { + onCopy() + toast.success({ + title: t('Copied!'), + description: t('Hero link copied to clipboard'), + }) + } const { getShareProjectUrl } = useProjectShare() @@ -40,118 +52,197 @@ export const SuccessImageComponent = ({ currentBadge }: { currentBadge?: Badge } return null } - const handleCopy = async () => { - try { - const dataUrl = await getDataUrl() - const base64Response = await fetch(dataUrl) - const blob = await base64Response.blob() - const items = { [blob.type]: blob } - const clipboardItem = new ClipboardItem(items) - await navigator.clipboard.write([clipboardItem]) - setCopied(true) - setTimeout(() => { - setCopied(false) - toast.success({ - title: 'Copied!', - description: 'Ready to paste into Social media posts', - }) - }, 1000) - } catch { - toast.error({ - title: 'Failed to download image', - description: 'Please try again', - }) + const renderSharingStats = () => { + if (ambassadorsCount) { + return ( + <> + {t('So far, ')} + + {ambassadorsCount} + {' '} + + {t('ambassador' + (ambassadorsCount === 1 ? ' has' : 's have') + ' enabled')} + {' '} + + {totalSats.toLocaleString()} + {' '} + {t('sats in contributions to this project.')} + + ) } + + return '' } - const getDataUrl = async () => { - const element = successComponent - if (element) { - const dataUrl = await htmlToImage.toPng(element, { - style: { backgroundColor: 'primary.400', borderStyle: 'double' }, - }) - return dataUrl + const renderSignInPromptBody = () => { + if (!isLoggedIn) { + return ( + + { + e.preventDefault() + loginOnOpen() + }} + > + {t('Sign in')} + {' '} + {t('to get your custom')}{' '} + + + + {t('Hero link')} + + + {' '} + {t('and track the impact of sharing.')} + + ) } - return '' + return null } const twitterShareText = `I just contributed to ${project.title} on Geyser! Check it out: ${projectShareUrl}` return ( - - - - {user && user.id && ( - - - - {user.username} - - - )} -

+ + {user && ( + + {user.imageUrl ? ( + + ) : ( + + )} + + {user.username} + + + )} + +

{t('Successfully contributed to')}

-

+

{project.title}

- {currentBadge && ( - - - - {t('You won a Nostr badge!')} - - - )}
- - - + + + {t('Become an')}{' '} + + + + {t('Ambassador')} + + + {' '} + {t('for this project by spreading the word using your')}{' '} + + + + {t('Hero link')} + + + + {'. '} + {renderSharingStats()} + + {renderSignInPromptBody()} + + + {heroId ? t('Hero Link:') : ''} {heroLink.replace('https://', '')} + + } + variant="ghost" + size="md" + onClick={handleCopy} + /> + + + + - - - + + ) } diff --git a/src/modules/project/pages1/projectView/ProjectView.tsx b/src/modules/project/pages1/projectView/ProjectView.tsx index 59d023561..5845c3183 100644 --- a/src/modules/project/pages1/projectView/ProjectView.tsx +++ b/src/modules/project/pages1/projectView/ProjectView.tsx @@ -8,6 +8,7 @@ import { FundingProviderWithProjectContext } from '../../context/FundingProvider import { ProjectProvider } from '../../context/ProjectProvider' import { ProjectContainer } from './ProjectContainer' import { addProjectAffiliateAtom } from './state/affiliateAtom' +import { addProjectHeroAtom } from './state/heroAtom' const ProjectIdsRedirects = [ { @@ -25,8 +26,10 @@ export const ProjectView = () => { const navigate = useNavigate() const addRefferal = useSetAtom(addProjectAffiliateAtom) + const addHeroId = useSetAtom(addProjectHeroAtom) const affiliateId = searchParams.get('refId') + const heroId = searchParams.get('hero') useEffect(() => { const redirect = ProjectIdsRedirects.find((item) => item.from === projectName) @@ -43,6 +46,16 @@ export const ProjectView = () => { }) } }, [projectName, affiliateId, addRefferal]) + + useEffect(() => { + if (heroId && projectName) { + addHeroId({ + projectName, + heroId, + }) + } + }, [projectName, heroId, addHeroId]) + return ( diff --git a/src/modules/project/pages1/projectView/hooks/useCreateAndCopyImage.ts b/src/modules/project/pages1/projectView/hooks/useCreateAndCopyImage.ts index 3ac558d2c..af022d2b8 100644 --- a/src/modules/project/pages1/projectView/hooks/useCreateAndCopyImage.ts +++ b/src/modules/project/pages1/projectView/hooks/useCreateAndCopyImage.ts @@ -18,6 +18,14 @@ export const useCreateAndCopyImage = () => { throw new Error('Element is not defined') }, []) + const getBlob = async (element: HTMLElement | null) => { + const dataUrl = await getDataUrl(element) + + const base64Response = await fetch(dataUrl) + + return base64Response.blob() + } + /** Async function, Always invoke asynchronously for safari's sake */ const handleGenerateAndCopy = useCallback( async ({ @@ -31,16 +39,8 @@ export const useCreateAndCopyImage = () => { }) => { setCopying(true) try { - const getBlob = async () => { - const dataUrl = await getDataUrl(element) - - const base64Response = await fetch(dataUrl) - - return base64Response.blob() - } - const clipboardItem = new ClipboardItem({ - 'image/png': getBlob().then((result) => { + 'image/png': getBlob(element).then((result) => { if (!result) { return new Promise(async (resolve) => { resolve('') @@ -64,5 +64,14 @@ export const useCreateAndCopyImage = () => { [getDataUrl], ) - return { handleGenerateAndCopy, copying } + const getObjectUrl = async ({ element, onError = () => {} }: { element: HTMLElement | null; onError?: Function }) => { + try { + const blob = await getBlob(element) + return URL.createObjectURL(blob) + } catch (error) { + onError() + } + } + + return { handleGenerateAndCopy, copying, getObjectUrl } } diff --git a/src/modules/project/pages1/projectView/hooks/useProjectShare.ts b/src/modules/project/pages1/projectView/hooks/useProjectShare.ts index 727177af2..102b553b7 100644 --- a/src/modules/project/pages1/projectView/hooks/useProjectShare.ts +++ b/src/modules/project/pages1/projectView/hooks/useProjectShare.ts @@ -39,10 +39,18 @@ type getCampainParametersProps = { keyword: string /** The page the user clicked from */ clickedFrom: CampaignContent + /** The heroId of the user */ + heroId?: string | null } /** This function is for use outside of ProjectProvider Context */ -export const getProjectShareUrlSuffix = ({ creator, isLoggedIn, keyword, clickedFrom }: getCampainParametersProps) => { +export const getProjectShareUrlSuffix = ({ + creator, + isLoggedIn, + keyword, + clickedFrom, + heroId, +}: getCampainParametersProps) => { const source = creator ? CampaignSource.creator : isLoggedIn ? CampaignSource.user : CampaignSource.visitor const campaignParameters = [ @@ -52,13 +60,16 @@ export const getProjectShareUrlSuffix = ({ creator, isLoggedIn, keyword, clicked { key: 'mtm_medium', value: 'geyser' }, { key: 'mtm_content', value: clickedFrom }, ] + + if (heroId) campaignParameters.unshift({ key: 'hero', value: heroId }) + return '?' + campaignParameters.map(({ key, value }) => `${key}=${value}`).join('&') } /** This hook must be used inside ProjectProvider Context to share project links */ export const useProjectShare = () => { const { project, isProjectOwner } = useProjectAtom() - const { isLoggedIn } = useAuthContext() + const { isLoggedIn, user } = useAuthContext() const [copied, setCopied] = useState(false) const getShareProjectUrl = ({ clickedFrom }: { clickedFrom: CampaignContent }) => { @@ -67,6 +78,7 @@ export const useProjectShare = () => { isLoggedIn, keyword: project?.name || '', clickedFrom, + heroId: user?.heroId, }) return `${window.location.origin}/project/${project?.name}${campaignUrlSuffix}` } diff --git a/src/modules/project/pages1/projectView/state/heroAtom.ts b/src/modules/project/pages1/projectView/state/heroAtom.ts new file mode 100644 index 000000000..1eaf49d28 --- /dev/null +++ b/src/modules/project/pages1/projectView/state/heroAtom.ts @@ -0,0 +1,38 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { DateTime } from 'luxon' + +export type ProjectHeroAtomType = { + dateTime: number + projectName: string + heroId: string +} + +/** Hero Ids for the project */ +export const projectHeroAtom = atomWithStorage('heroId', []) + +/** Add hero to the project */ +export const addProjectHeroAtom = atom(null, (get, set, hero: Omit) => { + const allProjectHeroes = get(projectHeroAtom) + + const isExist = allProjectHeroes.some((r) => r.projectName === hero.projectName) + const dateTime = DateTime.now().toMillis() + + const currentProjectAffiliate = { ...hero, dateTime } + + let newProjectHero = [] as ProjectHeroAtomType[] + + if (isExist) { + newProjectHero = allProjectHeroes.map((r) => { + if (r.projectName === hero.projectName) { + return currentProjectAffiliate + } + + return r + }) + } else { + newProjectHero = [...allProjectHeroes, currentProjectAffiliate] + } + + set(projectHeroAtom, newProjectHero) +}) diff --git a/src/modules/project/pages1/projectView/views/body/sections/header/components/ShareProjectButton.tsx b/src/modules/project/pages1/projectView/views/body/sections/header/components/ShareProjectButton.tsx index 665d17c65..f69ab33fd 100644 --- a/src/modules/project/pages1/projectView/views/body/sections/header/components/ShareProjectButton.tsx +++ b/src/modules/project/pages1/projectView/views/body/sections/header/components/ShareProjectButton.tsx @@ -18,13 +18,7 @@ export const ShareProjectButton = () => { - + ) } diff --git a/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/ProjectShareModal.tsx b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/ProjectShareModal.tsx index ad9472833..d89bce8e5 100644 --- a/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/ProjectShareModal.tsx +++ b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/ProjectShareModal.tsx @@ -4,27 +4,25 @@ import { useTranslation } from 'react-i18next' import { Modal } from '@/shared/components/layouts' import { AnimatedNavBar, AnimatedNavBarItem } from '@/shared/components/navigation/AnimatedNavBar' import { useAnimatedNavBar } from '@/shared/components/navigation/useAnimatedNavBar' -import { Body } from '@/shared/components/typography' -import { ShareBlock } from '../../../components' +import { ProjectBannerView } from './views/ProjectBannerView' import { ProjectShareContribute } from './views/ProjectShareContribute' import { ProjectShareView } from './views/ProjectShareView' -interface IQRModal { +interface IProjectShareModal { isOpen: boolean onClose: () => void - name: string projectId: string title: string } enum ProjectShareModalView { share = 'share', - contribute = 'contribute', - // embed = 'embed', + banner = 'banner', + lightning = 'lightning', } -export const ProjectShareModal = ({ isOpen, onClose, name }: IQRModal) => { +export const ProjectShareModal = ({ isOpen, onClose }: IProjectShareModal) => { const { t } = useTranslation() const items = [ @@ -34,34 +32,37 @@ export const ProjectShareModal = ({ isOpen, onClose, name }: IQRModal) => { render: () => , }, { - name: t('Contribute'), - key: ProjectShareModalView.contribute, + name: t('Banner'), + key: ProjectShareModalView.banner, + render: () => , + }, + { + name: t('Lightning'), + key: ProjectShareModalView.lightning, render: () => , }, ] as AnimatedNavBarItem[] - const { render, ...animatedNavBarProps } = useAnimatedNavBar({ items, defaultView: ProjectShareModalView.share }) + const { render, ...animatedNavBarProps } = useAnimatedNavBar({ + items, + defaultView: ProjectShareModalView.share, + }) return ( - <> - - - {t('Share the project page to spread the word across the internet and social media.')} - - - {render && render()} - - - + + + {render && render()} + ) } diff --git a/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectBannerView.tsx b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectBannerView.tsx new file mode 100644 index 000000000..970542621 --- /dev/null +++ b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectBannerView.tsx @@ -0,0 +1,97 @@ +import { Button, HStack, Spinner, VStack } from '@chakra-ui/react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PiCopy } from 'react-icons/pi' + +import { LogoDark } from '@/assets' +import { useProjectAtom } from '@/modules/project/hooks/useProjectAtom' +import { CampaignContent, useCreateAndCopyImage, useProjectShare } from '@/modules/project/pages1/projectView/hooks' +import { GeyserShareImageUrl } from '@/shared/constants' +import { validateImageUrl } from '@/shared/markdown/validations/image' +import { useNotification } from '@/utils' + +import { ProjectShareBanner } from '../components/ProjectShareBanner' + +export const ProjectBannerView = () => { + const { t } = useTranslation() + + const { project } = useProjectAtom() + + const toast = useNotification() + + const ref = useRef(null) + + const [generating, setGenerating] = useState(true) + + useEffect(() => { + setTimeout(() => { + setGenerating(false) + }, 5000) + }, []) + + const { handleGenerateAndCopy, copying } = useCreateAndCopyImage() + + const handleCopy = async () => { + await handleGenerateAndCopy({ + element: ref.current, + onSuccess() { + toast.success({ + title: 'Copied!', + description: 'Ready to paste into Social media posts', + }) + }, + onError() { + toast.error({ + title: 'Failed to download image', + description: 'Please try again', + }) + }, + }) + } + + const { getShareProjectUrl } = useProjectShare() + + const projectUrl = getShareProjectUrl({ clickedFrom: CampaignContent.projectShareQrBanner }) + + const isImage = validateImageUrl(project.images[0]) + + return ( + + + + + {generating ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectShareView.tsx b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectShareView.tsx index b8fc9a7ae..0a61cce5f 100644 --- a/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectShareView.tsx +++ b/src/modules/project/pages1/projectView/views/body/sections/header/shareModal/views/ProjectShareView.tsx @@ -1,97 +1,165 @@ -import { Button, HStack, Spinner, VStack } from '@chakra-ui/react' -import { useEffect, useRef, useState } from 'react' +import { Button, Tooltip } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' -import { PiCopy } from 'react-icons/pi' -import { LogoDark } from '@/assets' +import { ShareView } from '@/components/molecules/ShareView' +import { useAuthContext } from '@/context' import { useProjectAtom } from '@/modules/project/hooks/useProjectAtom' -import { CampaignContent, useCreateAndCopyImage, useProjectShare } from '@/modules/project/pages1/projectView/hooks' -import { GeyserShareImageUrl } from '@/shared/constants' -import { validateImageUrl } from '@/shared/markdown/validations/image' -import { useNotification } from '@/utils' - -import { ProjectShareBanner } from '../components/ProjectShareBanner' +import { generateTwitterShareUrl } from '@/modules/project/utils' +import { useAuthModal } from '@/pages/auth/hooks' +import { Body } from '@/shared/components/typography' +import { lightModeColors } from '@/shared/styles' +import { useProjectAmbassadorStatsQuery } from '@/types' +import { commaFormatted } from '@/utils' export const ProjectShareView = () => { const { t } = useTranslation() - + const { user, isLoggedIn } = useAuthContext() + const { loginOnOpen } = useAuthModal() const { project } = useProjectAtom() - const toast = useNotification() - - const ref = useRef(null) + const { data } = useProjectAmbassadorStatsQuery({ variables: { where: { id: project.id } } }) - const [generating, setGenerating] = useState(true) + const heroId = user?.heroId + const heroLink = `${window.location.origin || 'https://geyser.fund'}/project/${project.name}${ + heroId ? `?hero=${heroId}` : '' + }` + const twitterShareText = `Help make this project happen! Check it out: ${heroLink}` - useEffect(() => { - setTimeout(() => { - setGenerating(false) - }, 5000) - }, []) + const ambassadorsCount = data?.projectGet?.ambassadors?.stats?.count + const satAmount = data?.projectGet?.ambassadors?.stats?.contributionsSum - const { handleGenerateAndCopy, copying } = useCreateAndCopyImage() + const renderSharingStats = () => { + if (ambassadorsCount) { + return ( + <> + {t('So far, ')} + + {ambassadorsCount} + {' '} + + {t('ambassador' + (ambassadorsCount === 1 ? ' has' : 's have') + ' enabled')} + {' '} + + {commaFormatted(satAmount)} + {' '} + {t('sats in contributions to this project.')} + + ) + } - const handleCopy = async () => { - await handleGenerateAndCopy({ - element: ref.current, - onSuccess() { - toast.success({ - title: 'Copied!', - description: 'Ready to paste into Social media posts', - }) - }, - onError() { - toast.error({ - title: 'Failed to download image', - description: 'Please try again', - }) - }, - }) + return '' } - const { getShareProjectUrl } = useProjectShare() - - const projectUrl = getShareProjectUrl({ clickedFrom: CampaignContent.projectShareQrBanner }) - - const isImage = validateImageUrl(project.images[0]) + const renderAmbassadorCopy = () => { + return ( + + {!ambassadorsCount ? ( + + {t('Become the first project')}{' '} + + + + {t('Ambassador')} + + + {' '} + {t('by spreading the word and enabling more contributions to this project.')} + + ) : ( + + {t('Become an')}{' '} + + + + {t('Ambassador')} + + + {' '} + {t('for this project by spreading the word using your')}{' '} + + + + {t('Hero link')} + + + + {'. '} + {renderSharingStats()} + + )} + {!isLoggedIn && ( + + {' '} + {t('to get your custom')}{' '} + + + + {t('Hero link')} + + + {' '} + {t('and track the impact of sharing.')} + + )} + + ) + } return ( - - - - - {generating ? ( - - ) : ( - - )} - - + {renderAmbassadorCopy()} + ) } diff --git a/src/modules/project/pages1/projectView/views/body/sections/leaderboardSummary/components/Leaderboard.tsx b/src/modules/project/pages1/projectView/views/body/sections/leaderboardSummary/components/Leaderboard.tsx index 48a042075..cd0cfe54d 100644 --- a/src/modules/project/pages1/projectView/views/body/sections/leaderboardSummary/components/Leaderboard.tsx +++ b/src/modules/project/pages1/projectView/views/body/sections/leaderboardSummary/components/Leaderboard.tsx @@ -49,10 +49,10 @@ export const Leaderboard = () => { {funders.map((funder, index) => { - return + return })} - {userContributor && } + {userContributor && }