1
0
Переглянути джерело

Merge remote-tracking branch 'Joystream/dev'

Artem 1 рік тому
батько
коміт
a6fe14d7f6
27 змінених файлів з 632 додано та 492 видалено
  1. 10 5
      packages/atlas/atlas.config.yml
  2. 16 0
      packages/atlas/plugins/index.ts
  3. 10 1
      packages/atlas/src/.env
  4. 3 1
      packages/atlas/src/api/client/cache.ts
  5. 13 124
      packages/atlas/src/components/AllNftSection/AllNftSection.tsx
  6. 14 1
      packages/atlas/src/components/JoyTokenIcon/JoyTokenIcon.tsx
  7. 2 8
      packages/atlas/src/components/NumberFormat/NumberFormat.tsx
  8. 1 1
      packages/atlas/src/components/Tabs/Tabs.tsx
  9. 14 6
      packages/atlas/src/components/_buttons/CopyAddressButton/CopyAddressButton.styles.ts
  10. 18 15
      packages/atlas/src/components/_buttons/CopyAddressButton/CopyAddressButton.tsx
  11. 22 9
      packages/atlas/src/components/_video/VideoPlayer/VideoOverlay.tsx
  12. 1 0
      packages/atlas/src/components/_video/VideoPlayer/VideoPlayer.tsx
  13. 72 54
      packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.tsx
  14. 23 7
      packages/atlas/src/config/configSchema.ts
  15. 1 1
      packages/atlas/src/config/routes.ts
  16. 140 0
      packages/atlas/src/hooks/useNftSectionFilters.tsx
  17. 3 1
      packages/atlas/src/index.html
  18. 1 1
      packages/atlas/src/providers/segmentAnalytics/segment.provider.tsx
  19. 1 1
      packages/atlas/src/providers/uploads/uploads.hooks.ts
  20. 19 1
      packages/atlas/src/views/global/YppLandingView/YppRewardSection.styles.ts
  21. 42 5
      packages/atlas/src/views/global/YppLandingView/YppRewardSection.tsx
  22. 1 0
      packages/atlas/src/views/studio/YppDashboard/YppDashboard.config.tsx
  23. 10 3
      packages/atlas/src/views/studio/YppDashboard/tabs/YppDashboardMainTab.tsx
  24. 9 9
      packages/atlas/src/views/viewer/MemberView/MemberActivity.tsx
  25. 0 128
      packages/atlas/src/views/viewer/MemberView/MemberNFTs.tsx
  26. 184 110
      packages/atlas/src/views/viewer/MemberView/MemberView.tsx
  27. 2 0
      packages/atlas/vite.config.ts

+ 10 - 5
packages/atlas/atlas.config.yml

@@ -59,7 +59,8 @@ features:
       - title: Sign Up to YouTube Partner Program
         showInDashboard: false # Optional. If false the reward will be shown only on YouTube partner program landing page. Default true
         shortDescription: Connect your YouTube channels via a simple step-by-step flow and get your first reward.
-        baseAmount: 5000 # Base amount that will be multiplied by tier multiplier
+        baseAmount: null # Base amount that will be multiplied by tier multiplier
+        baseUsdAmount: 20
       - title: Sync videos from your YouTube channel
         shortDescription: Opt in to auto-sync feature upon sign up and all YouTube content will get uploaded to $VITE_APP_NAME automatically. Get paid for every new video synced to your $VITE_APP_NAME channel.
         stepsDescription: Publishing your existing and new content with $VITE_APP_NAME is the fastest way to earn more JOY tokens.
@@ -68,7 +69,8 @@ features:
           - Wait till your videos get synced to your $VITE_APP_NAME channel.
           - Publish new videos to your YouTube channel.
           - Get rewarded for every new video synced to $VITE_APP_NAME.
-        baseAmount: 300
+        baseAmount: null
+        baseUsdAmount: 3
       - title: Refer another YouTube creator
         shortDescription: Get JOY for every new creator who signs up to YPP program using your referral link. Referrals multiplier depends on the popularity tier of the channel signed up using referral link.
         stepsDescription: Earn when another YouTube creator signs up to the program by using your your referral link.
@@ -77,9 +79,10 @@ features:
           - Send it to as many Web3 YouTube creators as you want.
           - Get rewarded for every new successful sign up, that uses your referral link. Referral reward depends on their popularity tier.
           - If signed up without the link they can simply add your channel name to the referral field in the registration flow.
-        baseAmount:
-          min: 1000
-          max: 5000
+        baseAmount: null
+        baseUsdAmount:
+          min: 10
+          max: 50
         actionButtonText: Get referral link
         actionButtonAction: copyReferral
     widgets: # Widgets on Ypp landing page
@@ -465,6 +468,8 @@ analytics:
     id: '$VITE_USERSNAP_ID'
   GA: # Google Analytics
     id: '$VITE_GA_ID'
+  optimize:
+    id: '$VITE_OPTIMIZE_ID'
   segment: # Segment Analytics
     id: '$VITE_SEGMENT_ID'
 

+ 16 - 0
packages/atlas/plugins/index.ts

@@ -118,3 +118,19 @@ export const EmbeddedFallbackPlugin: PluginOption = {
     })
   },
 }
+
+export const OptimizePlugin: PluginOption = {
+  name: 'optimize-init-plugin',
+  transformIndexHtml: {
+    enforce: 'pre',
+    transform: (html) => {
+      const optimizeEnv = 'VITE_OPTIMIZE_ID'
+      const optimizeId = process.env[optimizeEnv] || loadEnv('production', path.join(process.cwd(), 'src'))[optimizeEnv]
+      const optimizeScript = optimizeId
+        ? `<script src="https://www.googleoptimize.com/optimize.js?id=${optimizeId}"></script>`
+        : ''
+
+      return html.replace('<optimize-script />', optimizeScript)
+    },
+  },
+}

+ 10 - 1
packages/atlas/src/.env

@@ -5,14 +5,16 @@ VITE_ENV=development
 VITE_ENV_SELECTION_ENABLED=true
 # default env in environments admin modal. Can be production, development, next or local. If not provided, VITE_ENV will be used
 VITE_DEFAULT_DATA_ENV=
-# forces maintenance screen. Set to true if Orion service is unavailable for a longer time 
+# forces maintenance screen. Set to true if Orion service is unavailable for a longer time
 VITE_FORCE_MAINTENANCE=
 
 # App configuration
 VITE_APP_ID=4414-2
 VITE_APP_NAME=Atlas
 
+
 VITE_AVATAR_SERVICE_URL=https://atlas-services.joystream.org/avatars
+VITE_ASSET_LOGS_URL=
 VITE_GEOLOCATION_SERVICE_URL=https://geolocation.joystream.org
 VITE_HCAPTCHA_SITE_KEY=41cae189-7676-4f6b-aa56-635be26d3ceb
 VITE_GOOGLE_CONSOLE_CLIENT_ID=
@@ -24,6 +26,13 @@ VITE_GOOGLE_CONSOLE_CLIENT_ID=246331758613-rc1psegmsr9l4e33nqu8rre3gno5dsca.apps
 VITE_YOUTUBE_SYNC_API_URL=https://52.204.147.11.nip.io
 VITE_YOUTUBE_COLLABORATOR_MEMBER_ID=18
 
+# Analytics tools
+VITE_GA_ID=
+VITE_SEGMENT_ID=
+VITE_SENTRY_DSN=
+VITE_OPTIMIZE_ID=
+VITE_USERSNAP_ID=
+
 # Production env URLs
 VITE_PRODUCTION_ORION_URL=https://orion.joystream.org/graphql
 VITE_PRODUCTION_QUERY_NODE_SUBSCRIPTION_URL=wss://orion.joystream.org/graphql

+ 3 - 1
packages/atlas/src/api/client/cache.ts

