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

🈂️ Nft section (#4090)

* Introduce section into view

* Simplify the filter creation logic
WRadoslaw 1 рік тому
батько
коміт
bc0dc1f77e

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

@@ -70,9 +70,11 @@ const getNftKeyArgs = (
   const sorting = stringifyValue(sortingArray)
   const createdAtGte = args?.where?.createdAt_gte ? JSON.stringify(args.where.createdAt_gte) : ''
   const createdAtLte = args?.where?.createdAt_lte ? JSON.stringify(args.where.createdAt_lte) : ''
+  const lastSalePriceGte = args?.where?.lastSalePrice_gte ? JSON.stringify(args.where.lastSalePrice_gte) : ''
+  const lastSalePriceLte = args?.where?.lastSalePrice_lte ? JSON.stringify(args.where.lastSalePrice_lte) : ''
   const video = stringifyValue(args?.where?.video)
 
-  return `${OR}:${AND}:${ownerMember}:${creatorChannel}:${status}:${auctionStatus}:${sorting}:${createdAtGte}:${createdAtLte}:${video}:${offset}`
+  return `${OR}:${AND}:${ownerMember}:${creatorChannel}:${status}:${auctionStatus}:${sorting}:${createdAtGte}:${createdAtLte}:${video}:${offset}:${lastSalePriceGte}:${lastSalePriceLte}`
 }
 
 const getChannelKeyArgs = (args: Partial<QueryChannelsConnectionArgs> | null) => {

+ 201 - 0
packages/atlas/src/components/AllNftSection/AllNftSection.tsx

@@ -0,0 +1,201 @@
+import styled from '@emotion/styled'
+import { useMemo, useState } from 'react'
+
+import { useNftsConnection } from '@/api/hooks/nfts'
+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 { NumberFormat } from '@/components/NumberFormat'
+import { Section } from '@/components/Section/Section'
+import { Button } from '@/components/_buttons/Button'
+import { NftTileViewer } from '@/components/_nft/NftTileViewer'
+import { useMediaMatch } from '@/hooks/useMediaMatch'
+import { tokenNumberToHapiBn } from '@/joystream-lib/utils'
+import { createPlaceholderData } from '@/utils/data'
+
+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 LIMIT = 12
+const LG_LIMIT = 30
+
+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 lgMatch = useMediaMatch('lg')
+  const limit = lgMatch ? LG_LIMIT : LIMIT
+  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: {
+        ...(isMatureExcluded ? { isExcluded_eq: false } : {}),
+        ...(isPromotionalExcluded ? { hasMarketing_eq: false } : {}),
+      },
+    }
+    return {
+      OR: mappedStatus.length
+        ? mappedStatus.map((transactionalStatus) => ({
+            ...commonFilters,
+            transactionalStatus,
+          }))
+        : [commonFilters],
+    }
+  }, [filters])
+
+  const { nfts, loading, totalCount, fetchMore, pageInfo } = useNftsConnection({
+    where: mappedFilters,
+    orderBy: order,
+    first: limit,
+  })
+  const [isLoading, setIsLoading] = useState(false)
+
+  const placeholderItems = loading || isLoading ? createPlaceholderData(limit) : []
+  const nftsWithPlaceholders = [...(nfts || []), ...placeholderItems]
+  return (
+    <Section
+      headerProps={{
+        onApplyFilters: setFilters,
+        start: {
+          type: 'title',
+          title: 'All NFTs',
+          nodeEnd:
+            typeof totalCount === 'number' ? (
+              <NumberFormat value={totalCount} as="p" variant={smMatch ? 'h500' : 'h400'} color="colorTextMuted" />
+            ) : undefined,
+        },
+        filters,
+        sort: {
+          type: 'toggle-button',
+          toggleButtonOptionTypeProps: {
+            type: 'options',
+            options: ['Newest', 'Oldest'],
+            value: order === OwnedNftOrderByInput.CreatedAtDesc ? 'Newest' : 'Oldest',
+            onChange: (order) =>
+              setOrder(order === 'Oldest' ? OwnedNftOrderByInput.CreatedAtAsc : OwnedNftOrderByInput.CreatedAtDesc),
+          },
+        },
+      }}
+      contentProps={{
+        type: 'grid',
+        minChildrenWidth: 250,
+        children:
+          !(isLoading || loading) && !nfts?.length
+            ? [
+                <FallbackContainer key="fallback">
+                  <EmptyFallback
+                    title="No NFTs found"
+                    subtitle="Please, try changing your filtering criteria."
+                    button={
+                      hasAppliedFilters && (
+                        <Button variant="secondary" onClick={() => setFilters(FILTERS)}>
+                          Clear all filters
+                        </Button>
+                      )
+                    }
+                  />
+                </FallbackContainer>,
+              ]
+            : nftsWithPlaceholders.map((nft, idx) => <NftTileViewer key={idx} nftId={nft.id} />),
+      }}
+      footerProps={{
+        type: 'infinite',
+        reachedEnd: !pageInfo?.hasNextPage ?? true,
+        fetchMore: async () => {
+          setIsLoading(true)
+          await fetchMore({
+            variables: {
+              after: pageInfo?.endCursor,
+            },
+          }).finally(() => {
+            setIsLoading(false)
+          })
+        },
+      }}
+    />
+  )
+}
+
+export const FallbackContainer = styled.div`
+  grid-column: 1/-1;
+`

