kodadot/nft-gallery

View on GitHub
components/series/SeriesTable.vue

Summary

Maintainability
Test Coverage
<template>
  <div>
    <Loader :model-value="isLoading" />
    <NeoField grouped>
      <NeoField
        class="text-right"
        expanded
      >
        <NeoSelect v-model="nbRows">
          <option value="10">
            10
          </option>
          <option value="20">
            20
          </option>
          <option value="50">
            50
          </option>
          <option value="100">
            100
          </option>
        </NeoSelect>
      </NeoField>
    </NeoField>

    <NeoTable
      sticky-header
      :data="data"
      :default-sort="[sortBy.field, sortBy.value]"
      default-sort-direction="desc"
      backend-sorting
      hoverable
      @sort="onSort"
    >
      <NeoTableColumn
        v-slot="props"
        cell-class="is-vcentered"
        field="id"
        label="N°"
      >
        {{ data.indexOf(props.row) + 1 }}
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="image"
        label=""
        header-class="z-10"
        cell-class="is-vcentered"
      >
        <div class="image size-12 mb-2">
          <BasicImage
            :src="props.row.image"
            :alt="props.row.name"
            rounded
          />
        </div>
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        cell-class="is-vcentered"
        field="id"
        label="Collection"
      >
        <nuxt-link
          v-if="!isLoading"
          :to="`/${urlPrefix}/collection/${props.row.id}`"
        >
          {{ props.row.name }}
        </nuxt-link>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="volume"
        :label="$t('series.volume')"
        sortable
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          <Money
            :value="props.row.volume"
            inline
            hide-unit
          />
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="dailyVolume"
        label="24h %"
        numeric
        cell-class="is-vcentered"
        :visible="nbDays === '24'"
      >
        <template v-if="!isLoading">
          <div
            :class="
              displayVolumePercent(
                props.row.dailyVolume,
                props.row.dailyrangeVolume,
                true,
              )
            "
          >
            {{
              displayVolumePercent(
                props.row.dailyVolume,
                props.row.dailyrangeVolume,
              )
            }}
          </div>
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <!-- <NeoTableColumn
        v-slot="props"
        field="weeklyVolume"
        label="7d %"
        numeric
        cell-class="is-vcentered"
        :visible="nbDays === '7'">
        <template v-if="!isLoading">
          <div
            :class="
              displayVolumePercent(
                props.row.weeklyVolume,
                props.row.weeklyrangeVolume,
                true
              )
            ">
            {{
              displayVolumePercent(
                props.row.weeklyVolume,
                props.row.weeklyrangeVolume
              )
            }}
          </div>
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn> -->

      <NeoTableColumn
        v-slot="props"
        field="monthlyVolume"
        label="30d %"
        numeric
        cell-class="is-vcentered"
        :visible="nbDays === '30'"
      >
        <template v-if="!isLoading">
          <div
            :class="
              displayVolumePercent(
                props.row.monthlyVolume,
                props.row.monthlyrangeVolume,
                true,
              )
            "
          >
            {{
              displayVolumePercent(
                props.row.monthlyVolume,
                props.row.monthlyrangeVolume,
              )
            }}
          </div>
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="floorPrice"
        :label="$t('series.floorprice')"
        numeric
        cell-class="is-vcentered"
        sortable
      >
        <template v-if="!isLoading">
          <Money
            :value="props.row.floorPrice"
            inline
            hide-unit
          />
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="averagePrice"
        :label="$t('series.averagePrice')"
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          <Money
            :value="props.row.averagePrice"
            inline
            hide-unit
          />
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="highestSale"
        :label="$t('series.highestSale')"
        numeric
        cell-class="is-vcentered"
        sortable
      >
        <template v-if="!isLoading">
          <Money
            :value="props.row.highestSale"
            inline
            hide-unit
          />
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="buys"
        :label="$t('series.buys')"
        numeric
        cell-class="is-vcentered"
        sortable
      >
        <template v-if="!isLoading">
          {{ props.row.buys }}
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="sold"
        :label="$t('series.owners')"
        sortable
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          {{ props.row.sold }}
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="total"
        :label="$t('series.assets')"
        sortable
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          {{ props.row.total }}
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="rank"
        :label="$t('series.score')"
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          {{ Math.ceil(props.row.rank) }}
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        field="emoteCount"
        :label="$t('series.emoteCount')"
        numeric
        cell-class="is-vcentered"
      >
        <template v-if="!isLoading">
          {{ Math.ceil(props.row.emoteCount) }}
        </template>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        cell-class="is-vcentered text-center"
        field="chart"
        :label="$t('series.chart')"
      >
        <nuxt-link
          v-if="!isLoading"
          :to="`/${urlPrefix}/collection/${props.row.id}?tab=chart&locate=true`"
          target="_blank"
        >
          <NeoIcon icon="chart-line" />
        </nuxt-link>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>

      <NeoTableColumn
        v-slot="props"
        cell-class="is-vcentered text-center"
        field="history"
        :label="$t('series.history')"
      >
        <nuxt-link
          v-if="!isLoading"
          :to="`/${urlPrefix}/collection/${props.row.id}?tab=history&locate=true`"
          target="_blank"
        >
          <NeoIcon icon="list-ul" />
        </nuxt-link>
        <NeoSkeleton :active="isLoading" />
      </NeoTableColumn>
      <NeoTableColumn
        v-slot="props"
        cell-class="is-vcentered text-center w-52 h-24"
        field="buyHistory"
        :label="$t('series.buyHistory')"
      >
        <NeoSkeleton
          v-if="isLoading"
          :active="isLoading"
        />
        <PulseChart
          v-else
          :id="props.row.id"
          :labels="props.row.buyHistory.xAxisList"
          :values="props.row.buyHistory.yAxisList"
        />
      </NeoTableColumn>
      <template #empty>
        <div
          v-if="!isLoading"
          class="w-full text-center"
        >
          {{ $t('spotlight.empty') }}
        </div>
        <NeoSkeleton :active="isLoading" />
      </template>
    </NeoTable>
  </div>
