From ef81b179556b0b956df7b8de52663d78adbbd4d9 Mon Sep 17 00:00:00 2001 From: Jordy McNab Date: Mon, 20 Oct 2025 10:07:25 -0500 Subject: [PATCH 1/5] fixes on fixes on fixes --- src/components/Navbar/SiteNavigation.tsx | 17 +- src/components/ProjectPageSEO.tsx | 35 ++-- .../ProtocolActivityElement.tsx | 90 ++++++++ .../ProtocolActivity/ProtocolActivityList.tsx | 98 +++------ .../translateEventDataToProtocolPresenter.tsx | 84 ++++++++ src/components/SEOHead.tsx | 90 ++++++++ src/components/VolumeChart/index.tsx | 21 +- .../common/CoreAppWrapper/CoreAppWrapper.tsx | 14 +- src/components/common/Head/Head.tsx | 80 ++++---- .../common/SEO/OpenGraphMetaTags.tsx | 44 ---- src/components/common/SEO/SEO.tsx | 110 ---------- src/components/common/SEO/TwitterMetaTags.tsx | 29 --- .../common/SEO/metaTagsFormatted.ts | 11 - .../CustomTokenSettings.tsx | 2 +- .../graphql/queries/activityEvents.graphql | 4 + .../V4V5ProjectDashboard.tsx | 7 +- .../V4V5ActivityPanel/V4V5ActivityList.tsx | 155 ++++++++++---- .../V4V5ActivityPanel/V4V5AnalyticsPanel.tsx | 51 +++++ .../utils/transformEventsData.ts | 194 ++++++++++++++++-- .../components/ProjectActivityContext.tsx | 50 +++++ .../components/ProjectActivityList.tsx | 108 ++++++++++ .../components/ProjectActivityPanel.tsx | 14 ++ src/pages/_app.tsx | 4 + .../account/[addressOrEnsName]/index.tsx | 4 +- src/pages/p/[handle]/index.tsx | 19 +- src/pages/v4/[jbUrn]/index.tsx | 53 +++-- src/pages/v5/[jbUrn]/index.tsx | 53 +++-- src/utils/format/formatActivityAmount.ts | 43 ++++ 28 files changed, 1042 insertions(+), 442 deletions(-) create mode 100644 src/components/ProtocolActivity/ProtocolActivityElement.tsx create mode 100644 src/components/ProtocolActivity/utils/translateEventDataToProtocolPresenter.tsx create mode 100644 src/components/SEOHead.tsx delete mode 100644 src/components/common/SEO/OpenGraphMetaTags.tsx delete mode 100644 src/components/common/SEO/SEO.tsx delete mode 100644 src/components/common/SEO/TwitterMetaTags.tsx delete mode 100644 src/components/common/SEO/metaTagsFormatted.ts create mode 100644 src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityContext.tsx create mode 100644 src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx create mode 100644 src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityPanel.tsx create mode 100644 src/utils/format/formatActivityAmount.ts diff --git a/src/components/Navbar/SiteNavigation.tsx b/src/components/Navbar/SiteNavigation.tsx index f6e3d48613..b46cfc4483 100644 --- a/src/components/Navbar/SiteNavigation.tsx +++ b/src/components/Navbar/SiteNavigation.tsx @@ -15,6 +15,7 @@ import { TransactionsList } from './components/TransactionList/TransactionsList' import { ChangeNetworksButton } from './components/Wallet/ChangeNetworksButton' import { WalletButton } from './components/Wallet/WalletButton' import ProtocolActivityToggle from 'components/ProtocolActivity/ProtocolActivityToggle' +import { useRouter } from 'next/router' export function SiteNavigation() { const [hasMounted, setHasMounted] = useState(false) @@ -33,6 +34,12 @@ export function SiteNavigation() { const DesktopSiteNavigation = () => { const { chainUnsupported } = useWallet() + const router = useRouter() + + // Hide protocol activity toggle on project pages + const isProjectPage = router.pathname.startsWith('/v5/') || router.pathname.startsWith('/v4/') + const isProjectRoute = /^\/(v4|v5)\/[^\/]+/.test(router.asPath) + const showProtocolActivityToggle = !(isProjectPage && isProjectRoute) return (
@@ -74,7 +81,7 @@ const DesktopSiteNavigation = () => { - + {showProtocolActivityToggle && }
@@ -93,6 +100,12 @@ const DesktopSiteNavigation = () => { const MobileSiteNavigation = () => { const { chainUnsupported } = useWallet() + const router = useRouter() + + // Hide protocol activity toggle on project pages + const isProjectPage = router.pathname.startsWith('/v5/') || router.pathname.startsWith('/v4/') + const isProjectRoute = /^\/(v4|v5)\/[^\/]+/.test(router.asPath) + const showProtocolActivityToggle = !(isProjectPage && isProjectRoute) return (
@@ -157,7 +170,7 @@ const MobileSiteNavigation = () => { - + {showProtocolActivityToggle && }
diff --git a/src/components/ProjectPageSEO.tsx b/src/components/ProjectPageSEO.tsx index 82c03c4277..15dde683c8 100644 --- a/src/components/ProjectPageSEO.tsx +++ b/src/components/ProjectPageSEO.tsx @@ -3,7 +3,7 @@ import { JBChainId, toJbUrn } from 'juice-sdk-core' import { ProjectMetadata } from 'models/projectMetadata' import { cidFromUrl, ipfsPublicGatewayUrl } from 'utils/ipfs' import { stripHtmlTags } from 'utils/string' -import { SEO } from './common/SEO/SEO' +import { SEOHead } from './SEOHead' const ProjectPageSEO: React.FC<{ metadata?: ProjectMetadata @@ -14,25 +14,20 @@ const ProjectPageSEO: React.FC<{ ? ipfsPublicGatewayUrl(cidFromUrl(metadata.logoUri)) : undefined + const description = metadata?.projectTagline + ? metadata.projectTagline + : metadata?.description + ? stripHtmlTags(metadata.description) + : undefined + return ( - ) } @@ -50,9 +45,7 @@ export const V4ProjectSEO: React.FC<{ projectId: number }> = ({ metadata, chainId, projectId }) => { const urn = toJbUrn(chainId, BigInt(projectId)) - return ( - - ) + return } export const V5ProjectSEO: React.FC<{ @@ -61,7 +54,5 @@ export const V5ProjectSEO: React.FC<{ projectId: number }> = ({ metadata, chainId, projectId }) => { const urn = toJbUrn(chainId, BigInt(projectId)) - return ( - - ) + return } diff --git a/src/components/ProtocolActivity/ProtocolActivityElement.tsx b/src/components/ProtocolActivity/ProtocolActivityElement.tsx new file mode 100644 index 0000000000..f162ac45f8 --- /dev/null +++ b/src/components/ProtocolActivity/ProtocolActivityElement.tsx @@ -0,0 +1,90 @@ +import EthereumAddress from 'components/EthereumAddress' +import EtherscanLink from 'components/EtherscanLink' +import ProjectLogo from 'components/ProjectLogo' +import { JBChainId } from 'juice-sdk-react' +import { ChainLogo } from 'packages/v4v5/components/ChainLogo' +import { formatHistoricalDate } from 'utils/format/formatDate' + +export interface ProtocolActivityElementEvent { + id: string + timestamp: number + txHash: string + chainId: number + from: string + projectId?: number + projectName?: string | null + projectHandle?: string | null +} + +export function ProtocolActivityElement({ + header, + subject, + event, + projectName, +}: { + header: string | JSX.Element + subject: string | JSX.Element | null + event: ProtocolActivityElementEvent | null | undefined + projectName: string +}) { + if (!event) return null + + const displayName = projectName || `Project #${event.projectId || '?'}` + + return ( +
+ {/* Project Logo - 48x48px */} +
+ +
+ + {/* Content */} +
+ {/* Row 1: Project Name + Chain Icon */} +
+
+ {displayName} +
+ +
+ + {/* Row 2: Action Label + Amount */} +
+ + {header} + +
+ {subject} +
+
+ + {/* Row 3: Timestamp + From Address */} +
+ {formatHistoricalDate(event.timestamp * 1000)} + · + + · + +
+
+
+ ) +} diff --git a/src/components/ProtocolActivity/ProtocolActivityList.tsx b/src/components/ProtocolActivity/ProtocolActivityList.tsx index fa327f3176..be97bb3009 100644 --- a/src/components/ProtocolActivity/ProtocolActivityList.tsx +++ b/src/components/ProtocolActivity/ProtocolActivityList.tsx @@ -1,79 +1,44 @@ import { Button } from 'antd' import Loading from 'components/Loading' -import { NETWORKS, TESTNET_IDS, MAINNET_IDS } from 'constants/networks' import { useActivityEventsQuery } from 'generated/v4v5/graphql' import { testnetBendystrawClient, mainnetBendystrawClient } from 'lib/apollo/bendystrawClient' -import { translateEventDataToPresenter } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5ActivityList' import { AnyEvent, transformEventData, } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData' -import React, { useState, useMemo } from 'react' -import { ActivityEvent } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/activityEventElems/ActivityElement' +import React, { useState } from 'react' import { twMerge } from 'tailwind-merge' -import { ChainFilterButton } from './ChainFilterButtons' import Link from 'next/link' import { v4v5ProjectRoute } from 'packages/v4v5/utils/routes' +import { ProtocolActivityElement } from './ProtocolActivityElement' +import { translateEventDataToProtocolPresenter } from './utils/translateEventDataToProtocolPresenter' const PAGE_SIZE = 20 const POLL_INTERVAL = 30000 // 30 seconds type NetworkType = 'testnet' | 'mainnet' -// Default to testnet if NEXT_PUBLIC_TESTNET is true, otherwise mainnet -const defaultNetwork: NetworkType = process.env.NEXT_PUBLIC_TESTNET === 'true' ? 'testnet' : 'mainnet' +// Always default to mainnet first +const defaultNetwork: NetworkType = 'mainnet' export function ProtocolActivityList() { const [network, setNetwork] = useState(defaultNetwork) - const [selectedChainIds, setSelectedChainIds] = useState>(new Set()) const [endCursor, setEndCursor] = useState(null) // Select client based on network toggle const client = network === 'testnet' ? testnetBendystrawClient : mainnetBendystrawClient - // Get available chains based on current network - const availableChainIds = useMemo(() => { - return network === 'testnet' ? Array.from(TESTNET_IDS) : Array.from(MAINNET_IDS) - }, [network]) - - // Reset cursor and chains when network changes + // Reset cursor when network changes React.useEffect(() => { setEndCursor(null) - setSelectedChainIds(new Set()) }, [network]) - // Reset cursor when chain selection changes - React.useEffect(() => { - setEndCursor(null) - }, [selectedChainIds]) - - // Toggle chain selection - const toggleChain = (chainId: number) => { - setSelectedChainIds(prev => { - const newSet = new Set(prev) - if (newSet.has(chainId)) { - newSet.delete(chainId) - } else { - newSet.add(chainId) - } - return newSet - }) - } - - // Build query filter for selected chains - const queryFilter = useMemo(() => { - if (selectedChainIds.size === 0) { - return {} // No filter, show all chains - } - return { chainId_in: Array.from(selectedChainIds) } - }, [selectedChainIds]) - - // Query protocol activity with optional chain filter + // Query protocol activity (no chain filter) const { data: activityEvents, loading, error } = useActivityEventsQuery({ client, pollInterval: POLL_INTERVAL, // Poll every 30 seconds variables: { - where: queryFilter, + where: {}, orderBy: 'timestamp', orderDirection: 'desc', after: endCursor, @@ -82,11 +47,15 @@ export function ProtocolActivityList() { }) const projectEvents = React.useMemo( - () => - activityEvents?.activityEvents.items + () => { + if (!activityEvents?.activityEvents.items) return [] + + // Transform and present events for protocol activity + return activityEvents.activityEvents.items .map(transformEventData) .filter((event): event is AnyEvent => !!event) - .map(e => translateEventDataToPresenter(e, undefined)) ?? [], + .map(e => translateEventDataToProtocolPresenter(e)) + }, [activityEvents?.activityEvents.items], ) @@ -99,39 +68,28 @@ export function ProtocolActivityList() { {/* Network Toggle */}
- {/* Chain Filter Checkboxes */} -
- {availableChainIds.map(chainId => ( - toggleChain(chainId)} - /> - ))} -
@@ -144,6 +102,8 @@ export function ProtocolActivityList() { {loading || (projectEvents && projectEvents.length > 0) ? ( <> {projectEvents?.map(event => { + if (!event?.event) return null + const projectLink = event.event.projectId && event.event.chainId ? v4v5ProjectRoute({ projectId: event.event.projectId, @@ -152,6 +112,10 @@ export function ProtocolActivityList() { }) : null + const displayName = event.event.projectName || + event.event.projectHandle || + `Project #${event.event.projectId}` + return (
- ) : ( - )}
diff --git a/src/components/ProtocolActivity/utils/translateEventDataToProtocolPresenter.tsx b/src/components/ProtocolActivity/utils/translateEventDataToProtocolPresenter.tsx new file mode 100644 index 0000000000..d62006409e --- /dev/null +++ b/src/components/ProtocolActivity/utils/translateEventDataToProtocolPresenter.tsx @@ -0,0 +1,84 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { AmountInCurrency } from 'components/currency/AmountInCurrency' +import { AnyEvent } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData' +import { formatActivityAmount } from 'utils/format/formatActivityAmount' + +/** + * Translate event data to protocol activity presenter with formatted amounts + */ +export function translateEventDataToProtocolPresenter(event: AnyEvent) { + switch (event.type) { + case 'payEvent': + return { + event, + header: 'Paid', + subject: `${formatActivityAmount(event.amount.value)} ETH`, + } + case 'addToBalanceEvent': + return { + event, + header: 'Added to balance', + subject: `${formatActivityAmount(event.amount.value)} ETH`, + } + case 'mintTokensEvent': + return { + event, + header: 'Minted tokens', + subject: `${formatActivityAmount(event.amount.value)} tokens`, + } + case 'cashOutEvent': + return { + event, + header: 'Cashed out', + subject: `${formatActivityAmount(event.reclaimAmount.value)} ETH`, + } + case 'deployedERC20Event': + return { + event, + header: 'Deployed ERC20', + subject: event.symbol, + } + case 'projectCreateEvent': + return { + event, + header: 'Created', + subject: 'Project created', + } + case 'distributePayoutsEvent': + return { + event, + header: 'Send payouts', + subject: `${formatActivityAmount(event.amount.value)} ETH`, + } + case 'distributeReservedTokensEvent': + return { + event, + header: 'Send reserved tokens', + subject: `${formatActivityAmount(event.tokenCount)} tokens`, + } + case 'distributeToReservedTokenSplitEvent': + return { + event, + header: 'Send to reserved token split', + subject: `${formatActivityAmount(event.tokenCount)} tokens`, + } + case 'distributeToPayoutSplitEvent': + return { + event, + header: 'Send to payout split', + subject: `${formatActivityAmount(event.amount.value)} ETH`, + } + case 'useAllowanceEvent': + return { + event, + header: 'Used allowance', + subject: `${formatActivityAmount(event.amount.value)} ETH`, + } + case 'burnEvent': + return { + event, + header: 'Burned', + subject: `${formatActivityAmount(event.amount.value)} tokens`, + } + } +} diff --git a/src/components/SEOHead.tsx b/src/components/SEOHead.tsx new file mode 100644 index 0000000000..80d5f10667 --- /dev/null +++ b/src/components/SEOHead.tsx @@ -0,0 +1,90 @@ +import config from 'config/seo_meta.json' +import { SiteBaseUrl } from 'constants/url' +import Head from 'next/head' +import { FC } from 'react' +import { cidFromUrl, ipfsPublicGatewayUrl } from 'utils/ipfs' + +export interface SEOHeadProps { + title?: string + description?: string + url?: string + image?: string + twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player' + twitterCreator?: string + robots?: string + overrideFormattedTitle?: boolean +} + +/** + * Single SEO Head component that renders all meta tags in one component. + * This ensures proper SSR extraction with Next.js Pages Router. + */ +export const SEOHead: FC = ({ + title, + description, + url, + image, + twitterCard, + twitterCreator, + robots, + overrideFormattedTitle, +}) => { + // Format title + const formattedTitle = overrideFormattedTitle + ? (title ?? config.title) + : title + ? config.titleTemplate.replace(/%s/g, title) + : config.title + + // Use provided values or fall back to defaults + const finalDescription = description ?? config.description + const finalUrl = url ?? SiteBaseUrl ?? '/' + const finalRobots = robots ?? 'index,follow' + + // Process image (handle IPFS URIs) + const rawImage = image ?? config.twitter.image + const processedImage = rawImage.startsWith('ipfs://') + ? ipfsPublicGatewayUrl(cidFromUrl(rawImage)) + : rawImage + + // Use default JBM banner if no custom image + const isDefaultImage = rawImage === config.twitter.image + const finalImage = isDefaultImage + ? (SiteBaseUrl ?? '/') + 'assets/JBM-Unfurl-banner.png' + : processedImage + + const imageWidth = isDefaultImage ? 1136 : 1200 + const imageHeight = isDefaultImage ? 497 : 630 + + // Twitter card type + const finalTwitterCard = twitterCard ?? (config.twitter.cardType as 'summary_large_image') + + return ( + + {/* Basic Meta Tags */} + {formattedTitle} + + + + + {/* Open Graph Meta Tags */} + + + + + + + + + + + {/* Twitter Card Meta Tags */} + + + + + + + + ) +} diff --git a/src/components/VolumeChart/index.tsx b/src/components/VolumeChart/index.tsx index 892f1d1437..db81ef7fa4 100644 --- a/src/components/VolumeChart/index.tsx +++ b/src/components/VolumeChart/index.tsx @@ -17,15 +17,19 @@ export default function VolumeChart({ projectId, pv, version, + lockedView, + hideViewSelector, }: { height: CSSProperties['height'] createdAt: number | undefined projectId: number pv: PV version?: number + lockedView?: ProjectTimelineView + hideViewSelector?: boolean }) { const [timelineView, setTimelineView] = - useState('volume') + useState(lockedView || 'volume') const [range, setRange] = useTimelineRange({ createdAt }) @@ -47,13 +51,18 @@ export default function VolumeChart({ const points = shouldUseV4Hook ? v4v5Points : v1v2v3Points const loading = shouldUseV4Hook ? v4v5Loading : legacyLoading + // Use locked view if provided, otherwise use state + const currentView = lockedView || timelineView + return (
- + {!hideViewSelector && ( + + )}
@@ -61,7 +70,7 @@ export default function VolumeChart({
diff --git a/src/components/common/CoreAppWrapper/CoreAppWrapper.tsx b/src/components/common/CoreAppWrapper/CoreAppWrapper.tsx index f70ecab9f0..e727d66735 100644 --- a/src/components/common/CoreAppWrapper/CoreAppWrapper.tsx +++ b/src/components/common/CoreAppWrapper/CoreAppWrapper.tsx @@ -12,9 +12,8 @@ import dynamic from 'next/dynamic' import { installJuiceboxWindowObject } from 'lib/juicebox' import { redirectTo } from 'utils/windowUtils' import { twJoin, twMerge } from 'tailwind-merge' -import { useRouter } from 'next/router' -import { useProtocolActivity } from 'components/ProtocolActivity/ProtocolActivityContext' import useMobile from 'hooks/useMobile' +import { useRouter } from 'next/router' const EthersTxHistoryProvider = dynamic( () => import('contexts/Transaction/EthersTxHistoryProvider'), @@ -79,7 +78,6 @@ const _Wrapper: React.FC> = ({ hideNav, }) => { const router = useRouter() - const { isOpen } = useProtocolActivity() const isMobile = useMobile() useAdjustUrl() @@ -88,10 +86,10 @@ const _Wrapper: React.FC> = ({ installJuiceboxWindowObject() }, []) - // redirect legacy hash routes - if (router.asPath.match(/^\/#\//)) { - redirectTo(router.asPath.replace('/#/', '')) - } + // Check if we're on a project page (v4/v5 projects render their own activity panel) + const isProjectPage = router.pathname.startsWith('/v5/') || router.pathname.startsWith('/v4/') + const isProjectRoute = /^\/(v4|v5)\/[^\/]+/.test(router.asPath) + const showProtocolActivity = !isMobile && !(isProjectPage && isProjectRoute) return ( @@ -105,7 +103,7 @@ const _Wrapper: React.FC> = ({
{children}
- {!isMobile && } + {showProtocolActivity && }
diff --git a/src/components/common/Head/Head.tsx b/src/components/common/Head/Head.tsx index 77eafd957f..bc3736f570 100644 --- a/src/components/common/Head/Head.tsx +++ b/src/components/common/Head/Head.tsx @@ -1,46 +1,50 @@ -import { SEO, SEOProps } from '../SEO/SEO' +import NextHead from 'next/head' +import { SEOHead, SEOHeadProps } from '../../SEOHead' import { FONT_PATHS } from './fonts' -export const Head: React.FC = props => { +export const Head: React.FC = props => { return ( - - - - + <> + + + + + - - - - - - {FONT_PATHS.map(path => ( - ))} - + + + + + {FONT_PATHS.map(path => ( + + ))} + + ) } diff --git a/src/components/common/SEO/OpenGraphMetaTags.tsx b/src/components/common/SEO/OpenGraphMetaTags.tsx deleted file mode 100644 index 6e7b4c9234..0000000000 --- a/src/components/common/SEO/OpenGraphMetaTags.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import Head from 'next/head' - -import { metaTagsFormatted } from './metaTagsFormatted' - -interface OpenGraphMetaTagsProps { - url?: string - title?: string - type?: string - description?: string - siteName?: string - image?: { - src: string - type: string - width: string - height: string - } -} - -export const OpenGraphMetaTags = ({ - url, - title, - type, - description, - siteName, - image, -}: OpenGraphMetaTagsProps) => { - const rootTags = - metaTagsFormatted( - { url, title, type, description, site_name: siteName }, - 'og', - ) ?? [] - const imageTags = - metaTagsFormatted( - { _root: image?.src, width: image?.width, height: image?.height }, - 'og:image', - ) ?? [] - return ( - - {[...rootTags, ...imageTags].map(({ key, value }) => ( - - ))} - - ) -} diff --git a/src/components/common/SEO/SEO.tsx b/src/components/common/SEO/SEO.tsx deleted file mode 100644 index ea1b6fbeb7..0000000000 --- a/src/components/common/SEO/SEO.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import config from 'config/seo_meta.json' -import { SiteBaseUrl } from 'constants/url' -import Head from 'next/head' -import { FC, ReactNode } from 'react' -import { ipfsUriToGatewayUrl } from 'utils/ipfs' -import { HotjarScript } from '../Head/scripts/HotjarScript' -import { OpenGraphMetaTags } from './OpenGraphMetaTags' -import { - TwitterCardType, - TwitterMetaTags, - TwitterMetaTagsProps, -} from './TwitterMetaTags' - -export interface SEOProps { - url?: string - title?: string - overrideFormattedTitle?: boolean - description?: string - twitter?: Omit - image?: string // Open Graph and Twitter image URL - robots?: string - children?: ReactNode -} - -export const SEO: FC> = ({ - url, - title, - overrideFormattedTitle, - description, - twitter, - image, - robots, - children, -}) => { - const formatTwitterHandle = (handle: string | undefined) => - handle ? (handle.startsWith('@') ? handle : '@' + handle) : undefined - - let formattedTitle = config.title - if (title) { - formattedTitle = config.titleTemplate.replace(/%s/g, title) - } - if (overrideFormattedTitle) { - formattedTitle = title ?? config.title - } - - // Use provided image, or fall back to twitter.image, or finally to default config - const finalImage = image ?? twitter?.image ?? config.twitter.image - const processedImage = ipfsUriToGatewayUrl(finalImage) - - // Use default JBM banner dimensions if using default, otherwise use standard OG dimensions - const isDefaultImage = finalImage === config.twitter.image - const ogImage = { - src: isDefaultImage - ? (SiteBaseUrl ? SiteBaseUrl : '/') + 'assets/JBM-Unfurl-banner.png' - : processedImage, - type: 'image/png', - width: isDefaultImage ? '1136' : '1200', - height: isDefaultImage ? '497' : '630', - } - - return ( - <> - - {formattedTitle} - - - - - - - - {children} - {/** - * As recommended in Next docs that next/script can be loaded directly - * outside next/head with strategies like afterInteractive without affecting - * the page performance - */} - {process.env.NODE_ENV === 'production' && ( - <> - - - )} - - ) -} diff --git a/src/components/common/SEO/TwitterMetaTags.tsx b/src/components/common/SEO/TwitterMetaTags.tsx deleted file mode 100644 index 01c88ffd9c..0000000000 --- a/src/components/common/SEO/TwitterMetaTags.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Head from 'next/head' - -import { metaTagsFormatted } from './metaTagsFormatted' - -export type TwitterCardType = - | 'summary' - | 'summary_large_image' - | 'app' - | 'player' - -export interface TwitterMetaTagsProps { - title?: string - description?: string - handle?: string - site?: string - creator?: string - card?: TwitterCardType - image?: string -} - -export const TwitterMetaTags = (props: TwitterMetaTagsProps) => { - return ( - - {metaTagsFormatted(props, 'twitter')?.map(({ key, value }) => ( - - ))} - - ) -} diff --git a/src/components/common/SEO/metaTagsFormatted.ts b/src/components/common/SEO/metaTagsFormatted.ts deleted file mode 100644 index 6668c18145..0000000000 --- a/src/components/common/SEO/metaTagsFormatted.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function metaTagsFormatted(props: T, prefix?: string) { - if (!props || !Object.keys(props).length) return null - - return Object.entries(props) - .map(([k, value]) => { - if (!value) return null - const key = k === '_root' ? prefix : `${prefix}:${k}` - return value ? { key, value } : null - }) - .filter((i): i is { key: string; value: string } => !!i) -} diff --git a/src/packages/v4v5/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx b/src/packages/v4v5/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx index 23cb086d50..59dabcdfdd 100644 --- a/src/packages/v4v5/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx +++ b/src/packages/v4v5/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx @@ -260,7 +260,7 @@ export const CustomTokenSettings = () => { )} - {!Form.useWatch('enableCashOuts', form) && ( + {Form.useWatch('cashOutTaxRate', form) === 100 && ( Cash outs are disabled. Token holders will not be able to redeem their tokens for ETH. diff --git a/src/packages/v4v5/graphql/queries/activityEvents.graphql b/src/packages/v4v5/graphql/queries/activityEvents.graphql index a4d0199b6e..8520f6424f 100644 --- a/src/packages/v4v5/graphql/queries/activityEvents.graphql +++ b/src/packages/v4v5/graphql/queries/activityEvents.graphql @@ -20,6 +20,10 @@ query ActivityEvents( id chainId timestamp + project { + name + handle + } payEvent { id projectId diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectDashboard.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectDashboard.tsx index a12873e12e..6662dbf6b4 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectDashboard.tsx +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectDashboard.tsx @@ -10,7 +10,7 @@ import { twMerge } from 'tailwind-merge' import { useProjectPageQueries } from './hooks/useProjectPageQueries' import { V4V5ProjectHeader } from './V4V5ProjectHeader' import { V4V5ProjectTabs } from './V4V5ProjectTabs/V4V5ProjectTabs' -import { V4V5ActivityList } from './V4V5ProjectTabs/V4V5ActivityPanel/V4V5ActivityList' +import { ProjectActivityList } from './components/ProjectActivityList' export default function V4V5ProjectDashboard() { const { projectPayReceipt } = useProjectPageQueries() @@ -45,10 +45,7 @@ export default function V4V5ProjectDashboard() { )} > - {/* Activity Section */} -
- -
+
- activityEvents?.activityEvents.items + () => { + if (!activityEvents?.activityEvents.items) return [] + + // TODO: Event grouping temporarily disabled + // const groupedEvents = groupEventsByTransaction(activityEvents.activityEvents.items) + + // Transform and present events + return activityEvents.activityEvents.items .map(transformEventData) .filter((event): event is AnyEvent => !!event) - .map(e => translateEventDataToPresenter(e, tokenSymbol)) ?? [], + .map(e => translateEventDataToPresenter(e, tokenSymbol)) + }, [activityEvents?.activityEvents.items, tokenSymbol], ) @@ -142,11 +149,7 @@ export function translateEventDataToPresenter( header: 'Paid', subject: ( - + {formatActivityAmount(event.amount.value)} ETH ), extra: , @@ -157,11 +160,7 @@ export function translateEventDataToPresenter( header: 'Added to balance', subject: ( - + {formatActivityAmount(event.amount.value)} ETH ), extra: event.note ? : null, @@ -188,11 +187,7 @@ export function translateEventDataToPresenter( header: 'Cashed out', subject: ( - + {formatActivityAmount(event.reclaimAmount.value)} ETH ), } @@ -216,16 +211,12 @@ export function translateEventDataToPresenter( header: 'Send payouts', subject: ( - + {formatActivityAmount(event.amount.value)} ETH ), extra: ( ), } @@ -265,11 +256,7 @@ export function translateEventDataToPresenter( header: 'Send to payout split', subject: ( - + {formatActivityAmount(event.amount.value)} ETH ), extra: ( @@ -286,11 +273,7 @@ export function translateEventDataToPresenter( header: 'Used allowance', subject: ( - + {formatActivityAmount(event.amount.value)} ETH ), extra: , @@ -310,12 +293,106 @@ export function translateEventDataToPresenter( ), extra: ( ), } + // TODO: Aggregated Events - temporarily disabled until event grouping is implemented + // case 'paymentEvent': + // return { + // event, + // header: 'Payment', + // subject: ( + // + // + // {' → '} + // {event.tokensMinted.format()}{' '} + // {tokenSymbolText({ + // capitalize: true, + // tokenSymbol, + // plural: event.tokensMinted.toFloat() > 1, + // })} + // + // ), + // extra: event.note ? : null, + // } + // case 'cashOutAggregatedEvent': + // return { + // event, + // header: 'Cash out', + // subject: ( + // + // {Number(event.tokensBurned.toFloat())}{' '} + // {tokenSymbolText({ + // tokenSymbol, + // plural: event.tokensBurned.toFloat() > 1, + // })} + // {' → '} + // + // + // ), + // extra: ( + // + // ), + // } + // case 'erc20CreationEvent': + // return { + // event, + // header: 'ERC-20 token created', + // subject: {event.symbol}, + // extra: ( + // 1 ? 's' : '' + // }: ${event.chains.map(c => NETWORKS[c.chainId]?.label || `Chain ${c.chainId}`).join(', ')}`} + // /> + // ), + // } + // case 'payoutDistributionEvent': + // return { + // event, + // header: 'Payouts distributed', + // subject: ( + // + // + // {event.numberOfSplits > 0 && ` to ${event.numberOfSplits} split${event.numberOfSplits > 1 ? 's' : ''}`} + // + // ), + // extra: ( + // + // ), + // } + // case 'reservedTokenDistributionEvent': + // return { + // event, + // header: 'Reserved tokens distributed', + // subject: ( + // + // {fromWad(event.totalTokens)}{' '} + // {tokenSymbolText({ tokenSymbol, plural: event.totalTokens > 1 })} + // {event.numberOfSplits > 0 && ` to ${event.numberOfSplits} split${event.numberOfSplits > 1 ? 's' : ''}`} + // + // ), + // extra: , + // } } } diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5AnalyticsPanel.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5AnalyticsPanel.tsx index 3451159437..ed731ef95a 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5AnalyticsPanel.tsx +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5AnalyticsPanel.tsx @@ -30,7 +30,11 @@ export const V4V5AnalyticsPanel = forwardRef((props, ref) => {
{Boolean(projectId) && ( <> + {/* Volume Chart */}
+

+ Volume +

}> Volume chart failed to load.} @@ -41,11 +45,14 @@ export const V4V5AnalyticsPanel = forwardRef((props, ref) => { createdAt={createdAt} pv={PV_V4} version={version} + lockedView="volume" + hideViewSelector={true} />
+ {/* Token Holders Chart */}
}> ((props, ref) => {
+ + {/* In Juicebox Chart */} +
+

+ In Juicebox +

+ }> + Balance chart failed to load.} + > + + + +
+ + {/* Trending Chart */} +
+

+ Trending +

+ }> + Trending chart failed to load.} + > + + + +
)}
diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData.ts b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData.ts index 7839632fb1..f868ab862b 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData.ts +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData.ts @@ -21,6 +21,8 @@ export interface Event { id: string type: EventType projectId: number + projectName?: string | null + projectHandle?: string | null timestamp: number txHash: string from: string @@ -124,6 +126,13 @@ export interface BurnEvent extends Event { erc20Amount: Ether } +// TODO: Aggregated Event Interfaces - to be implemented later +// export interface PaymentEvent extends Event... +// export interface CashOutAggregatedEvent extends Event... +// export interface ERC20CreationEvent extends Event... +// export interface PayoutDistributionEvent extends Event... +// export interface ReservedTokenDistributionEvent extends Event... + export type AnyEvent = | PayEvent | AddToBalanceEvent @@ -139,13 +148,15 @@ export type AnyEvent = | BurnEvent // eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractBaseEventData(event: any): AnyEvent { +function extractBaseEventData(event: any, projectName?: string | null, projectHandle?: string | null): AnyEvent { return { // Make type null and set it later // @ts-ignore type: null, id: event.id, projectId: event.projectId, + projectName, + projectHandle, timestamp: event.timestamp, txHash: event.txHash, from: event.from, @@ -157,9 +168,21 @@ function extractBaseEventData(event: any): AnyEvent { export function transformEventData( data: ActivityEventsQuery['activityEvents']['items'][number], ): AnyEvent | null { + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + // Check for aggregated events first + // TODO: Aggregated event handling - temporarily disabled + // const aggregatedType = (data as any).__aggregatedType + // const relatedEvents = (data as any).__relatedEvents || [] + // if (aggregatedType === 'paymentEvent') { return transformPaymentEvent(data, relatedEvents) } + // if (aggregatedType === 'cashOutAggregatedEvent') { return transformCashOutAggregatedEvent(data, relatedEvents) } + // etc... + + // Handle individual events if (data.payEvent) { return { - ...extractBaseEventData(data.payEvent), + ...extractBaseEventData(data.payEvent, projectName, projectHandle), chainId: data.chainId, type: 'payEvent', amount: new Ether(BigInt(data.payEvent.amount)), @@ -174,7 +197,7 @@ export function transformEventData( } if (data.addToBalanceEvent) { return { - ...extractBaseEventData(data.addToBalanceEvent), + ...extractBaseEventData(data.addToBalanceEvent, projectName, projectHandle), chainId: data.chainId, type: 'addToBalanceEvent', amount: new Ether(BigInt(data.addToBalanceEvent.amount)), @@ -183,7 +206,7 @@ export function transformEventData( } if (data.mintTokensEvent) { return { - ...extractBaseEventData(data.mintTokensEvent), + ...extractBaseEventData(data.mintTokensEvent, projectName, projectHandle), chainId: data.chainId, type: 'mintTokensEvent', amount: new Ether(BigInt(data.mintTokensEvent.tokenCount)), @@ -193,7 +216,7 @@ export function transformEventData( } if (data.cashOutTokensEvent) { return { - ...extractBaseEventData(data.cashOutTokensEvent), + ...extractBaseEventData(data.cashOutTokensEvent, projectName, projectHandle), chainId: data.chainId, type: 'cashOutEvent', metadata: data.cashOutTokensEvent.metadata, @@ -205,7 +228,7 @@ export function transformEventData( } if (data.deployErc20Event) { return { - ...extractBaseEventData(data.deployErc20Event), + ...extractBaseEventData(data.deployErc20Event, projectName, projectHandle), chainId: data.chainId, type: 'deployedERC20Event', symbol: data.deployErc20Event.symbol, @@ -214,14 +237,14 @@ export function transformEventData( } if (data.projectCreateEvent) { return { - ...extractBaseEventData(data.projectCreateEvent), + ...extractBaseEventData(data.projectCreateEvent, projectName, projectHandle), chainId: data.chainId, type: 'projectCreateEvent', } } if (data.sendPayoutsEvent) { return { - ...extractBaseEventData(data.sendPayoutsEvent), + ...extractBaseEventData(data.sendPayoutsEvent, projectName, projectHandle), chainId: data.chainId, type: 'distributePayoutsEvent', amount: new Ether(BigInt(data.sendPayoutsEvent.amount)), @@ -233,7 +256,7 @@ export function transformEventData( } if (data.sendReservedTokensToSplitsEvent) { return { - ...extractBaseEventData(data.sendReservedTokensToSplitsEvent), + ...extractBaseEventData(data.sendReservedTokensToSplitsEvent, projectName, projectHandle), chainId: data.chainId, type: 'distributeReservedTokensEvent', rulesetCycleNumber: @@ -243,7 +266,7 @@ export function transformEventData( } if (data.sendReservedTokensToSplitEvent) { return { - ...extractBaseEventData(data.sendReservedTokensToSplitEvent), + ...extractBaseEventData(data.sendReservedTokensToSplitEvent, projectName, projectHandle), chainId: data.chainId, type: 'distributeToReservedTokenSplitEvent', tokenCount: data.sendReservedTokensToSplitEvent.tokenCount, @@ -257,7 +280,7 @@ export function transformEventData( } if (data.sendPayoutToSplitEvent) { return { - ...extractBaseEventData(data.sendPayoutToSplitEvent), + ...extractBaseEventData(data.sendPayoutToSplitEvent, projectName, projectHandle), chainId: data.chainId, type: 'distributeToPayoutSplitEvent', amount: new Ether(BigInt(data.sendPayoutToSplitEvent.amount)), @@ -270,7 +293,7 @@ export function transformEventData( } if (data.useAllowanceEvent) { return { - ...extractBaseEventData(data.useAllowanceEvent), + ...extractBaseEventData(data.useAllowanceEvent, projectName, projectHandle), chainId: data.chainId, type: 'useAllowanceEvent', rulesetId: BigInt(data.useAllowanceEvent.rulesetId), @@ -288,7 +311,7 @@ export function transformEventData( } if (data.burnEvent) { return { - ...extractBaseEventData(data.burnEvent), + ...extractBaseEventData(data.burnEvent, projectName, projectHandle), chainId: data.chainId, type: 'burnEvent', holder: data.burnEvent.from, @@ -300,3 +323,148 @@ export function transformEventData( console.warn('Unknown event type', data) return null } + +// TODO: Transform functions for aggregated events - temporarily disabled +/* +function transformPaymentEvent( + data: ActivityEventsQuery['activityEvents']['items'][number], + relatedEvents: ActivityEventsQuery['activityEvents']['items'], +): any | null { + const payEvent = relatedEvents.find(e => e.payEvent)?.payEvent + const mintEvent = relatedEvents.find(e => e.mintTokensEvent)?.mintTokensEvent + + if (!payEvent) return null + + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + return { + ...extractBaseEventData(payEvent, projectName, projectHandle), + chainId: data.chainId, + type: 'paymentEvent', + amountPaid: new Ether(BigInt(payEvent.amount)), + tokensMinted: new JBProjectToken( + BigInt(mintEvent?.tokenCount || payEvent.newlyIssuedTokenCount), + ), + beneficiary: payEvent.beneficiary, + note: payEvent.memo || '', + distributionFromProjectId: payEvent.distributionFromProjectId, + feeFromProject: payEvent.feeFromProject, + } +} + +function transformCashOutAggregatedEvent( + data: ActivityEventsQuery['activityEvents']['items'][number], + relatedEvents: ActivityEventsQuery['activityEvents']['items'], +): CashOutAggregatedEvent | null { + const cashOutEvent = relatedEvents.find(e => e.cashOutTokensEvent) + ?.cashOutTokensEvent + const burnEvent = relatedEvents.find(e => e.burnEvent)?.burnEvent + + if (!cashOutEvent || !burnEvent) return null + + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + return { + ...extractBaseEventData(cashOutEvent, projectName, projectHandle), + chainId: data.chainId, + type: 'cashOutAggregatedEvent', + holder: cashOutEvent.holder, + beneficiary: cashOutEvent.beneficiary, + tokensBurned: new Ether(BigInt(burnEvent.amount)), + stakedAmount: new Ether(BigInt(burnEvent.creditAmount)), + erc20Amount: new Ether(BigInt(burnEvent.erc20Amount)), + ethReceived: new Ether(BigInt(cashOutEvent.reclaimAmount)), + metadata: cashOutEvent.metadata, + } +} + +function transformERC20CreationEvent( + data: ActivityEventsQuery['activityEvents']['items'][number], + relatedEvents: ActivityEventsQuery['activityEvents']['items'], +): ERC20CreationEvent | null { + const erc20Events = relatedEvents.filter(e => e.deployErc20Event) + + if (erc20Events.length === 0) return null + + const firstEvent = erc20Events[0].deployErc20Event + if (!firstEvent) return null + + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + return { + ...extractBaseEventData(firstEvent, projectName, projectHandle), + chainId: data.chainId, + type: 'erc20CreationEvent', + symbol: firstEvent.symbol, + chains: erc20Events.map(e => ({ + chainId: e.chainId, + address: e.deployErc20Event?.token || '', + })), + } +} + +function transformPayoutDistributionEvent( + data: ActivityEventsQuery['activityEvents']['items'][number], + relatedEvents: ActivityEventsQuery['activityEvents']['items'], +): PayoutDistributionEvent | null { + const distributeEvent = relatedEvents.find(e => e.sendPayoutsEvent) + ?.sendPayoutsEvent + const splitEvents = relatedEvents.filter(e => e.sendPayoutToSplitEvent) + + if (!distributeEvent) return null + + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + return { + ...extractBaseEventData(distributeEvent, projectName, projectHandle), + chainId: data.chainId, + type: 'payoutDistributionEvent', + totalAmount: new Ether(BigInt(distributeEvent.amount)), + amountPaidOut: new Ether(BigInt(distributeEvent.amountPaidOut)), + fee: new Ether(BigInt(distributeEvent.fee)), + numberOfSplits: splitEvents.length, + rulesetCycleNumber: BigInt(distributeEvent.rulesetCycleNumber), + rulesetId: BigInt(distributeEvent.rulesetId), + splits: splitEvents.map(e => ({ + beneficiary: e.sendPayoutToSplitEvent?.beneficiary || '', + amount: new Ether(BigInt(e.sendPayoutToSplitEvent?.amount || 0)), + percent: e.sendPayoutToSplitEvent?.percent || 0, + splitProjectId: e.sendPayoutToSplitEvent?.splitProjectId || 0, + })), + } +} + +function transformReservedTokenDistributionEvent( + data: ActivityEventsQuery['activityEvents']['items'][number], + relatedEvents: ActivityEventsQuery['activityEvents']['items'], +): ReservedTokenDistributionEvent | null { + const distributeEvent = relatedEvents.find( + e => e.sendReservedTokensToSplitsEvent, + )?.sendReservedTokensToSplitsEvent + const splitEvents = relatedEvents.filter(e => e.sendReservedTokensToSplitEvent) + + if (!distributeEvent) return null + + const projectName = data.project?.name ?? null + const projectHandle = data.project?.handle ?? null + + return { + ...extractBaseEventData(distributeEvent, projectName, projectHandle), + chainId: data.chainId, + type: 'reservedTokenDistributionEvent', + totalTokens: distributeEvent.tokenCount, + numberOfSplits: splitEvents.length, + rulesetCycleNumber: distributeEvent.rulesetCycleNumber, + splits: splitEvents.map(e => ({ + beneficiary: e.sendReservedTokensToSplitEvent?.beneficiary || '', + tokenCount: e.sendReservedTokensToSplitEvent?.tokenCount || BigInt(0), + percent: e.sendReservedTokensToSplitEvent?.percent || 0, + splitProjectId: e.sendReservedTokensToSplitEvent?.splitProjectId || 0, + })), + } +} +*/ diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityContext.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityContext.tsx new file mode 100644 index 0000000000..72d591e13f --- /dev/null +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityContext.tsx @@ -0,0 +1,50 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, + useState, +} from 'react' + +interface ProjectActivityContextType { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +const ProjectActivityContext = createContext< + ProjectActivityContextType | undefined +>(undefined) + +export const useProjectActivity = () => { + const context = useContext(ProjectActivityContext) + if (!context) { + throw new Error( + 'useProjectActivity must be used within ProjectActivityProvider', + ) + } + return context +} + +export const ProjectActivityProvider: React.FC = ({ + children, +}) => { + const [isOpen, setIsOpen] = useState(true) + + const value = useMemo( + () => ({ + isOpen, + open: () => setIsOpen(true), + close: () => setIsOpen(false), + toggle: () => setIsOpen(prev => !prev), + }), + [isOpen], + ) + + return ( + + {children} + + ) +} diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx new file mode 100644 index 0000000000..ad45857c38 --- /dev/null +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx @@ -0,0 +1,108 @@ +import { Button } from 'antd' +import Loading from 'components/Loading' +import { useActivityEventsQuery, useProjectQuery } from 'generated/v4v5/graphql' +import { getBendystrawClient } from 'lib/apollo/bendystrawClient' +import { translateEventDataToPresenter } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5ActivityList' +import { + AnyEvent, + transformEventData, +} from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/utils/transformEventsData' +import React, { useState } from 'react' +import { ActivityEvent } from 'packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/activityEventElems/ActivityElement' +import { useJBChainId, useJBContractContext } from 'juice-sdk-react' +import { useV4V5Version } from 'packages/v4v5/contexts/V4V5VersionProvider' + +const PAGE_SIZE = 20 +const POLL_INTERVAL = 30000 // 30 seconds + +export function ProjectActivityList() { + const { projectId } = useJBContractContext() + const chainId = useJBChainId() + const { version } = useV4V5Version() + const [endCursor, setEndCursor] = useState(null) + + // Load the bendystraw project to get its suckerGroupId + const { data: project } = useProjectQuery({ + client: getBendystrawClient(chainId), + variables: { + chainId: Number(chainId), + projectId: Number(projectId), + version: version + }, + skip: !chainId || !projectId, + }) + + // Query project activity using suckerGroupId (this shows activity across all chains for this project) + const { data: activityEvents, loading, error } = useActivityEventsQuery({ + client: getBendystrawClient(chainId), + pollInterval: POLL_INTERVAL, // Poll every 30 seconds + skip: !project?.project?.suckerGroupId, + variables: { + where: { + suckerGroupId: project?.project?.suckerGroupId, + }, + orderBy: 'timestamp', + orderDirection: 'desc', + after: endCursor, + limit: PAGE_SIZE, + }, + }) + + const projectEvents = React.useMemo( + () => + activityEvents?.activityEvents.items + .map(transformEventData) + .filter((event): event is AnyEvent => !!event) + .map(e => translateEventDataToPresenter(e, undefined)) ?? [], + [activityEvents?.activityEvents.items], + ) + + return ( +
+

+ Activity +

+
+ {loading && } + {error && ( +
+ Error loading activity: {error.message} +
+ )} + {loading || (projectEvents && projectEvents.length > 0) ? ( + <> + {projectEvents?.map(event => { + if (!event?.event) return null + + return ( +
+ +
+ ) + })} + {activityEvents?.activityEvents.pageInfo.hasNextPage && ( + + )} + + ) : ( + !loading && No activity yet. + )} +
+
+ ) +} diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityPanel.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityPanel.tsx new file mode 100644 index 0000000000..ad3313f3b4 --- /dev/null +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityPanel.tsx @@ -0,0 +1,14 @@ +import { ProjectActivityList } from './ProjectActivityList' + +export function ProjectActivityPanel() { + return ( +
+ {/* Content */} +
+ +
+
+ ) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index bd7f0d1f67..47a5d83dba 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -28,6 +28,10 @@ export default function MyApp({ Component, pageProps }: AppProps) { {/* Default HEAD - only rendered when page doesn't provide metadata */} {shouldRenderDefaultHead && } + {/* Project-specific HEAD - rendered when metadata is provided */} + {!shouldRenderDefaultHead && pageProps.metadata && pageProps.seoProps && ( + + )} {/* Moving ThemeProvider up so Para can react to the theme changes and update modal*/} diff --git a/src/pages/account/[addressOrEnsName]/index.tsx b/src/pages/account/[addressOrEnsName]/index.tsx index 35c4a2fad4..06a344ab4f 100644 --- a/src/pages/account/[addressOrEnsName]/index.tsx +++ b/src/pages/account/[addressOrEnsName]/index.tsx @@ -3,7 +3,7 @@ import axios from 'axios' import { AccountDashboard } from 'components/AccountDashboard/AccountDashboard' import Loading from 'components/Loading' import { AppWrapper } from 'components/common/CoreAppWrapper/CoreAppWrapper' -import { SEO } from 'components/common/SEO/SEO' +import { SEOHead } from 'components/SEOHead' import { isAddress } from 'ethers/lib/utils' import { resolveAddress } from 'lib/api/ens' import { loadCatalog } from 'locales/utils' @@ -61,7 +61,7 @@ function _AccountPage({ addressOrEnsName }: { addressOrEnsName: string }) { return ( <> - + ) diff --git a/src/pages/p/[handle]/index.tsx b/src/pages/p/[handle]/index.tsx index 94bde0e051..6f2d50dd2d 100644 --- a/src/pages/p/[handle]/index.tsx +++ b/src/pages/p/[handle]/index.tsx @@ -1,7 +1,7 @@ import Loading from 'components/Loading' import Project404 from 'components/Project404' import { AppWrapper } from 'components/common/CoreAppWrapper/CoreAppWrapper' -import { SEO } from 'components/common/SEO/SEO' +import { SEOHead } from 'components/SEOHead' import { SiteBaseUrl } from 'constants/url' import { AnnouncementLauncher } from 'contexts/Announcements/AnnouncementLauncher' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' @@ -51,20 +51,17 @@ export default function V1HandlePage({ // Checks URL to see if user was just directed from project deploy return ( <> - diff --git a/src/pages/v4/[jbUrn]/index.tsx b/src/pages/v4/[jbUrn]/index.tsx index ed25d75382..e2b6983266 100644 --- a/src/pages/v4/[jbUrn]/index.tsx +++ b/src/pages/v4/[jbUrn]/index.tsx @@ -1,17 +1,20 @@ -import { V4ProjectSEO } from 'components/ProjectPageSEO' import { FEATURE_FLAGS } from 'constants/featureFlags' import { PV_V4 } from 'constants/pv' -import { jbUrn } from 'juice-sdk-core' +import { SiteBaseUrl } from 'constants/url' +import { jbUrn, toJbUrn } from 'juice-sdk-core' import { loadCatalog } from 'locales/utils' import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next' import dynamic from 'next/dynamic' import { V4V5VersionProvider } from 'packages/v4v5/contexts/V4V5VersionProvider' import React, { PropsWithChildren } from 'react' import { featureFlagEnabled } from 'utils/featureFlags' +import { cidFromUrl, ipfsPublicGatewayUrl } from 'utils/ipfs' import { getProjectStaticProps, ProjectPageProps, } from 'utils/server/pages/props' +import { stripHtmlTags } from 'utils/string' +import type { SEOHeadProps } from 'components/SEOHead' const V4V5ProjectProviders = dynamic( () => import('packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectProviders'), @@ -43,7 +46,7 @@ export const getStaticPaths: GetStaticPaths = async () => { } export const getStaticProps: GetStaticProps< - ProjectPageProps & { i18n: unknown } + ProjectPageProps & { i18n: unknown; seoProps?: SEOHeadProps } > = async context => { const locale = context.locale as string const messages = await loadCatalog(locale) @@ -61,6 +64,29 @@ export const getStaticProps: GetStaticProps< )) as any if (props?.props) { props.props.i18n = i18n + + // Generate SEO props for _app.tsx to render + if (props.props.metadata && chainId && projectId) { + const urn = toJbUrn(chainId, BigInt(projectId)) + const metadata = props.props.metadata + const projectImage = metadata?.logoUri + ? ipfsPublicGatewayUrl(cidFromUrl(metadata.logoUri)) + : undefined + const description = metadata?.projectTagline + ? metadata.projectTagline + : metadata?.description + ? stripHtmlTags(metadata.description) + : undefined + + props.props.seoProps = { + title: metadata?.name, + url: `${SiteBaseUrl}v4/${urn}`, + description, + image: projectImage, + twitterCard: 'summary_large_image' as const, + twitterCreator: metadata?.twitter, + } + } } return { @@ -101,19 +127,12 @@ export default function V4ProjectPage({ } return ( - <> - - <_Wrapper> - - - - - - - + <_Wrapper> + + + + + + ) } diff --git a/src/pages/v5/[jbUrn]/index.tsx b/src/pages/v5/[jbUrn]/index.tsx index 4cbc1aca12..99ec152094 100644 --- a/src/pages/v5/[jbUrn]/index.tsx +++ b/src/pages/v5/[jbUrn]/index.tsx @@ -1,17 +1,20 @@ -import { V5ProjectSEO } from 'components/ProjectPageSEO' import { FEATURE_FLAGS } from 'constants/featureFlags' import { PV_V5 } from 'constants/pv' -import { jbUrn } from 'juice-sdk-core' +import { SiteBaseUrl } from 'constants/url' +import { jbUrn, toJbUrn } from 'juice-sdk-core' import { loadCatalog } from 'locales/utils' import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next' import dynamic from 'next/dynamic' import { V4V5VersionProvider } from 'packages/v4v5/contexts/V4V5VersionProvider' import React, { PropsWithChildren } from 'react' import { featureFlagEnabled } from 'utils/featureFlags' +import { cidFromUrl, ipfsPublicGatewayUrl } from 'utils/ipfs' import { getProjectStaticProps, ProjectPageProps, } from 'utils/server/pages/props' +import { stripHtmlTags } from 'utils/string' +import type { SEOHeadProps } from 'components/SEOHead' const V4V5ProjectProviders = dynamic( () => import('packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectProviders'), @@ -43,7 +46,7 @@ export const getStaticPaths: GetStaticPaths = async () => { } export const getStaticProps: GetStaticProps< - ProjectPageProps & { i18n: unknown } + ProjectPageProps & { i18n: unknown; seoProps?: SEOHeadProps } > = async context => { const locale = context.locale as string const messages = await loadCatalog(locale) @@ -61,6 +64,29 @@ export const getStaticProps: GetStaticProps< )) as any if (props?.props) { props.props.i18n = i18n + + // Generate SEO props for _app.tsx to render + if (props.props.metadata && chainId && projectId) { + const urn = toJbUrn(chainId, BigInt(projectId)) + const metadata = props.props.metadata + const projectImage = metadata?.logoUri + ? ipfsPublicGatewayUrl(cidFromUrl(metadata.logoUri)) + : undefined + const description = metadata?.projectTagline + ? metadata.projectTagline + : metadata?.description + ? stripHtmlTags(metadata.description) + : undefined + + props.props.seoProps = { + title: metadata?.name, + url: `${SiteBaseUrl}v5/${urn}`, + description, + image: projectImage, + twitterCard: 'summary_large_image' as const, + twitterCreator: metadata?.twitter, + } + } } return { @@ -101,19 +127,12 @@ export default function V5ProjectPage({ } return ( - <> - - <_Wrapper> - - - - - - - + <_Wrapper> + + + + + + ) } diff --git a/src/utils/format/formatActivityAmount.ts b/src/utils/format/formatActivityAmount.ts new file mode 100644 index 0000000000..27bfeae79c --- /dev/null +++ b/src/utils/format/formatActivityAmount.ts @@ -0,0 +1,43 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { fromWad } from './formatNumber' + +/** + * Format amounts for activity feeds with adaptive decimal places. + * - Very small (< 0.01): Up to 4 decimals + * - Small (0.01 - 1): 3 decimals + * - Medium/Large (>= 1): 2 decimals + * - Adds comma separators for thousands + */ +export function formatActivityAmount(amount: BigNumber | bigint | string): string { + const num = typeof amount === 'string' + ? parseFloat(fromWad(amount)) + : parseFloat(fromWad(BigNumber.from(amount))) + + if (isNaN(num)) return '0' + + // Very small amounts: keep precision + if (num < 0.01 && num > 0) { + // Find first non-zero decimal + const str = num.toFixed(6) + const match = str.match(/0\.0*[1-9]/) + if (match) { + const zerosAfterDecimal = match[0].length - 2 // subtract "0." + const decimals = Math.min(zerosAfterDecimal + 2, 6) // show 2 significant digits + return num.toFixed(decimals) + } + return num.toFixed(4) + } + + // Small amounts (0.01 - 1): 3 decimals + if (num < 1) { + return num.toFixed(3) + } + + // Medium and large amounts (>= 1): 2 decimals with comma separators + const formatted = num.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + + return formatted +} From c2c0565253cbd5d760b31e1c09669bb8e2b31656 Mon Sep 17 00:00:00 2001 From: Jordy McNab Date: Mon, 20 Oct 2025 10:10:21 -0500 Subject: [PATCH 2/5] fix: add max height and overflow to activity list for better UI experience --- .../V4V5ProjectDashboard/components/ProjectActivityList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx index ad45857c38..8f1e2318dd 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/components/ProjectActivityList.tsx @@ -62,7 +62,7 @@ export function ProjectActivityList() {

Activity

-
+
{loading && } {error && (
From ff1b429400006612f28d88a28b5b464014dfef0f Mon Sep 17 00:00:00 2001 From: Jordy McNab Date: Mon, 20 Oct 2025 10:38:49 -0500 Subject: [PATCH 3/5] fix: improve readability by adding braces for conditional statements in TokenPieChart and TokenDistributionChart --- .../TokenDistributionChart/TokenPieChart.tsx | 4 +- .../TokenDistributionChart/index.tsx | 4 +- .../V4V5TokenHoldersChart.tsx | 112 +++++++++--------- 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx index e49ba3ff5a..ab0a3b5d75 100644 --- a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx +++ b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx @@ -31,7 +31,9 @@ export default function TokenPieChart({ // Format participants for chart display const pieChartData = useMemo(() => { - if (!tokenSupply || !participants) return [] + if (!tokenSupply || !participants) { + return [] + } // Only show (arbitrary) max number of wallets to avoid chart clutter const maxVisibleWallets = 100 diff --git a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx index ffa51f03ac..c451747a1b 100644 --- a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx +++ b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx @@ -17,7 +17,9 @@ export default function TokenDistributionChart({ const [viewMode, setViewMode] = useState<'pie' | 'area'>('pie') // Don't render chart for projects with no token supply - if (tokenSupply === 0n || !participants?.length) return null + if (tokenSupply === 0n || !participants?.length) { + return null + } const size = 320 diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx index feac1e7f4f..5bce40f8a1 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx @@ -6,73 +6,79 @@ import { useJBTokenContext, } from 'juice-sdk-react' import { getBendystrawClient } from 'lib/apollo/bendystrawClient' -import { tokenSymbolText } from 'utils/tokenSymbolText' +import TokenDistributionChart from 'packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart' import { useV4V5Version } from 'packages/v4v5/contexts/V4V5VersionProvider' import { useV4V5TotalTokenSupply } from 'packages/v4v5/hooks/useV4V5TotalTokenSupply' -import TokenDistributionChart from 'packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart' import { forwardRef } from 'react' +import { tokenSymbolText } from 'utils/tokenSymbolText' -export const V4V5TokenHoldersChart = forwardRef((props, ref) => { - const { projectId } = useJBContractContext() - const chainId = useJBChainId() - const { version } = useV4V5Version() +export const V4V5TokenHoldersChart = forwardRef( + (props, ref) => { + const { projectId } = useJBContractContext() + const chainId = useJBChainId() + const { version } = useV4V5Version() - const { token } = useJBTokenContext() - const tokenSymbol = token?.data?.symbol + const { token } = useJBTokenContext() + const tokenSymbol = token?.data?.symbol - const { data: totalTokenSupply } = useV4V5TotalTokenSupply() + const { data: totalTokenSupply } = useV4V5TotalTokenSupply() - const { data: project } = useProjectQuery({ - client: getBendystrawClient(chainId), - variables: { - projectId: Number(projectId), - chainId: chainId || 0, - version: version - }, - skip: !projectId || !chainId, - }) + const { data: project } = useProjectQuery({ + client: getBendystrawClient(chainId), + variables: { + projectId: Number(projectId), + chainId: chainId || 0, + version: version, + }, + skip: !projectId || !chainId, + }) - const { data, loading } = useParticipantsQuery({ - client: getBendystrawClient(chainId), - variables: { - orderDirection: 'desc', - orderBy: 'balance', - where: { - suckerGroupId: project?.project?.suckerGroupId, + const { data, loading } = useParticipantsQuery({ + client: getBendystrawClient(chainId), + variables: { + orderDirection: 'desc', + orderBy: 'balance', + where: { + suckerGroupId: project?.project?.suckerGroupId, + }, }, - }, - skip: !project?.project?.suckerGroupId, - }) + skip: !project?.project?.suckerGroupId, + }) - const allParticipants = data?.participants + const allParticipants = data?.participants - // Don't render if no token supply or participants - if (!totalTokenSupply || totalTokenSupply === 0n || !allParticipants?.items.length) { - return null - } + // Don't render if no token supply or participants + if ( + !totalTokenSupply || + totalTokenSupply === 0n || + !allParticipants?.items.length + ) { + return null + } - return ( -
-
-

- - {tokenSymbolText({ tokenSymbol, capitalize: true })} holders - -

-
- {allParticipants.items.length} wallets + return ( +
+
+

+ + {tokenSymbolText({ tokenSymbol, capitalize: true })} holders + +

+
+ {allParticipants.items.length} wallets +
-
-
- +
+ +
-
- ) -}) + ) + }, +) V4V5TokenHoldersChart.displayName = 'V4V5TokenHoldersChart' From ca6c82ce93ed83222a2adc0b9cba6d3b570e2bce Mon Sep 17 00:00:00 2001 From: Jordy McNab Date: Mon, 20 Oct 2025 12:58:17 -0500 Subject: [PATCH 4/5] fix: filter token holders to exclude zero-balance participants - Filter participants to only include wallets with non-zero token balances - Add zero-balance check in TokenDistributionChart to prevent rendering empty charts - Update wallet count displays to show actual token holders instead of all participants - Fixes issue where charts showed empty when participants had contributed but held no tokens --- .../TokenDistributionChart/index.tsx | 13 +++++++++++++ .../V4V5TokenHoldersModal.tsx | 13 +++++++++++-- .../V4V5ActivityPanel/V4V5TokenHoldersChart.tsx | 17 +++++++++++++---- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx index c451747a1b..767dba67c3 100644 --- a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx +++ b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/index.tsx @@ -21,6 +21,19 @@ export default function TokenDistributionChart({ return null } + // Check if all participants have zero balances + const hasNonZeroBalances = participants.some((p) => { + const balance = BigInt(p.balance || 0) + const creditBalance = BigInt(p.creditBalance || 0) + const erc20Balance = BigInt(p.erc20Balance || 0) + const totalBalance = balance + creditBalance + erc20Balance + return totalBalance > 0n + }) + + if (!hasNonZeroBalances) { + return null + } + const size = 320 if (isLoading) { diff --git a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/V4V5TokenHoldersModal.tsx b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/V4V5TokenHoldersModal.tsx index 0d2fa6dcc5..085810c71d 100644 --- a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/V4V5TokenHoldersModal.tsx +++ b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/V4V5TokenHoldersModal.tsx @@ -56,6 +56,15 @@ export const V4V5TokenHoldersModal = ({ const allParticipants = data?.participants + // Filter to only include participants with non-zero token balances + const tokenHolders = allParticipants?.items.filter((p) => { + const balance = BigInt(p.balance || 0) + const creditBalance = BigInt(p.creditBalance || 0) + const erc20Balance = BigInt(p.erc20Balance || 0) + const totalBalance = balance + creditBalance + erc20Balance + return totalBalance > 0n + }) + return (
)} -
{allParticipants?.items.length} wallets
+
{tokenHolders?.length ?? 0} wallets
diff --git a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx index 5bce40f8a1..bd09bc79d9 100644 --- a/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx +++ b/src/packages/v4v5/views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5ActivityPanel/V4V5TokenHoldersChart.tsx @@ -47,11 +47,20 @@ export const V4V5TokenHoldersChart = forwardRef( const allParticipants = data?.participants - // Don't render if no token supply or participants + // Filter to only include participants with non-zero token balances + const tokenHolders = allParticipants?.items.filter((p) => { + const balance = BigInt(p.balance || 0) + const creditBalance = BigInt(p.creditBalance || 0) + const erc20Balance = BigInt(p.erc20Balance || 0) + const totalBalance = balance + creditBalance + erc20Balance + return totalBalance > 0n + }) + + // Don't render if no token supply or no actual token holders if ( !totalTokenSupply || totalTokenSupply === 0n || - !allParticipants?.items.length + !tokenHolders?.length ) { return null } @@ -65,13 +74,13 @@ export const V4V5TokenHoldersChart = forwardRef(
- {allParticipants.items.length} wallets + {tokenHolders.length} wallets
From 5a1a5a370c6cef45b9cc46b260d21a54d34ece44 Mon Sep 17 00:00:00 2001 From: Jordy McNab Date: Mon, 20 Oct 2025 15:50:22 -0500 Subject: [PATCH 5/5] Update messages.pot --- src/locales/messages.pot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 18d497f4f0..c3913ce7c1 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -3089,6 +3089,9 @@ msgstr "" msgid "Search projects" msgstr "" +msgid "Balance chart failed to load." +msgstr "" + msgid "Collection details" msgstr "" @@ -3146,6 +3149,9 @@ msgstr "" msgid "Advanced options:" msgstr "" +msgid "Trending chart failed to load." +msgstr "" + msgid "Payments to this project are paused in this cycle." msgstr ""