1
0
Pārlūkot izejas kodu

📝 Changes on member profile (#4271)

* Create hook for NFT section filters

* Adjust CopyAddressButton

* Initial refactor

* CR fixes

* Fix errors
WRadoslaw 1 gadu atpakaļ
vecāks
revīzija
8b65eb77b8

+ 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>
                     )

+ 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>
   )
 }

+ 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,
+    },
+  }
+}

+ 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>
   )