@@ -34,7 +34,9 @@ const getVideoKeyArgs = (
   const idIn = args?.where?.id_in || []
   const isPublic = args?.where?.isPublic_eq ?? ''
   const createdAtGte = args?.where?.createdAt_gte ? JSON.stringify(args.where.createdAt_gte) : ''
+  const createdAtGt = args?.where?.createdAt_gt ? JSON.stringify(args.where.createdAt_gt) : ''
   const createdAtLte = args?.where?.createdAt_lte ? JSON.stringify(args.where.createdAt_lte) : ''
+  const createdAtLt = args?.where?.createdAt_lt ? JSON.stringify(args.where.createdAt_lt) : ''
   const durationGte = args?.where?.duration_gte || ''
   const durationLte = args?.where?.duration_gte || ''
   const titleContains = args?.where?.title_contains || ''
@@ -48,7 +50,7 @@ const getVideoKeyArgs = (
     return `${createdAtGte}:${channel}`
   }
 
-  return `${onlyCount}:${channel}:${category}:${nft}:${language}:${createdAtGte}:${createdAtLte}:${isPublic}:${idEq}:${idIn}:${sorting}:${durationGte}:${durationLte}:${titleContains}:${titleContainsInsensitive}:${offset}`
+  return `${onlyCount}:${channel}:${category}:${nft}:${language}:${createdAtGte}:${createdAtLte}:${isPublic}:${idEq}:${idIn}:${sorting}:${durationGte}:${durationLte}:${titleContains}:${titleContainsInsensitive}:${offset}:${createdAtGt}:${createdAtLt}`
 }
 
 const getNftKeyArgs = (

+ 13 - 124
packages/atlas/src/components/AllNftSection/AllNftSection.tsx

@@ -1,140 +1,29 @@
 import styled from '@emotion/styled'
-import { useMemo, useState } from 'react'
 
-import { OwnedNftOrderByInput, OwnedNftWhereInput } from '@/api/queries/__generated__/baseTypes.generated'
-import { SvgActionSell, SvgActionSettings, SvgActionShoppingCart } from '@/assets/icons'
 import { EmptyFallback } from '@/components/EmptyFallback'
-import { FilterButtonOption, SectionFilter } from '@/components/FilterButton'
 import { Section } from '@/components/Section/Section'
 import { Button } from '@/components/_buttons/Button'
 import { NftTileViewer } from '@/components/_nft/NftTileViewer'
-import { publicVideoFilter } from '@/config/contentFilter'
 import { useInfiniteNftsGrid } from '@/hooks/useInfiniteNftsGrid'
 import { useMediaMatch } from '@/hooks/useMediaMatch'
-import { tokenNumberToHapiBn } from '@/joystream-lib/utils'
+import { SORTING_FILTERS, useNftSectionFilters } from '@/hooks/useNftSectionFilters'
 import { DEFAULT_NFTS_GRID } from '@/styles'
 import { InfiniteLoadingOffsets } from '@/utils/loading.contants'
 
 import { NumberFormat } from '../NumberFormat'
 
-const NFT_STATUSES: FilterButtonOption[] = [
-  {
-    value: 'AuctionTypeEnglish',
-    selected: false,
-    applied: false,
-    label: 'Timed auction',
-  },
-  {
-    value: 'AuctionTypeOpen',
-    selected: false,
-    applied: false,
-    label: 'Open auction',
-  },
-  {
-    value: 'TransactionalStatusBuyNow',
-    selected: false,
-    applied: false,
-    label: 'Fixed price',
-  },
-  {
-    value: 'TransactionalStatusIdle',
-    selected: false,
-    applied: false,
-    label: 'Not for sale',
-  },
-]
-
-const OTHER: FilterButtonOption[] = [
-  { label: 'Exclude paid promotional materials', selected: false, applied: false, value: 'promotional' },
-  { label: 'Exclude mature content rating', selected: false, applied: false, value: 'mature' },
-]
-
-const FILTERS: SectionFilter[] = [
-  {
-    name: 'price',
-    type: 'range',
-    label: 'Last price',
-    icon: <SvgActionSell />,
-    range: { min: undefined, max: undefined },
-  },
-  {
-    name: 'status',
-    label: 'Status',
-    icon: <SvgActionShoppingCart />,
-    type: 'checkbox',
-    options: NFT_STATUSES,
-  },
-  { name: 'other', type: 'checkbox', options: OTHER, label: 'Other', icon: <SvgActionSettings /> },
-]
-
-const sortingOptions = [
-  {
-    label: 'Newest',
-    value: OwnedNftOrderByInput.CreatedAtDesc,
-  },
-  {
-    label: 'Oldest',
-    value: OwnedNftOrderByInput.CreatedAtAsc,
-  },
-]
-
 export const AllNftSection = () => {
-  const [filters, setFilters] = useState<SectionFilter[]>(FILTERS)
-  const [hasAppliedFilters, setHasAppliedFilters] = useState(false)
-  const [order, setOrder] = useState<OwnedNftOrderByInput>(OwnedNftOrderByInput.CreatedAtDesc)
   const smMatch = useMediaMatch('sm')
-  const mappedFilters = useMemo((): OwnedNftWhereInput => {
-    const mappedStatus =
-      filters
-        .find((filter) => filter.name === 'status')
-        ?.options?.filter((option) => option.applied)
-        .map((option) => {
-          if (['AuctionTypeOpen', 'AuctionTypeEnglish'].includes(option.value)) {
-            return {
-              auction: {
-                auctionType: {
-                  isTypeOf_eq: option.value,
-                },
-              },
-            }
-          }
-
-          return { isTypeOf_eq: option.value }
-        }, [] as OwnedNftWhereInput['transactionalStatus'][]) ?? []
-    const otherFilters = filters.find((filter) => filter.name === 'other')
-    const isMatureExcluded = otherFilters?.options?.some((option) => option.value === 'mature' && option.applied)
-    const isPromotionalExcluded = otherFilters?.options?.some(
-      (option) => option.value === 'promotional' && option.applied
-    )
-    const priceFilter = filters.find((filter) => filter.name === 'price')
-    const minPrice = priceFilter?.range?.appliedMin
-    const maxPrice = priceFilter?.range?.appliedMax
-
-    setHasAppliedFilters(
-      Boolean(minPrice || maxPrice || isPromotionalExcluded || isMatureExcluded || mappedStatus.length)
-    )
-
-    const commonFilters = {
-      lastSalePrice_gte: minPrice ? tokenNumberToHapiBn(minPrice).toString() : undefined,
-      lastSalePrice_lte: maxPrice ? tokenNumberToHapiBn(maxPrice).toString() : undefined,
-      video: {
-        ...publicVideoFilter,
-        ...(isMatureExcluded ? { isExcluded_eq: false } : {}),
-        ...(isPromotionalExcluded ? { hasMarketing_eq: false } : {}),
-      },
-    }
-    return {
-      OR: mappedStatus.length
-        ? mappedStatus.map((transactionalStatus) => ({
-            ...commonFilters,
-            transactionalStatus,
-          }))
-        : [commonFilters],
-    }
-  }, [filters])
+  const {
+    ownedNftWhereInput,
+    order,
+    hasAppliedFilters,
+    rawFilters,
+    actions: { onApplyFilters, setOrder, clearFilters },
+  } = useNftSectionFilters()
 
   const { columns, fetchMore, pageInfo, tiles, totalCount } = useInfiniteNftsGrid({
-    where: mappedFilters,
+    where: ownedNftWhereInput,
     orderBy: order,
   })
 
@@ -142,7 +31,7 @@ export const AllNftSection = () => {
   return (
     <Section
       headerProps={{
-        onApplyFilters: setFilters,
+        onApplyFilters,
         start: {
           type: 'title',
           title: 'All NFTs',
@@ -151,12 +40,12 @@ export const AllNftSection = () => {
               <NumberFormat value={totalCount} as="p" variant={smMatch ? 'h500' : 'h400'} color="colorTextMuted" />
             ) : undefined,
         },
-        filters,
+        filters: rawFilters,
         sort: {
           type: 'toggle-button',
           toggleButtonOptionTypeProps: {
             type: 'options',
-            options: sortingOptions,
+            options: SORTING_FILTERS,
             value: order,
             onChange: setOrder,
           },
@@ -174,7 +63,7 @@ export const AllNftSection = () => {
                   subtitle="Please, try changing your filtering criteria."
                   button={
                     hasAppliedFilters && (
-                      <Button variant="secondary" onClick={() => setFilters(FILTERS)}>
+                      <Button variant="secondary" onClick={() => clearFilters()}>
                         Clear all filters
                       </Button>
                     )

+ 14 - 1
packages/atlas/src/components/JoyTokenIcon/JoyTokenIcon.tsx

@@ -18,8 +18,10 @@ import {
   SvgJoyTokenSilver48,
 } from '@/assets/icons'
 import { Tooltip } from '@/components/Tooltip'
+import { TooltipText } from '@/components/Tooltip/Tooltip.styles'
 import { atlasConfig } from '@/config'
 import { cVar } from '@/styles'
+import { Anchor } from '@/views/global/YppLandingView/YppAuthorizationModal/YppAuthorizationModal.styles'
 
 type JoyTokenIconVariant = 'primary' | 'silver' | 'regular' | 'gray'
 type JoyTokenIconSize = 16 | 24 | 32 | 48
@@ -70,8 +72,19 @@ export const JoyTokenIcon: FC<JoyTokenIconProps> = ({
   return (
     <>
       <Tooltip
+        interactive
         hidden={withoutInformationTooltip}
-        text={`${atlasConfig.joystream.tokenTicker} token is a native crypto asset of Joystream blockchain. It is used for platform governance, purchasing NFTs, trading creator tokens, and covering blockchain processing fees.`}
+        customContent={
+          <TooltipText as="span" variant="t100">
+            {atlasConfig.joystream.tokenTicker} token is a native crypto asset of Joystream blockchain. It is used for
+            platform governance, purchasing NFTs, trading creator tokens, and covering blockchain processing fees. They
+            are listed on{' '}
+            <Anchor href="https://www.mexc.com/exchange/JOYSTREAM_USDT" target="__blank">
+              MEXC
+            </Anchor>{' '}
+            exchange under JOYSTREAM ticker
+          </TooltipText>
+        }
         multiline
         reference={ref.current}
         delay={1000}

+ 2 - 8
packages/atlas/src/components/NumberFormat/NumberFormat.tsx

@@ -100,15 +100,10 @@ const numberCompactFormatter = new Intl.NumberFormat('en-US', {
   maximumFractionDigits: 2,
 })
 
-const dollarFormatter = new Intl.NumberFormat('en-US', {
-  style: 'currency',
-  currency: 'USD',
-})
-
 const dollarSmallNumberFormatter = new Intl.NumberFormat('en-US', {
   style: 'currency',
   currency: 'USD',
-  minimumSignificantDigits: 2,
+  minimumSignificantDigits: 1,
   maximumSignificantDigits: 3,
 })
 
@@ -116,5 +111,4 @@ const formatNumberShort = (num: number): string => {
   return numberCompactFormatter.format(num).replaceAll(',', ' ')
 }
 
-const formatDollars = (num: number) =>
-  (num >= 1 ? dollarFormatter.format(num) : dollarSmallNumberFormatter.format(num)).replaceAll(',', ' ')
+const formatDollars = (num: number) => dollarSmallNumberFormatter.format(num).replaceAll(',', ' ')

+ 1 - 1
packages/atlas/src/components/Tabs/Tabs.tsx

@@ -93,7 +93,7 @@ export const Tabs: FC<TabsProps> = memo(
                 data-badge={tab.badgeNumber}
               >
                 {tab.name}
-                {tab.pillText && <StyledPill size="small" label={tab.pillText} />}
+                {typeof tab.pillText !== 'undefined' && <StyledPill size="small" label={tab.pillText} />}
               </Text>
             </Tab>
           ))}

+ 14 - 6
packages/atlas/src/components/_buttons/CopyAddressButton/CopyAddressButton.styles.ts

@@ -2,14 +2,28 @@ import styled from '@emotion/styled'
 
 import { SvgActionCheck, SvgActionCopy } from '@/assets/icons'
 import { Text } from '@/components/Text'
+import { Tooltip } from '@/components/Tooltip'
 import { cVar, sizes } from '@/styles'
 
+export const StyledTooltip = styled(Tooltip)`
+  :hover {
+    button {
+      color: ${cVar('colorCoreNeutral50')};
+    }
+
+    path {
+      fill: ${cVar('colorCoreNeutral50')};
+    }
+  }
+`
+
 export const StyledText = styled(Text)`
   border: none;
   background: none;
   display: flex;
   align-items: center;
   cursor: pointer;
+  transition: ${cVar('animationTransitionFast')};
 `
 export const StyledSvgActionCopy = styled(SvgActionCopy)`
   margin-left: ${sizes(2)};
@@ -18,12 +32,6 @@ export const StyledSvgActionCopy = styled(SvgActionCopy)`
     fill: ${cVar('colorCoreNeutral300')};
     transition: ${cVar('animationTransitionFast')};
   }
-
-  :hover {
-    path {
-      fill: ${cVar('colorCoreNeutral50')};
-    }
-  }
 `
 export const StyledSvgActionCheck = styled(SvgActionCheck)`
   margin-left: ${sizes(2)};

+ 18 - 15
packages/atlas/src/components/_buttons/CopyAddressButton/CopyAddressButton.tsx

@@ -1,10 +1,9 @@
 import { FC, useState } from 'react'
 
-import { Tooltip } from '@/components/Tooltip'
 import { useClipboard } from '@/hooks/useClipboard'
 import { shortenString } from '@/utils/misc'
 
-import { StyledSvgActionCheck, StyledSvgActionCopy, StyledText } from './CopyAddressButton.styles'
+import { StyledSvgActionCheck, StyledSvgActionCopy, StyledText, StyledTooltip } from './CopyAddressButton.styles'
 
 export type CopyAddressButtonProps = {
   address: string
@@ -17,28 +16,32 @@ export const CopyAddressButton: FC<CopyAddressButtonProps> = ({ address, classNa
   const [copyButtonClicked, setCopyButtonClicked] = useState(false)
 
   const handleCopyAddress = () => {
-    if (!address) {
+    if (!address || copyButtonClicked) {
       return
     }
-    copyToClipboard(address, 'Account address copied to clipboard')
+    copyToClipboard(address)
     setCopyButtonClicked(true)
     setTimeout(() => {
       setCopyButtonClicked(false)
-    }, 3000)
+    }, 2_000)
   }
 
   return (
-    <StyledText
-      as="button"
-      variant={size === 'big' ? 't300' : 't100'}
-      color="colorText"
-      className={className}
-      onClick={handleCopyAddress}
+    <StyledTooltip
+      hideOnClick={false}
+      text={copyButtonClicked ? 'Copied!' : 'Copy account address'}
+      placement="top-start"
     >
-      {shortenString(address, 6, 4)}
-      <Tooltip text="Copy account address" placement="top">
+      <StyledText
+        as="button"
+        variant={size === 'big' ? 't300' : 't100'}
+        color="colorText"
+        className={className}
+        onClick={handleCopyAddress}
+      >
+        {shortenString(address, 6, 4)}
         {copyButtonClicked ? <StyledSvgActionCheck /> : <StyledSvgActionCopy />}
-      </Tooltip>
-    </StyledText>
+      </StyledText>
+    </StyledTooltip>
   )
 }

+ 22 - 9
packages/atlas/src/components/_video/VideoPlayer/VideoOverlay.tsx

@@ -3,7 +3,9 @@ import { FC, useEffect, useState } from 'react'
 import { CSSTransition, SwitchTransition } from 'react-transition-group'
 
 import { useBasicVideos } from '@/api/hooks/video'
+import { VideoOrderByInput, VideoWhereInput } from '@/api/queries/__generated__/baseTypes.generated'
 import { BasicVideoFieldsFragment } from '@/api/queries/__generated__/fragments.generated'
+import { publicVideoFilter } from '@/config/contentFilter'
 import { cVar, transitions } from '@/styles'
 import { getRandomIntInclusive } from '@/utils/number'
 
@@ -20,7 +22,9 @@ type VideoOverlayProps = {
   isPlayNextDisabled?: boolean
   playRandomVideoOnEnded?: boolean
   isMinimized?: boolean
+  currentVideoCreatedAt?: Date
 }
+
 export const VideoOverlay: FC<VideoOverlayProps> = ({
   playerState,
   onPlayAgain,
@@ -31,28 +35,37 @@ export const VideoOverlay: FC<VideoOverlayProps> = ({
   isPlayNextDisabled,
   isMinimized,
   playRandomVideoOnEnded = true,
+  currentVideoCreatedAt,
 }) => {
   const [randomNextVideo, setRandomNextVideo] = useState<BasicVideoFieldsFragment | null>(null)
-  const { videos } = useBasicVideos(
-    {
-      where: {
-        channel: {
-          id_eq: channelId,
-        },
+  const commonFiltersFactory = (where?: VideoWhereInput) => ({
+    limit: 1,
+    orderBy: VideoOrderByInput.ChannelCreatedAtAsc,
+    where: {
+      ...publicVideoFilter,
+      channel: {
+        id_eq: channelId,
       },
+      ...where,
     },
-    { context: { delay: 2000 } }
+  })
+  const { videos: newerVideos, loading: loadingNewestVideos } = useBasicVideos(
+    commonFiltersFactory({ createdAt_gt: currentVideoCreatedAt })
   )
+  const { videos: olderVideos } = useBasicVideos(commonFiltersFactory({ createdAt_lt: currentVideoCreatedAt }), {
+    skip: loadingNewestVideos || !!newerVideos?.length,
+  })
 
   useEffect(() => {
-    if (!videos?.length || videos.length <= 1) {
+    const videos = newerVideos?.length ? newerVideos : olderVideos
+    if (!videos?.length || videos.length === 0) {
       return
     }
     const filteredVideos = videos.filter((video) => video.id !== videoId)
     const randomNumber = getRandomIntInclusive(0, filteredVideos.length - 1)
 
     setRandomNextVideo(filteredVideos[randomNumber])
-  }, [videoId, videos])
+  }, [channelId, currentVideoCreatedAt, newerVideos, olderVideos, videoId])
 
   return (
     <SwitchTransition>

+ 1 - 0
packages/atlas/src/components/_video/VideoPlayer/VideoPlayer.tsx

@@ -886,6 +886,7 @@ const VideoPlayerComponent: ForwardRefRenderFunction<HTMLVideoElement, VideoPlay
         )}
         <VideoOverlay
           videoId={videoId}
+          currentVideoCreatedAt={video?.createdAt}
           isFullScreen={isFullScreen}
           isPlayNextDisabled={playNextDisabled}
           playerState={playerState}

+ 72 - 54
packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.tsx

@@ -51,8 +51,8 @@ export type BenefitCardProps = {
     onClick?: () => void
     to?: string
   }
-  joyAmount: JoyAmountNumber | JoyAmountRange
-  dollarAmount?: DollarAmountNumber | DollarAmountRange
+  joyAmount: JoyAmountNumber | JoyAmountRange | null
+  dollarAmount?: DollarAmountNumber | DollarAmountRange | null
   className?: string
 }
 
@@ -71,83 +71,101 @@ export const BenefitCard: FC<BenefitCardProps> = ({
 
   const RewardAmount = () => {
     const isJoyTokenIconVisible =
-      (!smMatch && !isFullVariant && dollarAmount) || (!isFullVariant && dollarAmount) || isFullVariant
+      !(dollarAmount && !joyAmount) &&
+      ((!smMatch && !isFullVariant && dollarAmount) || (!isFullVariant && dollarAmount) || isFullVariant)
     return (
       <RewardWrapper isCompact={!isFullVariant}>
-        {!!dollarAmount && dollarAmount.type === 'number' && (
-          <NumberFormat
-            as="p"
-            format="dollar"
-            variant={!smMatch ? 'h500' : 'h600'}
-            value={dollarAmount.amount}
-            margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
-          />
-        )}
-        {!!dollarAmount && dollarAmount.type === 'range' && (
-          <>
-            <NumberFormat
-              as="p"
-              format="dollar"
-              variant={!smMatch ? 'h500' : 'h600'}
-              value={dollarAmount.min}
-              margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
-            />
+        <TokenRewardWrapper>
+          {!!dollarAmount && !isFullVariant && (
             <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
-              -
+              +
             </Text>
+          )}
+          {!!dollarAmount && dollarAmount.type === 'number' && (
             <NumberFormat
               as="p"
               format="dollar"
               variant={!smMatch ? 'h500' : 'h600'}
-              value={dollarAmount.max}
+              value={dollarAmount.amount}
               margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
             />
-          </>
-        )}
-        <TokenRewardWrapper>
-          {isJoyTokenIconVisible ? (
-            <StyledJoyTokenIcon variant="silver" size={smMatch && !dollarAmount ? 24 : 16} />
-          ) : (
-            <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
-              +
-            </Text>
           )}
-          {joyAmount.type === 'number' && (
-            <NumberFormat
-              as="span"
-              format="short"
-              color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
-              variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
-              value={joyAmount.amount}
-            />
-          )}
-          {joyAmount.type === 'range' && (
+          {!!dollarAmount && dollarAmount.type === 'range' && (
             <>
               <NumberFormat
-                as="span"
-                format="short"
-                color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
-                variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
-                value={joyAmount.min}
+                as="p"
+                format="dollar"
+                variant={!smMatch ? 'h500' : 'h600'}
+                value={dollarAmount.min}
+                margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
               />
               <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
                 -
               </Text>
               <NumberFormat
-                as="span"
-                format="short"
-                color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
-                variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
-                value={joyAmount.max}
+                as="p"
+                format="dollar"
+                variant={!smMatch ? 'h500' : 'h600'}
+                value={dollarAmount.max}
+                margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
               />
             </>
           )}
-          {!dollarAmount && !isFullVariant && (
+          {!!dollarAmount && !joyAmount && (
             <Text as="span" variant={smMatch ? 'h400' : 'h300'} color="colorText" margin={{ left: 1 }}>
-              {atlasConfig.joystream.tokenTicker}
+              USD
             </Text>
           )}
         </TokenRewardWrapper>
+        {joyAmount && (
+          <>
+            <TokenRewardWrapper>
+              {isJoyTokenIconVisible ? (
+                <StyledJoyTokenIcon variant="silver" size={smMatch && !dollarAmount ? 24 : 16} />
+              ) : (
+                <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
+                  +
+                </Text>
+              )}
+              {joyAmount.type === 'number' && (
+                <NumberFormat
+                  as="span"
+                  format="short"
+                  color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+                  variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+                  value={joyAmount.amount}
+                />
+              )}
+              {joyAmount.type === 'range' && (
+                <>
+                  <NumberFormat
+                    as="span"
+                    format="short"
+                    color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+                    variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+                    value={joyAmount.min}
+                  />
+                  <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
+                    -
+                  </Text>
+                  <NumberFormat
+                    as="span"
+                    format="short"
+                    color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+                    variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+                    value={joyAmount.max}
+                  />
+                </>
+              )}
+              {(!dollarAmount && !isFullVariant) ||
+                (dollarAmount && !joyAmount && (
+                  <Text as="span" variant={smMatch ? 'h400' : 'h300'} color="colorText" margin={{ left: 1 }}>
+                    {dollarAmount ? 'USD' : atlasConfig.joystream.tokenTicker}
+                  </Text>
+                ))}
+            </TokenRewardWrapper>
+          </>
+        )}
       </RewardWrapper>
     )
   }

+ 23 - 7
packages/atlas/src/config/configSchema.ts

@@ -62,13 +62,24 @@ export const configSchema = z.object({
             shortDescription: z.string(),
             stepsDescription: z.string().optional(),
             steps: z.array(z.string()).optional(),
-            baseAmount: z.union([
-              z.number(),
-              z.object({
-                min: z.number(),
-                max: z.number(),
-              }),
-            ]),
+            baseAmount: z
+              .union([
+                z.number(),
+                z.object({
+                  min: z.number(),
+                  max: z.number(),
+                }),
+              ])
+              .nullable(),
+            baseUsdAmount: z
+              .union([
+                z.number(),
+                z.object({
+                  min: z.number(),
+                  max: z.number(),
+                }),
+              ])
+              .nullable(),
             actionButtonText: z.string().optional(),
             actionButtonAction: z
               .string()
@@ -151,6 +162,11 @@ export const configSchema = z.object({
         id: z.string().nullable(),
       })
       .nullable(),
+    optimize: z
+      .object({
+        id: z.string().nullable(),
+      })
+      .nullable(),
     segment: z
       .object({
         id: z.string().nullable(),

+ 1 - 1
packages/atlas/src/config/routes.ts

@@ -82,7 +82,7 @@ export const absoluteRoutes = Object.entries(BASE_PATHS).reduce((absoluteRoutesA
   return absoluteRoutesAcc
 }, {} as typeof relativeRoutes)
 
-export type MemberTabs = 'NFTs owned' | 'Activity' | 'About'
+export type MemberTabs = 'NFTs' | 'Activity' | 'About'
 
 export const QUERY_PARAMS = {
   SEARCH: 'query',

+ 140 - 0
packages/atlas/src/hooks/useNftSectionFilters.tsx

@@ -0,0 +1,140 @@
+import { useCallback, useMemo, useState } from 'react'
+
+import { OwnedNftOrderByInput, OwnedNftWhereInput } from '@/api/queries/__generated__/baseTypes.generated'
+import { SvgActionSell, SvgActionSettings, SvgActionShoppingCart } from '@/assets/icons'
+import { FilterButtonOption, SectionFilter } from '@/components/FilterButton'
+import { publicVideoFilter } from '@/config/contentFilter'
+import { tokenNumberToHapiBn } from '@/joystream-lib/utils'
+
+export const NFT_STATUSES: FilterButtonOption[] = [
+  {
+    value: 'AuctionTypeEnglish',
+    selected: false,
+    applied: false,
+    label: 'Timed auction',
+  },
+  {
+    value: 'AuctionTypeOpen',
+    selected: false,
+    applied: false,
+    label: 'Open auction',
+  },
+  {
+    value: 'TransactionalStatusBuyNow',
+    selected: false,
+    applied: false,
+    label: 'Fixed price',
+  },
+  {
+    value: 'TransactionalStatusIdle',
+    selected: false,
+    applied: false,
+    label: 'Not for sale',
+  },
+]
+
+export const OTHER_FILTERS: FilterButtonOption[] = [
+  { label: 'Exclude paid promotional materials', selected: false, applied: false, value: 'promotional' },
+  { label: 'Exclude mature content rating', selected: false, applied: false, value: 'mature' },
+]
+
+export const FILTERS: SectionFilter[] = [
+  {
+    name: 'price',
+    type: 'range',
+    label: 'Last price',
+    icon: <SvgActionSell />,
+    range: { min: undefined, max: undefined },
+  },
+  {
+    name: 'status',
+    label: 'Status',
+    icon: <SvgActionShoppingCart />,
+    type: 'checkbox',
+    options: NFT_STATUSES,
+  },
+  { name: 'other', type: 'checkbox', options: OTHER_FILTERS, label: 'Other', icon: <SvgActionSettings /> },
+]
+
+export const SORTING_FILTERS = [
+  {
+    label: 'Newest',
+    value: OwnedNftOrderByInput.CreatedAtDesc,
+  },
+  {
+    label: 'Oldest',
+    value: OwnedNftOrderByInput.CreatedAtAsc,
+  },
+]
+
+export const useNftSectionFilters = () => {
+  const [filters, setFilters] = useState<SectionFilter[]>(FILTERS)
+  const [hasAppliedFilters, setHasAppliedFilters] = useState(false)
+  const [order, setOrder] = useState<OwnedNftOrderByInput>(OwnedNftOrderByInput.CreatedAtDesc)
+
+  const mappedFilters = useMemo((): OwnedNftWhereInput => {
+    const mappedStatus =
+      filters
+        .find((filter) => filter.name === 'status')
+        ?.options?.filter((option) => option.applied)
+        .map((option) => {
+          if (['AuctionTypeOpen', 'AuctionTypeEnglish'].includes(option.value)) {
+            return {
+              auction: {
+                auctionType: {
+                  isTypeOf_eq: option.value,
+                },
+              },
+            }
+          }
+
+          return { isTypeOf_eq: option.value }
+        }, [] as OwnedNftWhereInput['transactionalStatus'][]) ?? []
+    const otherFilters = filters.find((filter) => filter.name === 'other')
+    const isMatureExcluded = otherFilters?.options?.some((option) => option.value === 'mature' && option.applied)
+    const isPromotionalExcluded = otherFilters?.options?.some(
+      (option) => option.value === 'promotional' && option.applied
+    )
+    const priceFilter = filters.find((filter) => filter.name === 'price')
+    const minPrice = priceFilter?.range?.appliedMin
+    const maxPrice = priceFilter?.range?.appliedMax
+
+    setHasAppliedFilters(
+      Boolean(minPrice || maxPrice || isPromotionalExcluded || isMatureExcluded || mappedStatus.length)
+    )
+
+    const commonFilters = {
+      lastSalePrice_gte: minPrice ? tokenNumberToHapiBn(minPrice).toString() : undefined,
+      lastSalePrice_lte: maxPrice ? tokenNumberToHapiBn(maxPrice).toString() : undefined,
+      video: {
+        ...publicVideoFilter,
+        ...(isMatureExcluded ? { isExcluded_eq: false } : {}),
+        ...(isPromotionalExcluded ? { hasMarketing_eq: false } : {}),
+      },
+    }
+    return {
+      OR: mappedStatus.length
+        ? mappedStatus.map((transactionalStatus) => ({
+            ...commonFilters,
+            transactionalStatus,
+          }))
+        : [commonFilters],
+    }
+  }, [filters])
+
+  const clearFilters = useCallback(() => {
+    setFilters(FILTERS)
+  }, [])
+
+  return {
+    ownedNftWhereInput: mappedFilters,
+    rawFilters: filters,
+    order,
+    hasAppliedFilters,
+    actions: {
+      setOrder,
+      onApplyFilters: setFilters,
+      clearFilters,
+    },
+  }
+}

+ 3 - 1
packages/atlas/src/index.html

@@ -4,6 +4,9 @@
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
 
+    <!--  The following node will be replaced by Optimize init script during Vite's build step (see ../plugins)  -->
+    <optimize-script />
+
     <!--  The following node will be replaced by meta tags (including <title>) generated during Vite's build step (see ../plugins)  -->
     <meta-tags />
 
@@ -20,7 +23,6 @@
       href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@600;700&family=Roboto:wght@400&family=Inter:wght@400;500;600;700&display=swap"
       rel="stylesheet"
     />
-    <script src="https://www.googleoptimize.com/optimize.js?id=%VITE_OPTIMIZE_ID%"></script>
   </head>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>

+ 1 - 1
packages/atlas/src/providers/segmentAnalytics/segment.provider.tsx

@@ -23,7 +23,7 @@ export const SegmentAnalyticsProvider: FC<AnalyticsProviderProps> = ({ children
   const writeKey = (analyticsEnabled && atlasConfig.analytics.segment?.id) || ''
 
   const segmentAnalytics: AnalyticsContextProps = useMemo(
-    () => ({ analytics: AnalyticsBrowser.load({ writeKey }) }),
+    () => ({ analytics: writeKey ? AnalyticsBrowser.load({ writeKey }) : new AnalyticsBrowser() }),
     [writeKey]
   )
 

+ 1 - 1
packages/atlas/src/providers/uploads/uploads.hooks.ts

@@ -179,7 +179,7 @@ export const useStartFileUpload = () => {
         const axiosError = e as AxiosError
         const networkFailure =
           axiosError.isAxiosError &&
-          (!axiosError.response?.status || (axiosError.response.status < 400 && axiosError.response.status >= 500))
+          (!axiosError.response?.status || (axiosError.response.status >= 400 && axiosError.response.status <= 500))
         if (networkFailure) {
           markStorageOperatorFailed(uploadOperator.id)
         }

+ 19 - 1
packages/atlas/src/views/global/YppLandingView/YppRewardSection.styles.ts

@@ -2,7 +2,8 @@ import styled from '@emotion/styled'
 
 import { GridItem } from '@/components/LayoutGrid'
 import { Button } from '@/components/_buttons/Button'
-import { sizes } from '@/styles'
+import { cVar, sizes } from '@/styles'
+import { Anchor } from '@/views/global/YppLandingView/YppAuthorizationModal/YppAuthorizationModal.styles'
 
 export const BenefitsCardButton = styled(Button)`
   border-radius: 999px;
@@ -27,3 +28,20 @@ export const BenefitsCardsContainerGridItem = styled(GridItem)`
   display: grid;
   gap: ${sizes(4)};
 `
+
+export const ColorAnchor = styled(Anchor)`
+  color: ${cVar('colorTextPrimary')};
+`
+
+export const RewardsSubtitleGridItem = styled(GridItem)`
+  display: grid;
+  gap: ${sizes(4)};
+  margin-top: ${sizes(8)};
+`
+
+export const RewardsSubtitleWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+`

+ 42 - 5
packages/atlas/src/views/global/YppLandingView/YppRewardSection.tsx

@@ -1,8 +1,10 @@
-import { FC, useState } from 'react'
+import { FC, useRef, useState } from 'react'
 
+import { Information } from '@/components/Information'
 import { LayoutGrid } from '@/components/LayoutGrid'
 import { NumberFormat } from '@/components/NumberFormat'
 import { Text } from '@/components/Text'
+import { TooltipText } from '@/components/Tooltip/Tooltip.styles'
 import { BenefitCard } from '@/components/_ypp/BenefitCard'
 import { atlasConfig } from '@/config'
 import { useMediaMatch } from '@/hooks/useMediaMatch'
@@ -17,13 +19,17 @@ import {
   BenefitsCardButton,
   BenefitsCardsButtonsGroup,
   BenefitsCardsContainerGridItem,
+  ColorAnchor,
+  RewardsSubtitleGridItem,
+  RewardsSubtitleWrapper,
 } from './YppRewardSection.styles'
 
 export const YppRewardSection: FC = () => {
   const mdMatch = useMediaMatch('md')
   const tiers = atlasConfig.features.ypp.tiersDefinition?.tiers
   const rewards = atlasConfig.features.ypp.rewards
-  const [rewardMultiplier, setRewardMultiplier] = useState<number>(tiers ? tiers[0].multiplier : 1)
+  const [rewardMultiplier, setRewardMultiplier] = useState<number>(tiers ? tiers[tiers.length - 1].multiplier : 1)
+  const ref = useRef<HTMLDivElement>(null)
 
   if (!rewards?.length) {
     return null
@@ -137,21 +143,52 @@ export const YppRewardSection: FC = () => {
         <LayoutGrid data-aos="fade-up" data-aos-delay="200" data-aos-offset="80" data-aos-easing="atlas-easing">
           <BenefitsCardsContainerGridItem colStart={{ lg: 2 }} colSpan={{ base: 12, lg: 10 }}>
             {rewards.map((reward) => {
-              const joyAmount =
-                typeof reward.baseAmount === 'number'
+              const rewardAmount = reward.baseAmount
+                ? typeof reward.baseAmount === 'number'
                   ? { type: 'number' as const, amount: reward.baseAmount * rewardMultiplier }
                   : { type: 'range' as const, min: reward.baseAmount.min, max: reward.baseAmount.max }
+                : null
+              const rewardAmountUsd = reward.baseUsdAmount
+                ? typeof reward.baseUsdAmount === 'number'
+                  ? { type: 'number' as const, amount: reward.baseUsdAmount * rewardMultiplier }
+                  : { type: 'range' as const, min: reward.baseUsdAmount.min, max: reward.baseUsdAmount.max }
+                : null
               return (
                 <BenefitCard
                   key={reward.title}
                   title={reward.title}
-                  joyAmount={joyAmount}
+                  joyAmount={rewardAmount}
+                  dollarAmount={rewardAmountUsd}
                   variant="compact"
                   description={reward.shortDescription}
                 />
               )
             })}
           </BenefitsCardsContainerGridItem>
+          <RewardsSubtitleGridItem colStart={{ base: 6 }} colSpan={{ base: 7, lg: 6 }}>
+            <RewardsSubtitleWrapper>
+              <Text variant="t200" as="p" color="colorText" margin={{ right: 1 }}>
+                Payments are made in {atlasConfig.joystream.tokenTicker} tokens
+              </Text>
+              <Information
+                interactive
+                customContent={
+                  <TooltipText as="span" variant="t100">
+                    {atlasConfig.joystream.tokenTicker} token is a native crypto asset of Joystream blockchain. It is
+                    used for platform governance, purchasing NFTs, trading creator tokens, and covering blockchain
+                    processing fees. They are listed on{' '}
+                    <ColorAnchor href="https://www.mexc.com/exchange/JOYSTREAM_USDT" target="__blank">
+                      MEXC
+                    </ColorAnchor>{' '}
+                    exchange under "JOYSTREAM" ticker.
+                  </TooltipText>
+                }
+                multiline
+                reference={ref.current}
+                delay={1000}
+              />
+            </RewardsSubtitleWrapper>
+          </RewardsSubtitleGridItem>
         </LayoutGrid>
       </StyledLimitedWidthContainer>
     </BackgroundContainer>

+ 1 - 0
packages/atlas/src/views/studio/YppDashboard/YppDashboard.config.tsx

@@ -86,6 +86,7 @@ export const REWARDS =
       description: reward.stepsDescription,
       steps: reward.steps,
       joyAmount: reward.baseAmount,
+      usdAmount: reward.baseUsdAmount,
       ...(reward.actionButtonText
         ? {
             actionButton: {

+ 10 - 3
packages/atlas/src/views/studio/YppDashboard/tabs/YppDashboardMainTab.tsx

@@ -87,10 +87,16 @@ export const YppDashboardMainTab: FC<YppDashboardMainTabProps> = ({ currentTier
       )}
       <RewardsWrapper>
         {REWARDS?.map((reward) => {
-          const joyAmount =
-            typeof reward.joyAmount === 'number'
+          const rewardAmount = reward.joyAmount
+            ? typeof reward.joyAmount === 'number'
               ? { type: 'number' as const, amount: reward.joyAmount * multiplier }
               : { type: 'range' as const, min: reward.joyAmount.min, max: reward.joyAmount.max }
+            : null
+          const rewardAmountUsd = reward.usdAmount
+            ? typeof reward.usdAmount === 'number'
+              ? { type: 'number' as const, amount: reward.usdAmount * multiplier }
+              : { type: 'range' as const, min: reward.usdAmount.min, max: reward.usdAmount.max }
+            : null
           return (
             <BenefitCard
               key={reward.title}
@@ -116,7 +122,8 @@ export const YppDashboardMainTab: FC<YppDashboardMainTabProps> = ({ currentTier
                     }
                   : undefined
               }
-              joyAmount={joyAmount}
+              joyAmount={rewardAmount}
+              dollarAmount={rewardAmountUsd}
             />
           )
         })}

+ 9 - 9
packages/atlas/src/views/viewer/MemberView/MemberActivity.tsx

@@ -40,7 +40,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -53,7 +53,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -74,7 +74,7 @@ const getDescription = (activity: ActivitiesRecord) => {
           purchased NFT for <NumberFormat as="span" color="inherit" format="short" value={activity.price} withToken />{' '}
           from{' '}
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -85,7 +85,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -102,7 +102,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -114,7 +114,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -126,7 +126,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -138,7 +138,7 @@ const getDescription = (activity: ActivitiesRecord) => {
       return (
         <>
           <StyledLink
-            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs owned' })}
+            to={absoluteRoutes.viewer.member(fromHandle, { tab: 'NFTs' })}
             onClick={(e) => e.stopPropagation()}
           >
             {fromHandle}
@@ -171,7 +171,7 @@ export const MemberActivity: FC<MemberActivityProps> = ({
   return (
     <section>
       {!loading && items.length === 0 ? (
-        <EmptyFallback title="No activity" subtitle="Go out there and explore!" variant="small" />
+        <EmptyFallback title="No activity" subtitle="This member hasn’t done anything yet." variant="large" />
       ) : (
         <LayoutGrid>
           <GridItem colSpan={{ base: 12, sm: 8 }} rowStart={{ base: 2, sm: 1 }}>

+ 0 - 128
packages/atlas/src/views/viewer/MemberView/MemberNFTs.tsx

@@ -1,128 +0,0 @@
-import { FC, useEffect, useState } from 'react'
-import { useParams } from 'react-router'
-
-import { useNfts } from '@/api/hooks/nfts'
-import { OwnedNftOrderByInput, OwnedNftWhereInput } from '@/api/queries/__generated__/baseTypes.generated'
-import { FullNftFieldsFragment } from '@/api/queries/__generated__/fragments.generated'
-import { EmptyFallback } from '@/components/EmptyFallback'
-import { Grid } from '@/components/Grid'
-import { NftTileViewer } from '@/components/_nft/NftTileViewer'
-import { useVideoGridRows } from '@/hooks/useVideoGridRows'
-import { useUser } from '@/providers/user/user.hooks'
-import { createPlaceholderData } from '@/utils/data'
-
-import { StyledPagination } from './MemberView.styles'
-
-type MemberNFTsProps = {
-  owner?: boolean
-  isFiltersApplied?: boolean
-  sortBy?: OwnedNftOrderByInput
-  ownedNftWhereInput?: OwnedNftWhereInput
-  setNftCount?: (count: number) => void
-}
-
-const INITIAL_TILES_PER_ROW = 4
-
-const VIEWER_TIMESTAMP = new Date()
-
-export const MemberNFTs: FC<MemberNFTsProps> = ({
-  owner,
-  isFiltersApplied,
-  sortBy,
-  ownedNftWhereInput,
-  setNftCount,
-}) => {
-  const [tilesPerRow, setTilesPerRow] = useState(INITIAL_TILES_PER_ROW)
-  const nftRows = useVideoGridRows('main')
-  const tilesPerPage = nftRows * tilesPerRow
-  const handleOnResizeGrid = (sizes: number[]) => setTilesPerRow(sizes.length)
-
-  const { activeMembership } = useUser()
-  const { handle } = useParams()
-
-  const [currentPage, setCurrentPage] = useState(0)
-
-  const ownershipOr: OwnedNftWhereInput['OR'] = [
-    {
-      owner: {
-        isTypeOf_eq: 'NftOwnerChannel',
-        channel: {
-          ownerMember: {
-            handle_eq: handle,
-          },
-        },
-      },
-    },
-    {
-      owner: {
-        isTypeOf_eq: 'NftOwnerMember',
-        member: {
-          handle_eq: handle,
-        },
-      },
-    },
-  ]
-
-  const {
-    nfts,
-    loading,
-    totalCount: totalNftsCount,
-  } = useNfts({
-    variables: {
-      where: {
-        AND: [
-          { OR: ownershipOr },
-          ...(ownedNftWhereInput?.OR?.length ? [{ OR: ownedNftWhereInput.OR }] : []),
-          {
-            video: {
-              isPublic_eq: handle !== activeMembership?.handle || undefined,
-            },
-            createdAt_lte: VIEWER_TIMESTAMP,
-          },
-        ],
-      },
-      limit: tilesPerPage,
-      offset: currentPage * tilesPerPage,
-      orderBy: sortBy as OwnedNftOrderByInput,
-    },
-    skip: !handle,
-  })
-
-  useEffect(() => {
-    if (totalNftsCount) {
-      setNftCount?.(totalNftsCount)
-    }
-  }, [setNftCount, totalNftsCount])
-
-  const handleChangePage = (page: number) => {
-    setCurrentPage(page)
-  }
-  return (
-    <section>
-      <Grid maxColumns={null} onResize={handleOnResizeGrid}>
-        {(loading ? createPlaceholderData<FullNftFieldsFragment>(tilesPerPage) : nfts ?? [])?.map((nft, idx) => (
-          <NftTileViewer key={`${idx}-${nft.id}`} nftId={nft.id} />
-        ))}
-      </Grid>
-      <StyledPagination
-        onChangePage={handleChangePage}
-        page={currentPage}
-        itemsPerPage={tilesPerPage}
-        totalCount={totalNftsCount}
-      />
-      {!loading && nfts && !nfts.length && (
-        <EmptyFallback
-          title={isFiltersApplied ? 'No NFTs found' : owner ? 'Start your collection' : 'No NFTs collected'}
-          subtitle={
-            isFiltersApplied
-              ? 'Try changing the filters.'
-              : owner
-              ? 'Buy NFTs across the platform or create your own.'
-              : "This member hasn't collected any NFTs yet."
-          }
-          variant="large"
-        />
-      )}
-    </section>
-  )
-}

+ 184 - 110
packages/atlas/src/views/viewer/MemberView/MemberView.tsx

@@ -4,55 +4,51 @@ import { useSearchParams } from 'react-router-dom'
 
 import { useMemberships } from '@/api/hooks/membership'
 import { NftActivityOrderByInput, OwnedNftOrderByInput } from '@/api/queries/__generated__/baseTypes.generated'
-import { SvgActionFilters } from '@/assets/icons'
+import { FallbackContainer } from '@/components/AllNftSection'
 import { EmptyFallback } from '@/components/EmptyFallback'
-import { FiltersBar, useFiltersBar } from '@/components/FiltersBar'
 import { LimitedWidthContainer } from '@/components/LimitedWidthContainer'
+import { Section, SectionProps } from '@/components/Section/Section'
 import { ViewErrorFallback } from '@/components/ViewErrorFallback'
 import { ViewWrapper } from '@/components/ViewWrapper'
 import { Button } from '@/components/_buttons/Button'
-import { Select } from '@/components/_inputs/Select'
+import { NftTileViewer } from '@/components/_nft/NftTileViewer'
 import { MemberTabs, QUERY_PARAMS, absoluteRoutes } from '@/config/routes'
-import { NFT_SORT_ACTIVITY_OPTIONS, NFT_SORT_OPTIONS } from '@/config/sorting'
 import { useHeadTags } from '@/hooks/useHeadTags'
+import { useInfiniteNftsGrid } from '@/hooks/useInfiniteNftsGrid'
+import { SORTING_FILTERS, useNftSectionFilters } from '@/hooks/useNftSectionFilters'
 import { getMemberAvatar } from '@/providers/assets/assets.helpers'
 import { useUser } from '@/providers/user/user.hooks'
+import { InfiniteLoadingOffsets } from '@/utils/loading.contants'
 import { SentryLogger } from '@/utils/logs'
 
 import { MemberAbout } from './MemberAbout'
 import { MemberActivity } from './MemberActivity'
-import { MemberNFTs } from './MemberNFTs'
-import {
-  FilterButtonContainer,
-  NotFoundMemberContainer,
-  SortContainer,
-  StyledMembershipInfo,
-  StyledTabs,
-  TabsContainer,
-  TabsWrapper,
-} from './MemberView.styles'
-
-const TABS: MemberTabs[] = ['NFTs owned', 'Activity', 'About']
+import { NotFoundMemberContainer, StyledMembershipInfo } from './MemberView.styles'
+
+const TABS: MemberTabs[] = ['NFTs', 'Activity', 'About']
+
+const ACTIVITY_SORTING_FILTERS = [
+  {
+    label: 'Newest',
+    value: NftActivityOrderByInput.EventTimestampDesc,
+  },
+  {
+    label: 'Oldest',
+    value: NftActivityOrderByInput.EventTimestampAsc,
+  },
+]
 
 export const MemberView: FC = () => {
   const [searchParams, setSearchParams] = useSearchParams()
   const currentTabName = searchParams.get(QUERY_PARAMS.TAB) as MemberTabs | null
-  const [sortBy, setSortBy] = useState<OwnedNftOrderByInput>(OwnedNftOrderByInput.CreatedAtDesc)
   const [sortByTimestamp, setSortByTimestamp] = useState<NftActivityOrderByInput>(
     NftActivityOrderByInput.EventTimestampDesc
   )
   const navigate = useNavigate()
-  const [currentTab, setCurrentTab] = useState<MemberTabs | null>(null)
-  const [nftCount, setNftCount] = useState<number | undefined>()
-  const { memberId, activeMembership } = useUser()
+  const [currentTab, setCurrentTab] = useState<typeof TABS[number] | null>(null)
+  const { memberId } = useUser()
   const { handle } = useParams()
   const headTags = useHeadTags(handle)
-  const filtersBarLogic = useFiltersBar()
-  const {
-    ownedNftWhereInput,
-    filters: { setIsFiltersOpen, isFiltersOpen },
-    canClearFilters: { canClearAllFilters },
-  } = filtersBarLogic
 
   const {
     memberships,
@@ -69,55 +65,143 @@ export const MemberView: FC = () => {
   const member = memberships?.find((member) => member.handle === handle)
   const { url: avatarUrl, isLoadingAsset: avatarLoading } = getMemberAvatar(member)
 
-  const toggleFilters = () => {
-    setIsFiltersOpen((value) => !value)
-  }
-  const handleSorting = (value?: OwnedNftOrderByInput | null) => {
-    if (value) {
-      setSortBy(value)
-    }
-  }
-  const handleSortingActivity = (value?: NftActivityOrderByInput | null) => {
-    if (value) {
-      setSortByTimestamp(value)
-    }
-  }
+  const {
+    ownedNftWhereInput,
+    order,
+    hasAppliedFilters,
+    rawFilters,
+    actions: { onApplyFilters, setOrder, clearFilters },
+  } = useNftSectionFilters()
+  const { columns, fetchMore, pageInfo, tiles, totalCount } = useInfiniteNftsGrid({
+    where: {
+      AND: [
+        ownedNftWhereInput,
+        {
+          OR: [
+            {
+              owner: {
+                isTypeOf_eq: 'NftOwnerChannel',
+                channel: {
+                  ownerMember: {
+                    handle_eq: handle,
+                  },
+                },
+              },
+            },
+            {
+              owner: {
+                isTypeOf_eq: 'NftOwnerMember',
+                member: {
+                  handle_eq: handle,
+                },
+              },
+            },
+          ],
+        },
+      ],
+    },
+    orderBy: order,
+  })
+
   const handleSetCurrentTab = async (tab: number) => {
     navigate(absoluteRoutes.viewer.member(handle, { tab: TABS[tab] }))
   }
 
   const mappedTabs = TABS.map((tab) => ({
     name: tab,
-    pillText: tab === 'NFTs owned' ? nftCount : undefined,
+    pillText: tab === 'NFTs' ? totalCount : undefined,
   }))
 
   const tabContent = useMemo(() => {
     switch (currentTab) {
-      case 'NFTs owned':
-        return (
-          <MemberNFTs
-            ownedNftWhereInput={ownedNftWhereInput}
-            sortBy={sortBy}
-            isFiltersApplied={canClearAllFilters}
-            owner={activeMembership?.handle === handle}
-            setNftCount={setNftCount}
-          />
-        )
+      case 'NFTs':
+        return tiles?.length
+          ? tiles.map((nft, idx) => <NftTileViewer key={idx} nftId={nft.id} />)
+          : [
+              <FallbackContainer key="fallback">
+                <EmptyFallback
+                  title="No NFTs found"
+                  subtitle="Please, try changing your filtering criteria."
+                  button={
+                    hasAppliedFilters && (
+                      <Button variant="secondary" onClick={() => clearFilters()}>
+                        Clear all filters
+                      </Button>
+                    )
+                  }
+                />
+              </FallbackContainer>,
+            ]
       case 'Activity':
-        return <MemberActivity memberId={member?.id} sort={sortByTimestamp} />
+        return [<MemberActivity key="member-activity" memberId={member?.id} sort={sortByTimestamp} />]
       case 'About':
-        return <MemberAbout />
+        return [<MemberAbout key="member-about" />]
+      default:
+        return [<div key="empty" />]
     }
-  }, [
-    activeMembership?.handle,
-    canClearAllFilters,
-    currentTab,
-    handle,
-    member?.id,
-    ownedNftWhereInput,
-    sortBy,
-    sortByTimestamp,
-  ])
+  }, [clearFilters, currentTab, hasAppliedFilters, member?.id, sortByTimestamp, tiles])
+
+  const gridColumns = useMemo(() => {
+    switch (currentTab) {
+      case 'NFTs':
+        return {
+          xss: {
+            columns: 1,
+          },
+          sm: {
+            columns: 2,
+          },
+          md: {
+            columns: 3,
+          },
+          lg: {
+            columns: 4,
+          },
+        }
+      default:
+        return {
+          xss: {
+            columns: 1,
+          },
+        }
+    }
+  }, [currentTab])
+
+  const headerFilters = useMemo((): Omit<
+    SectionProps<OwnedNftOrderByInput | NftActivityOrderByInput>['headerProps'],
+    'start'
+  > => {
+    switch (currentTab) {
+      case 'NFTs':
+        return {
+          onApplyFilters,
+          filters: rawFilters,
+          sort: {
+            type: 'toggle-button',
+            toggleButtonOptionTypeProps: {
+              type: 'options',
+              options: SORTING_FILTERS,
+              value: order,
+              onChange: setOrder,
+            },
+          },
+        }
+      case 'Activity':
+        return {
+          sort: {
+            type: 'toggle-button',
+            toggleButtonOptionTypeProps: {
+              type: 'options',
+              options: ACTIVITY_SORTING_FILTERS,
+              value: sortByTimestamp,
+              onChange: setSortByTimestamp,
+            },
+          },
+        }
+      default:
+        return {}
+    }
+  }, [currentTab, onApplyFilters, order, rawFilters, setOrder, sortByTimestamp])
 
   // At mount set the tab from the search params
   const initialRender = useRef(true)
@@ -131,11 +215,9 @@ export const MemberView: FC = () => {
 
   useEffect(() => {
     if (currentTabName) {
-      setSortBy(OwnedNftOrderByInput.CreatedAtDesc)
       setCurrentTab(currentTabName)
-      setIsFiltersOpen(false)
     }
-  }, [currentTabName, setIsFiltersOpen])
+  }, [currentTabName])
 
   if (!loadingMember && !member) {
     return (
@@ -167,51 +249,43 @@ export const MemberView: FC = () => {
           loading={loadingMember}
           isOwner={memberId === member?.id}
         />
-        <TabsWrapper isFiltersOpen={isFiltersOpen}>
-          <TabsContainer isMemberActivityTab={currentTab === 'Activity'}>
-            <StyledTabs
-              selected={TABS.findIndex((x) => x === currentTab)}
-              initialIndex={0}
-              tabs={mappedTabs}
-              onSelectTab={handleSetCurrentTab}
-            />
-            {currentTab && ['NFTs owned', 'Activity'].includes(currentTab) && (
-              <SortContainer>
-                {currentTab === 'NFTs owned' ? (
-                  <Select
-                    size="medium"
-                    inlineLabel="Sort by"
-                    value={sortBy}
-                    items={NFT_SORT_OPTIONS}
-                    onChange={handleSorting}
-                  />
-                ) : (
-                  <Select
-                    size="medium"
-                    inlineLabel="Sort by"
-                    value={sortByTimestamp}
-                    items={NFT_SORT_ACTIVITY_OPTIONS}
-                    onChange={handleSortingActivity}
-                  />
-                )}
-              </SortContainer>
-            )}
-            {currentTab === 'NFTs owned' && (
-              <FilterButtonContainer>
-                <Button
-                  badge={canClearAllFilters}
-                  variant="secondary"
-                  icon={<SvgActionFilters />}
-                  onClick={toggleFilters}
-                >
-                  Filters
-                </Button>
-              </FilterButtonContainer>
-            )}
-          </TabsContainer>
-          <FiltersBar {...filtersBarLogic} activeFilters={['nftStatus']} />
-        </TabsWrapper>
-        {tabContent}
+        <Section
+          headerProps={{
+            start: {
+              type: 'tabs',
+              tabsProps: {
+                selected: TABS.findIndex((t) => t === currentTabName),
+                tabs: mappedTabs,
+                onSelectTab: handleSetCurrentTab,
+              },
+            },
+            ...headerFilters,
+          }}
+          contentProps={{
+            type: 'grid',
+            grid: gridColumns,
+            children: tabContent,
+          }}
+          footerProps={
+            currentTab === 'NFTs'
+              ? {
+                  type: 'infinite',
+                  loadingTriggerOffset: InfiniteLoadingOffsets.NftTile,
+                  reachedEnd: !pageInfo?.hasNextPage ?? true,
+                  fetchMore: async () => {
+                    if (pageInfo?.hasNextPage) {
+                      await fetchMore({
+                        variables: {
+                          first: columns * 4,
+                          after: pageInfo?.endCursor,
+                        },
+                      })
+                    }
+                  },
+                }
+              : undefined
+          }
+        />
       </LimitedWidthContainer>
     </ViewWrapper>
   )

+ 2 - 0
packages/atlas/vite.config.ts

@@ -11,6 +11,7 @@ import {
   AtlasHtmlMetaTagsPlugin,
   AtlasWebmanifestPlugin,
   EmbeddedFallbackPlugin,
+  OptimizePlugin,
   PolkadotWorkerMetaFixPlugin,
 } from './plugins'
 
@@ -46,6 +47,7 @@ export default defineConfig({
     AtlasHtmlMetaTagsPlugin,
     AtlasWebmanifestPlugin,
     EmbeddedFallbackPlugin,
+    OptimizePlugin,
     ViteYaml(),
     react({
       exclude: /\.stories\.[tj]sx?$/,