</template>

<script lang="ts" setup>
import {
  NeoField,
  NeoIcon,
  NeoSelect,
  NeoSkeleton,
  NeoTable,
  NeoTableColumn,
} from '@kodadot1/brick'
import type { BuyHistory, RowSeries, SortType } from './types'
import {
  calculateAvgPrice,
  getDateArray,
  lastmonthDate,
  onlyDate,
  toSort,
  today,
} from './utils'
import type { Collection } from '@/types'
import { exist } from '@/utils/exist'
import { sanitizeIpfsUrl } from '@/utils/ipfs'
import collectionsEvents from '@/queries/rmrk/subsquid/collectionsEvents.graphql'
import seriesInsightList from '@/queries/rmrk/subsquid/seriesInsightList.graphql'

import Money from '@/components/shared/format/Money.vue'
import BasicImage from '@/components/shared/view/BasicImage.vue'
import Loader from '@/components/shared/Loader.vue'

const route = useRoute()
const router = useRouter()
const { $consola } = useNuxtApp()
const { client, urlPrefix } = usePrefix()
const nbRows = ref('50')
const nbDays = ref('7')
const isLoading = ref(false)
const sortBy = reactive<SortType>({ field: 'series.volume', value: 'DESC' })

const data = ref<RowSeries[]>([])

const seriesQueryParams = async (limit, sort) => ({
  limit,
  offset: 0,
  orderBy: sort || 'volume_DESC',
  where: {
    volume_isNull: false,
  },
})

const fetchCollectionEvents = async (ids: string[]) => {
  if (ids.length === 0) {
    return []
  }
  try {
    const { data } = await useAsyncQuery({
      clientId: client.value,
      query: collectionsEvents,
      variables: {
        ids: ids,
        and: {
          interaction_eq: 'BUY',
        },
        lte: today,
        gte: lastmonthDate,
      },
    })
    return data.value.events
  }
  catch (e) {
    $consola.error(e)
    return []
  }
}