+ 1 - 0
packages/atlas/src/components/AllNftSection/index.ts

@@ -0,0 +1 @@
+export * from './AllNftSection'

+ 12 - 3
packages/atlas/src/components/FilterButton/FilterButton.stories.tsx

@@ -3,7 +3,7 @@ import { useState } from 'react'
 
 import { SvgActionShoppingCart } from '@/assets/icons'
 
-import { FilterButton, FilterButtonOption, FilterButtonProps } from './FilterButton'
+import { FilterButton, FilterButtonOption, FilterButtonProps, isFilterRange } from './FilterButton'
 
 export default {
   title: 'inputs/FilterButton',
@@ -31,7 +31,14 @@ export default {
 const CheckboxTemplate: StoryFn<FilterButtonProps> = (args) => {
   const [options, setOptions] = useState<FilterButtonOption[]>(args.options || [])
 
-  return <FilterButton {...args} type="checkbox" options={options} onChange={setOptions} />
+  return (
+    <FilterButton
+      {...args}
+      type="checkbox"
+      options={options}
+      onChange={(val) => !isFilterRange(val) && setOptions(val)}
+    />
+  )
 }
 
 export const Checkbox = CheckboxTemplate.bind({})
@@ -39,7 +46,9 @@ export const Checkbox = CheckboxTemplate.bind({})
 const RadioTemplate: StoryFn<FilterButtonProps> = (args) => {
   const [options, setOptions] = useState<FilterButtonOption[]>(args.options || [])
 
-  return <FilterButton {...args} type="radio" options={options} onChange={setOptions} />
+  return (
+    <FilterButton {...args} type="radio" options={options} onChange={(val) => !isFilterRange(val) && setOptions(val)} />
+  )
 }
 
 export const Radio = RadioTemplate.bind({})

+ 1 - 1
packages/atlas/src/components/FilterButton/FilterButton.styles.ts

@@ -14,7 +14,7 @@ export const Counter = styled.div`
 `
 
 export const StyledButton = styled(Button)`
-  > svg > path {
+  svg > path {
     fill: ${cVar('colorTextMuted')};
   }
 `

+ 142 - 64
packages/atlas/src/components/FilterButton/FilterButton.tsx

@@ -1,7 +1,9 @@
-import { ChangeEvent, FC, ReactNode, useRef } from 'react'
+import { ChangeEvent, FC, ReactNode, useEffect, useRef, useState } from 'react'
+import { useInView } from 'react-intersection-observer'
 
 import { Counter, StyledButton } from '@/components/FilterButton/FilterButton.styles'
 import { CheckboxGroup } from '@/components/_inputs/CheckboxGroup'
+import { PriceRangeInput } from '@/components/_inputs/PriceRangeInput'
 import { DialogPopover } from '@/components/_overlays/DialogPopover'
 
 import { RadioButtonGroup } from '../_inputs/RadioButtonGroup'
@@ -13,85 +15,161 @@ export type FilterButtonOption = {
   applied: boolean
 }
 
+export type FilterRange = {
+  min?: number
+  max?: number
+  appliedMin?: number
+  appliedMax?: number
+}
+
+export const isFilterRange = (value: FilterRange | FilterButtonOption[]): value is FilterRange => 'min' in value
+
 export type FilterButtonProps = {
   name: string
-  type: 'checkbox' | 'radio'
   label?: string
   icon?: ReactNode
   className?: string
-  onChange: (selectedOptions: FilterButtonOption[]) => void
+  type: 'checkbox' | 'radio' | 'range'
+  onChange: (value: FilterRange | FilterButtonOption[]) => void
+  range?: FilterRange
   options?: FilterButtonOption[]
 }
 
 export type SectionFilter = Omit<FilterButtonProps, 'onChange'>
 
-export const FilterButton: FC<FilterButtonProps> = ({ type, name, onChange, className, icon, label, options = [] }) => {
-  const counter = options.filter((option) => option.applied)?.length
+export const FilterButton: FC<FilterButtonProps> = (props) => {
   const triggerRef = useRef<HTMLButtonElement>(null)
+  const firstRangeInput = useRef<HTMLInputElement | null>(null)
+  const [shouldFocus, setShouldFocus] = useState(false)
+  const { ref, inView } = useInView()
 
-  const handleApply = () => {
-    onChange(options.map((option) => ({ ...option, applied: option.selected })))
-    triggerRef.current?.click()
-  }
+  useEffect(() => {
+    if (inView && shouldFocus) {
+      firstRangeInput.current?.focus()
+      setShouldFocus(false)
+    }
+  }, [inView, shouldFocus])
 
-  const handleCheckboxSelection = (num: number) => {
-    const selected = options.map((option, idx) => {
-      if (num === idx) {
-        return { ...option, selected: !option.selected }
-      }
-      return option
-    })
-    onChange(selected)
-  }
+  if (props.type === 'checkbox' || props.type === 'radio') {
+    const { type, name, onChange, className, icon, label, options = [] } = props
 
-  const handleRadioButtonClick = (e: ChangeEvent<Omit<HTMLInputElement, 'value'> & { value: string | boolean }>) => {
-    const optionIdx = options.findIndex((option) => option.value === e.currentTarget.value)
-    const selected = options.map((option, idx) => ({ ...option, selected: optionIdx === idx }))
-    onChange(selected)
-  }
+    const counter = options.filter((option) => option.applied)?.length
+
+    const handleApply = () => {
+      onChange(options.map((option) => ({ ...option, applied: option.selected })))
+      triggerRef.current?.click()
+    }
+
+    const handleCheckboxSelection = (num: number) => {
+      const selected = options.map((option, idx) => {
+        if (num === idx) {
+          return { ...option, selected: !option.selected }
+        }
+        return option
+      })
+      onChange(selected)
+    }
+
+    const handleRadioButtonClick = (e: ChangeEvent<Omit<HTMLInputElement, 'value'> & { value: string | boolean }>) => {
+      const optionIdx = options.findIndex((option) => option.value === e.currentTarget.value)
+      const selected = options.map((option, idx) => ({ ...option, selected: optionIdx === idx }))
+      onChange(selected)
+    }
 
-  const handleClear = () => {
-    onChange(options.map((option) => ({ ...option, selected: false, applied: false })))
-    triggerRef.current?.click()
+    const handleClear = () => {
+      onChange(options.map((option) => ({ ...option, selected: false, applied: false })))
+      triggerRef.current?.click()
+    }
+
+    return (
+      <DialogPopover
+        className={className}
+        flipEnabled
+        appendTo={document.body}
+        trigger={
+          <StyledButton
+            ref={triggerRef}
+            icon={counter ? <Counter>{counter}</Counter> : icon}
+            iconPlacement="right"
+            variant="secondary"
+          >
+            {label}
+          </StyledButton>
+        }
+        primaryButton={{ text: 'Apply', onClick: handleApply }}
+        secondaryButton={{
+          text: 'Clear',
+          onClick: handleClear,
+        }}
+      >
+        {type === 'checkbox' && (
+          <CheckboxGroup
+            name={name}
+            options={options.map((option) => ({ ...option, value: option.selected }))}
+            checkedIds={options.map((option, index) => (option.selected ? index : -1)).filter((index) => index !== -1)}
+            onChange={handleCheckboxSelection}
+          />
+        )}
+        {type === 'radio' && (
+          <RadioButtonGroup
+            name={name}
+            options={options}
+            value={options.find((option) => option.selected)?.value}
+            onChange={handleRadioButtonClick}
+          />
+        )}
+      </DialogPopover>
+    )
   }
 
-  return (
-    <DialogPopover
-      className={className}
-      flipEnabled
-      appendTo={document.body}
-      trigger={
-        <StyledButton
-          ref={triggerRef}
-          icon={counter ? <Counter>{counter}</Counter> : icon}
-          iconPlacement="right"
-          variant="secondary"
-        >
-          {label}
-        </StyledButton>
-      }
-      primaryButton={{ text: 'Apply', onClick: handleApply }}
-      secondaryButton={{
-        text: 'Clear',
-        onClick: handleClear,
-      }}
-    >
-      {type === 'checkbox' && (
-        <CheckboxGroup
-          name={name}
-          options={options.map((option) => ({ ...option, value: option.selected }))}
-          checkedIds={options.map((option, index) => (option.selected ? index : -1)).filter((index) => index !== -1)}
-          onChange={handleCheckboxSelection}
-        />
-      )}
-      {type === 'radio' && (
-        <RadioButtonGroup
-          name={name}
-          options={options}
-          value={options.find((option) => option.selected)?.value}
-          onChange={handleRadioButtonClick}
+  if (props.type === 'range') {
+    const { onChange, className, icon, label, range } = props
+
+    const isApplied = Boolean(range?.appliedMax || range?.appliedMin)
+
+    const handleApply = () => {
+      onChange({ ...range, appliedMin: range?.min, appliedMax: range?.max })
+      triggerRef.current?.click()
+    }
+
+    const handleClear = () => {
+      onChange({ min: undefined, max: undefined, appliedMin: undefined, appliedMax: undefined })
+      triggerRef.current?.click()
+    }
+
+    return (
+      <DialogPopover
+        className={className}
+        flipEnabled
+        onShow={() => setShouldFocus(true)}
+        appendTo={document.body}
+        trigger={
+          <StyledButton
+            ref={triggerRef}
+            icon={isApplied ? <Counter>1</Counter> : icon}
+            iconPlacement="right"
+            variant="secondary"
+          >
+            {label}
+          </StyledButton>
+        }
+        primaryButton={{ text: 'Apply', onClick: handleApply }}
+        secondaryButton={{
+          text: 'Clear',
+          onClick: handleClear,
+        }}
+      >
+        <PriceRangeInput
+          ref={(inputRef) => {
+            ref(inputRef)
+            firstRangeInput.current = inputRef
+          }}
+          value={range}
+          onChange={(value) => onChange({ ...range, ...value })}
         />
-      )}
-    </DialogPopover>
-  )
+      </DialogPopover>
+    )
+  }
+
+  return null
 }

+ 21 - 1
packages/atlas/src/components/MobileFilterButton/MobileFilterButton.tsx

@@ -8,6 +8,7 @@ import { MobileFilterContainer } from '../FiltersBar/FiltersBar.styles'
 import { Text } from '../Text'
 import { Button } from '../_buttons/Button'
 import { CheckboxGroup } from '../_inputs/CheckboxGroup'
+import { InputRange, PriceRangeInput } from '../_inputs/PriceRangeInput'
 import { RadioButtonGroup } from '../_inputs/RadioButtonGroup'
 import { DialogModal, DialogModalProps } from '../_overlays/DialogModal'
 
@@ -26,6 +27,7 @@ export const MobileFilterButton: FC<MobileFilterButtonProps> = ({ filters, onCha
     const newFilters = filters.map((filter) => ({
       ...filter,
       options: filter.options?.map((option) => ({ ...option, applied: option.selected })),
+      range: { ...filter.range, appliedMin: filter.range?.min, appliedMax: filter.range?.max },
     }))
 
     onChangeFilters?.(newFilters)
@@ -80,10 +82,25 @@ export const MobileFilterButton: FC<MobileFilterButtonProps> = ({ filters, onCha
     onChangeFilters?.(newFilters)
   }
 
+  const handleRangeChange = (name: string, range: InputRange) => {
+    const newFilters = filters.map((filter) => {
+      if (filter.name === name) {
+        return {
+          ...filter,
+          range,
+        }
+      }
+      return filter
+    })
+
+    onChangeFilters?.(newFilters)
+  }
+
   const handleClear = () => {
     const newFilters = filters.map((filter) => ({
       ...filter,
       options: filter.options?.map((option) => ({ ...option, selected: false, applied: false })),
+      range: { min: undefined, max: undefined, appliedMin: undefined, appliedMax: undefined },
     }))
 
     onChangeFilters?.(newFilters)
@@ -114,7 +131,7 @@ export const MobileFilterButton: FC<MobileFilterButtonProps> = ({ filters, onCha
         show={isFiltersOpen}
         content={
           <>
-            {filters.map(({ name, options = [], label, type }, idx) => (
+            {filters.map(({ name, options = [], label, type, range }, idx) => (
               <MobileFilterContainer key={idx}>
                 <Text as="span" variant="h300">
                   {label}
@@ -137,6 +154,9 @@ export const MobileFilterButton: FC<MobileFilterButtonProps> = ({ filters, onCha
                     value={options.find((option) => option.selected)?.value}
                   />
                 )}
+                {type === 'range' && (
+                  <PriceRangeInput value={range} onChange={(newRange) => handleRangeChange(name, newRange)} />
+                )}
               </MobileFilterContainer>
             ))}
           </>

+ 1 - 1
packages/atlas/src/components/Section/SectionContent/SectionContent.styles.ts

@@ -4,7 +4,7 @@ import { media, sizes } from '@/styles'
 
 export const GridWrapper = styled.div<{ minWidth: number }>`
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(${(props) => `${props.minWidth}px`}, 1fr));
+  grid-template-columns: repeat(auto-fill, minmax(${(props) => `${props.minWidth}px, 1fr`}));
   gap: ${sizes(4)};
 
   ${media.md} {

+ 9 - 4
packages/atlas/src/components/Section/SectionHeader/SectionFilters/SectionFilters.tsx

@@ -1,7 +1,7 @@
 import { FC, useRef } from 'react'
 
 import { SvgActionChevronL, SvgActionChevronR, SvgActionClose } from '@/assets/icons'
-import { FilterButton, FilterButtonOption, SectionFilter } from '@/components/FilterButton'
+import { FilterButton, FilterButtonOption, FilterRange, SectionFilter, isFilterRange } from '@/components/FilterButton'
 import { MobileFilterButton } from '@/components/MobileFilterButton'
 import { Button } from '@/components/_buttons/Button'
 import { useHorizonthalFade } from '@/hooks/useHorizonthalFade'
@@ -28,15 +28,19 @@ export const SectionFilters: FC<SectionFiltersProps> = ({ filters, onApplyFilter
   const { handleMouseDown, visibleShadows, handleArrowScroll, isOverflow } = useHorizonthalFade(filterWrapperRef)
 
   const areThereAnyOptionsSelected = filters
-    .map((filter) => filter.options?.map((option) => option.applied))
+    .map(
+      (filter) =>
+        filter.options?.map((option) => option.applied) ?? (filter.range?.appliedMin || filter.range?.appliedMax)
+    )
     .flat()
     .some(Boolean)
 
-  const handleApply = (name: string, selectedOptions: FilterButtonOption[]) => {
+  const handleApply = (name: string, selectedOptions: FilterButtonOption[] | FilterRange) => {
     onApplyFilters?.(
       filters.map((filter) => {
         if (filter.name === name) {
-          return { ...filter, options: selectedOptions }
+          const isFilter = isFilterRange(selectedOptions)
+          return { ...filter, [isFilter ? 'range' : 'options']: selectedOptions }
         }
         return filter
       })
@@ -47,6 +51,7 @@ export const SectionFilters: FC<SectionFiltersProps> = ({ filters, onApplyFilter
     const newFilters = filters.map((filter) => ({
       ...filter,
       options: filter.options?.map((option) => ({ ...option, selected: false, applied: false })),
+      range: { min: undefined, max: undefined, maxApplied: undefined, minApplied: undefined },
     }))
 
     onApplyFilters?.(newFilters)

+ 4 - 2
packages/atlas/src/components/Section/SectionHeader/SectionHeader.tsx

@@ -55,6 +55,7 @@ type SectionHeaderStart =
       type: 'title'
       title: string
       nodeStart?: TitleNodeStart
+      nodeEnd?: ReactNode
     }
   | {
       type: 'tabs'
@@ -94,7 +95,7 @@ export const SectionHeader: FC<SectionHeaderProps> = (props) => {
         <MobileFirstRow>
           {!isSearchInputOpen && (
             <>
-              {start.type === 'title' && <SectionTitleComponent nodeStart={start.nodeStart} title={start.title} />}
+              {start.type === 'title' && <SectionTitleComponent {...start} />}
               {start.type === 'tabs' && <Tabs {...start.tabsProps} />}
             </>
           )}
@@ -125,6 +126,7 @@ export const SectionHeader: FC<SectionHeaderProps> = (props) => {
         <MobileSecondRow>
           {filters && !filtersInFirstRow && <SectionFilters filters={filters} onApplyFilters={onApplyFilters} />}
           {sort?.type === 'select' && <Select {...sort.selectProps} size="medium" />}
+          {sort?.type === 'toggle-button' && <ToggleButtonGroup {...sort.toggleButtonOptionTypeProps} width="fluid" />}
         </MobileSecondRow>
       </SectionHeaderWrapper>
     )
@@ -135,7 +137,7 @@ export const SectionHeader: FC<SectionHeaderProps> = (props) => {
   return (
     <SectionHeaderWrapper isTabs={start.type === 'tabs'}>
       <StartWrapper enableHorizonthalScrolling={start.type === 'tabs'}>
-        {start.type === 'title' && <SectionTitleComponent nodeStart={start.nodeStart} title={start.title} />}
+        {start.type === 'title' && <SectionTitleComponent {...start} />}
         {start.type === 'tabs' && <Tabs {...start.tabsProps} />}
       </StartWrapper>
       {search && <DynamicSearch search={search} isOpen={isSearchInputOpen} onSearchToggle={setIsSearchInputOpen} />}

+ 3 - 1
packages/atlas/src/components/Section/SectionHeader/SectionTitle/SectionTitle.tsx

@@ -22,10 +22,11 @@ type TitleNodeStart =
 
 type SectionTitleComponentProps = {
   nodeStart?: TitleNodeStart
+  nodeEnd?: ReactNode
   title: string
 }
 
-export const SectionTitleComponent: FC<SectionTitleComponentProps> = ({ nodeStart, title }) => {
+export const SectionTitleComponent: FC<SectionTitleComponentProps> = ({ nodeStart, title, nodeEnd }) => {
   const smMatch = useMediaMatch('sm')
 
   const renderNodeStart = () => {
@@ -46,6 +47,7 @@ export const SectionTitleComponent: FC<SectionTitleComponentProps> = ({ nodeStar
       <HeaderTitle variant={smMatch ? 'h500' : 'h400'} as="h3">
         {title}
       </HeaderTitle>
+      {nodeEnd}
     </HeaderTitleWrapper>
   )
 }

+ 48 - 0
packages/atlas/src/components/_inputs/PriceRangeInput/PriceRangeInput.tsx

@@ -0,0 +1,48 @@
+import styled from '@emotion/styled'
+import { forwardRef } from 'react'
+
+import { SvgJoyTokenMonochrome16 } from '@/assets/icons'
+import { Input } from '@/components/_inputs/Input'
+import { sizes } from '@/styles'
+
+export type InputRange = {
+  min?: number
+  max?: number
+}
+
+type RangeInputsProps = {
+  value?: InputRange
+  onChange?: (value: InputRange) => void
+}
+export const PriceRangeInput = forwardRef<HTMLInputElement, RangeInputsProps>(
+  ({ onChange, value }: RangeInputsProps, ref) => {
+    return (
+      <InputsContainer>
+        <Input
+          ref={ref}
+          type="number"
+          size="medium"
+          nodeStart={<SvgJoyTokenMonochrome16 />}
+          placeholder="Min"
+          value={value?.min ?? ''}
+          onChange={(e) => onChange?.({ ...value, min: parseInt(e.target.value, 10) })}
+        />
+        <Input
+          type="number"
+          size="medium"
+          nodeStart={<SvgJoyTokenMonochrome16 />}
+          placeholder="Max"
+          value={value?.max ?? ''}
+          onChange={(e) => onChange?.({ ...value, max: parseInt(e.target.value, 10) })}
+        />
+      </InputsContainer>
+    )
+  }
+)
+
+PriceRangeInput.displayName = 'PriceRangeInput'
+
+const InputsContainer = styled.div`
+  display: flex;
+  gap: ${sizes(2)};
+`

+ 1 - 0
packages/atlas/src/components/_inputs/PriceRangeInput/index.ts

@@ -0,0 +1 @@
+export * from './PriceRangeInput'

+ 1 - 0
packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.styles.ts

@@ -25,6 +25,7 @@ export const Container = styled.div<{ width: ContainerWidth }>`
   box-shadow: inset 0 0 0 1px ${cVar('colorBorderMutedAlpha')};
   border-radius: ${cVar('radiusSmall')};
   max-width: ${(props) => getContainerMaxWidth(props.width)};
+  width: 100%;
 `
 
 export const OptionWrapper = styled.div<MaskProps>`

+ 1 - 1
packages/atlas/src/views/playground/PlaygroundLayout.tsx

@@ -26,13 +26,13 @@ import {
   PlaygroundImageDownsizing,
   PlaygroundIndirectSignInDialog,
   PlaygroundInputAutocomplete,
-  PlaygroundMarketplaceCarousel,
   PlaygroundNftPurchase,
   PlaygroundNftSettleAuction,
   PlaygroundNftWhitelistMembers,
   PlaygroundReactionsComments,
   PlaygroundTokenPrice,
 } from './Playgrounds'
+import { PlaygroundMarketplaceCarousel } from './Playgrounds/PlaygroundMarketplaceCarousel'
 
 const playgroundRoutes = [
   { path: 'nft-purchase', element: <PlaygroundNftPurchase />, name: 'NFT Purchase' },

+ 0 - 1
packages/atlas/src/views/playground/Playgrounds/index.ts

@@ -10,4 +10,3 @@ export * from './PlaygroundIframe'
 export * from './PlaygroundCaptcha'
 export * from './PlaygroundGoogleAuthentication'
 export * from './PlaygroundInputAutocomplete'
-export * from './PlaygroundMarketplaceCarousel'

+ 3 - 143
packages/atlas/src/views/viewer/NftsView/NftsView.tsx

@@ -1,156 +1,16 @@
-import { FC, useState } from 'react'
+import { FC } from 'react'
 
-import { useNfts } from '@/api/hooks/nfts'
-import { OwnedNftOrderByInput, OwnedNftWhereInput } from '@/api/queries/__generated__/baseTypes.generated'
-import { SvgActionFilters } from '@/assets/icons'
-import { EmptyFallback } from '@/components/EmptyFallback'
-import { FiltersBar, useFiltersBar } from '@/components/FiltersBar'
-import { GridItem } from '@/components/LayoutGrid'
-import { Text } from '@/components/Text'
-import { Button } from '@/components/_buttons/Button'
-import { Select } from '@/components/_inputs/Select'
-import { NftTileViewer } from '@/components/_nft/NftTileViewer'
+import { AllNftSection } from '@/components/AllNftSection/AllNftSection'
 import { VideoContentTemplate } from '@/components/_templates/VideoContentTemplate'
 import { useHeadTags } from '@/hooks/useHeadTags'
-import { useMediaMatch } from '@/hooks/useMediaMatch'
-import { useVideoGridRows } from '@/hooks/useVideoGridRows'
-import { createPlaceholderData } from '@/utils/data'
-import { SentryLogger } from '@/utils/logs'
-import { StyledPagination } from '@/views/studio/MyVideosView/MyVideos.styles'
-
-import { HeaderContainer, HeaderWrapper, StyledGrid } from './NftsView.styles'
-
-const SORT_OPTIONS = [
-  { name: 'newest', value: OwnedNftOrderByInput.CreatedAtDesc },
-  { name: 'oldest', value: OwnedNftOrderByInput.CreatedAtAsc },
-]
-
-const VIEWER_TIMESTAMP = new Date()
 
 export const NftsView: FC = () => {
   const headTags = useHeadTags('Video NFTs')
-  const smMatch = useMediaMatch('sm')
-  const mdMatch = useMediaMatch('md')
-  const filtersBarLogic = useFiltersBar()
-  const {
-    filters: { setIsFiltersOpen, isFiltersOpen },
-    canClearFilters: { canClearAllFilters },
-    ownedNftWhereInput,
-    videoWhereInput,
-  } = filtersBarLogic
-
-  const [sortBy, setSortBy] = useState<OwnedNftOrderByInput>(OwnedNftOrderByInput.CreatedAtDesc)
-  const [currentPage, setCurrentPage] = useState(0)
-  const [tilesPerRow, setTilesPerRow] = useState(4)
-  const nftRows = useVideoGridRows('main')
-  const tilesPerPage = nftRows * tilesPerRow
-
-  const basicVariables: OwnedNftWhereInput = {
-    createdAt_lte: VIEWER_TIMESTAMP,
-    createdAt_gte: videoWhereInput.createdAt_gte,
-    video: {
-      ...videoWhereInput,
-      media: {
-        isAccepted_eq: true,
-      },
-      thumbnailPhoto: {
-        isAccepted_eq: true,
-      },
-      isPublic_eq: true,
-      channel: {
-        isPublic_eq: true,
-      },
-    },
-  }
-
-  const orVariablesFromFilter = ownedNftWhereInput.OR?.map((value) => ({
-    ...basicVariables,
-    ...value,
-  }))
-
-  const { nfts, loading, totalCount } = useNfts({
-    variables: {
-      where: {
-        ...(orVariablesFromFilter?.length
-          ? { OR: orVariablesFromFilter }
-          : { ...ownedNftWhereInput, ...basicVariables }),
-      },
-      orderBy: sortBy,
-      limit: tilesPerPage,
-      offset: currentPage * tilesPerPage,
-    },
-    fetchPolicy: 'cache-first',
-    notifyOnNetworkStatusChange: true,
-    onError: (error) => SentryLogger.error('Failed to fetch NFTs', 'NftsView', error),
-  })
-
-  const handleResizeGrid = (sizes: number[]) => setTilesPerRow(sizes.length)
-
-  const handleSortingChange = (value?: OwnedNftOrderByInput | null) => {
-    if (value) {
-      setSortBy(value)
-    }
-  }
-
-  const handleFilterClick = () => {
-    setIsFiltersOpen((value) => !value)
-  }
-
-  const handleChangePage = (page: number) => {
-    setCurrentPage(page)
-  }
-
-  const sortingNode = (
-    <Select size="medium" value={sortBy} inlineLabel="Sort by" items={SORT_OPTIONS} onChange={handleSortingChange} />
-  )
 
   return (
     <VideoContentTemplate title="Video NFTs">
       {headTags}
-      <HeaderWrapper>
-        <HeaderContainer>
-          <GridItem colSpan={{ base: 2, sm: 1 }}>
-            <Text as="p" variant={mdMatch ? 'h500' : 'h400'}>
-              All NFTs {totalCount !== undefined && `(${totalCount})`}
-            </Text>
-          </GridItem>
-          {!smMatch && sortingNode}
-          <div>
-            <Button
-              badge={canClearAllFilters}
-              variant="secondary"
-              icon={<SvgActionFilters />}
-              onClick={handleFilterClick}
-            >
-              Filters
-            </Button>
-          </div>
-          {smMatch && sortingNode}
-        </HeaderContainer>
-        <FiltersBar
-          {...filtersBarLogic}
-          onAnyFilterSet={() => setCurrentPage(0)}
-          activeFilters={['nftStatus', 'date-minted', 'other']}
-        />
-      </HeaderWrapper>
-      <StyledGrid maxColumns={null} onResize={handleResizeGrid} isFiltersOpen={isFiltersOpen}>
-        {(loading ? createPlaceholderData(tilesPerPage) : nfts ?? []).map((nft, idx) => (
-          <NftTileViewer key={`${idx}-${nft.id}`} nftId={nft.id} />
-        ))}
-      </StyledGrid>
-      {nfts && !nfts.length && (
-        <EmptyFallback
-          title="No NFTs found"
-          subtitle={canClearAllFilters ? 'Try changing the filters.' : 'No NFTs were minted yet.'}
-          variant="large"
-        />
-      )}
-      <StyledPagination
-        onChangePage={handleChangePage}
-        page={currentPage}
-        itemsPerPage={tilesPerPage}
-        totalCount={totalCount}
-      />
+      <AllNftSection />
     </VideoContentTemplate>
   )
 }