const fetchCollectionsSeries = async (
  limit = Number(nbRows.value),
  sort: string = toSort(sortBy),
) => {
  isLoading.value = true
  const { data: collections } = await useAsyncQuery({
    clientId: client.value,
    query: seriesInsightList,
    variables: await seriesQueryParams(limit, sort),
  })

  const { collectionEntities } = collections.value

  const defaultBuyEvents = getDateArray(lastmonthDate, today).reduce(
    (res, date) => {
      res[date] = 0
      return res
    },
    {},
  )

  const axisLize = (obj = {}): BuyHistory => ({
    xAxisList: Object.keys(obj),
    yAxisList: Object.values(obj),
  })

  const ids = collectionEntities.map((c: Collection) => c.id)

  const buyEvents = (await fetchCollectionEvents(ids))
    .map(e => ({
      ...e.nft.collection,
      timestamp: onlyDate(new Date(e.timestamp)),
    }))
    .reduce((res, e) => {
      const { id, timestamp: ts } = e
      if (!res[id]) {
        res[id] = Object.assign({}, defaultBuyEvents)
      }
      res[id][ts] += 1
      return res
    }, {})

  data.value = collectionEntities.map(
    (e: RowSeries): RowSeries => ({
      ...e,
      image: sanitizeIpfsUrl(e.image),
      rank: e.sold * (e.unique / e.total || 1),
      averagePrice: calculateAvgPrice(e.volume as string, e.buys),
      buyHistory: axisLize(
        Object.assign({}, defaultBuyEvents, buyEvents[e.id] || {}),
      ),
      emoteCount: e.emoteCount || 0,
    }),
  )

  isLoading.value = false
}

onBeforeMount(async () => {
  exist(route.query.rows, (val) => {
    nbRows.value = val
  })
  exist(route.query.period, (val) => {
    nbDays.value = val
  })
  exist(route.query.sort, (val) => {
    sortBy.field = val.slice(1)
    sortBy.value = val.charAt(0) === '-' ? 'DESC' : 'ASC'
  })
  await fetchCollectionsSeries(Number(nbRows.value))
})

const onSort = (field: string, order: string) => {
  const sort: SortType = {
    field: field,
    value: order === 'desc' ? 'DESC' : 'ASC',
  }
  router
    .replace({
      path: String(route.path),
      query: {
        ...route.query,
        sort: (order === 'desc' ? '-' : '+') + field,
      },
    })
    .catch(e => $consola.warn(e))
  fetchCollectionsSeries(Number(nbRows.value), toSort(sort))
}

const displayVolumePercent = (
  priceNow: number,
  priceAgo: number,
  getClass?: boolean,
) => {
  /* added getClass for getting the class name for the row
   * it would be true when you want to return the class name
   */
  const vol = ((priceNow - priceAgo) / priceAgo) * 100
  if (vol === 0 || !parseFloat(String(vol)) || !isFinite(vol)) {
    return getClass ? '' : '---'
  }
  const volumePercent = Math.round(vol * 100) / 100

  if (getClass) {
    return volumePercent > 0 ? 'text-k-green' : 'text-k-red'
  }

  return volumePercent > 0 ? `+${volumePercent}%` : `${volumePercent}%`
}

watch(client, (value) => {
  if (value) {
    fetchCollectionsSeries(Number(nbRows.value))
  }
})

watch(nbRows, (value: string) => {
  router
    .replace({
      path: String(route.path),
      query: { ...route.query, rows: value },
    })
    .catch(e => $consola.warn(e))
  fetchCollectionsSeries(Number(value))
})

watch(nbDays, (value: string) => {
  router
    .replace({
      path: String(route.path),
      query: { ...route.query, period: value },
    })
    .catch(e => $consola.warn(e))
})
</script>