diff --git a/components/collection/CollectionSwaps.vue b/components/collection/CollectionTrades.vue similarity index 92% rename from components/collection/CollectionSwaps.vue rename to components/collection/CollectionTrades.vue index 8a96140457..be9de7d98c 100644 --- a/components/collection/CollectionSwaps.vue +++ b/components/collection/CollectionTrades.vue @@ -19,8 +19,11 @@ <script setup lang="ts"> import { type TradeTableQuery } from '@/components/trade/TradeActivityTable.vue' +import type { TradeType } from '@/components/trade/types' -const tradeType = TradeType.SWAP +defineProps<{ + tradeType: TradeType +}>() const route = useRoute() diff --git a/components/common/BaseCartItemDetailsSkeleton.vue b/components/common/BaseCartItemDetailsSkeleton.vue new file mode 100644 index 0000000000..6556cce639 --- /dev/null +++ b/components/common/BaseCartItemDetailsSkeleton.vue @@ -0,0 +1,42 @@ +<template> + <div class="w-full h-[50px]"> + <div class="flex justify-between"> + <div class="flex"> + <div> + <NeoSkeleton + no-margin + height="48px" + width="48px" + :rounded="false" + /> + </div> + + <div class="flex flex-col justify-between ml-4 w-[100px] md:w-[170px]"> + <NeoSkeleton + no-margin + :rounded="false" + width="130px" + /> + + <NeoSkeleton + no-margin + :rounded="false" + width="90px" + /> + </div> + </div> + + <div class="flex items-end"> + <NeoSkeleton + no-margin + :rounded="false" + width="60px" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { NeoSkeleton } from '@kodadot1/brick' +</script> diff --git a/components/common/ConnectWallet/WalletAsset.vue b/components/common/ConnectWallet/WalletAsset.vue index 313637d2c7..38db7d6649 100644 --- a/components/common/ConnectWallet/WalletAsset.vue +++ b/components/common/ConnectWallet/WalletAsset.vue @@ -7,7 +7,10 @@ <MultipleBalances /> </div> - <WalletAssetMenu /> + <div class="h-full flex flex-col justify-end gap-5"> + <WalletAssetTrades v-if="tradeVisible(urlPrefix) && vm === walletVm" /> + <WalletAssetMenu /> + </div> </div> </template> @@ -15,7 +18,9 @@ import WalletAssetIdentity from './WalletAssetIdentity.vue' import WalletAssetNfts from './WalletAssetNfts.vue' import WalletAssetMenu from './WalletAssetMenu.vue' +import WalletAssetTrades from './WalletAssetTrades.vue' import { useIdentityStore } from '@/stores/identity' +import { tradeVisible } from '@/utils/config/permission.config' const MultipleBalances = defineAsyncComponent( () => import('@/components/balance/MultipleBalances.vue'), @@ -23,6 +28,9 @@ const MultipleBalances = defineAsyncComponent( const identityStore = useIdentityStore() const { $consola } = useNuxtApp() +const { urlPrefix } = usePrefix() +const { vm } = useChain() +const { getWalletVM: walletVm } = storeToRefs(useWalletStore()) if (identityStore.getAuthAddress) { $consola.log('fetching balance...') diff --git a/components/common/ConnectWallet/WalletAssetMenu.vue b/components/common/ConnectWallet/WalletAssetMenu.vue index 29e5911e08..3501cf5a4e 100644 --- a/components/common/ConnectWallet/WalletAssetMenu.vue +++ b/components/common/ConnectWallet/WalletAssetMenu.vue @@ -1,79 +1,77 @@ <template> - <div class="h-full flex flex-col justify-end"> - <div - :class="{ 'border-t': filteredMenus.length }" - class="wallet-asset-container flex flex-col" - data-testid="sidebar-wallet-container" - > - <div> - <a - v-for="menu in filteredMenus" - :key="menu.label" - v-safe-href="menu.to" - class="wallet-asset-menu" - > - <span>{{ menu.label }}</span> - <NeoIcon - icon="angle-right" - size="medium" - class="text-k-grey" - /> - </a> - </div> - <div class="wallet-asset-footer flex py-5 text-xs text-k-grey"> - <!-- light/dark mode --> - <ColorModeSwitch /> - - <!-- language --> - <div - data-testid="sidebar-language" - class="language-selector" + <div + :class="{ 'border-t': filteredMenus.length }" + class="wallet-asset-container flex flex-col" + data-testid="sidebar-wallet-container" + > + <div> + <a + v-for="menu in filteredMenus" + :key="menu.label" + v-safe-href="menu.to" + class="wallet-asset-menu" + > + <span>{{ menu.label }}</span> + <NeoIcon + icon="angle-right" + size="medium" + class="text-k-grey" + /> + </a> + </div> + <div class="wallet-asset-footer flex py-5 text-xs text-k-grey"> + <!-- light/dark mode --> + <ColorModeSwitch /> + + <!-- language --> + <div + data-testid="sidebar-language" + class="language-selector" + > + <NeoDropdown + position="top-left" + aria-role="menu" + mobile-modal > - <NeoDropdown - position="top-left" - aria-role="menu" - mobile-modal + <template #trigger> + <div class="flex items-center"> + <NeoIcon + icon="globe" + size="medium" + /> + <span class="is-hidden-mobile ml-1"> + {{ $t('profileMenu.language') }} + </span> + </div> + </template> + + <NeoDropdownItem + v-for="lang in langsFlags" + :key="lang.value" + aria-role="listitem" + :data-testid="`sidebar-language-${lang.value}`" + :value="lang.value" + :class="{ 'is-active': $i18n.locale === lang.value }" + @click="usePreferencesStore().setUserLocale(lang.value)" > - <template #trigger> - <div class="flex items-center"> - <NeoIcon - icon="globe" - size="medium" - /> - <span class="is-hidden-mobile ml-1"> - {{ $t('profileMenu.language') }} - </span> - </div> - </template> - - <NeoDropdownItem - v-for="lang in langsFlags" - :key="lang.value" - aria-role="listitem" - :data-testid="`sidebar-language-${lang.value}`" - :value="lang.value" - :class="{ 'is-active': $i18n.locale === lang.value }" - @click="usePreferencesStore().setUserLocale(lang.value)" - > - <span>{{ lang.flag }} {{ lang.label }}</span> - </NeoDropdownItem> - </NeoDropdown> - </div> - - <!-- settings --> - <nuxt-link - to="/settings" - class="text-k-grey items-center" - data-testid="sidebar-link-settings" - @click="closeModal" - > - <NeoIcon - icon="gear" - size="medium" - /> - <span class="is-hidden-mobile">{{ $t('settings') }}</span> - </nuxt-link> + <span>{{ lang.flag }} {{ lang.label }}</span> + </NeoDropdownItem> + </NeoDropdown> </div> + + <!-- settings --> + <nuxt-link + to="/settings" + class="text-k-grey items-center" + data-testid="sidebar-link-settings" + @click="closeModal" + > + <NeoIcon + icon="gear" + size="medium" + /> + <span class="is-hidden-mobile">{{ $t('settings') }}</span> + </nuxt-link> </div> </div> </template> @@ -99,7 +97,7 @@ const menus = ref<{ label: string, to: string, check: (v: Prefix) => boolean }[] check: teleportVisible, }, { - label: $i18n.t('swap.swap'), + label: $i18n.t('swap.createSwap'), to: `/${urlPrefix.value}/swap`, check: swapVisible, }, diff --git a/components/common/ConnectWallet/WalletAssetTrades.vue b/components/common/ConnectWallet/WalletAssetTrades.vue new file mode 100644 index 0000000000..d09e6850e9 --- /dev/null +++ b/components/common/ConnectWallet/WalletAssetTrades.vue @@ -0,0 +1,213 @@ +<template> + <div class="wallet-asset-container"> + <div + v-if="loading" + class="flex flex-col gap-4" + > + <NeoSkeleton + no-margin + height="123px" + /> + + <div class="flex items-center justify-between"> + <NeoSkeleton + no-margin + width="60px" + class="!w-[60px]" + height="14px" + /> + <NeoSkeleton + no-margin + class="!w-[99px]" + width="99px" + border-radius="10px" + height="24px" + /> + </div> + </div> + <div + v-else-if="trades.length" + class="flex flex-col gap-4" + > + <div + class="border border-border-color rounded-lg !p-4 w-full" + > + <div class="flex justify-between items-center"> + <p class="capitalize"> + {{ $t('trades.incomingTrades') }} + </p> + + <NeoButton + variant="icon" + @click="refetch" + > + <NeoIcon + icon="refresh" + /> + </NeoButton> + </div> + + <hr class="my-3"> + + <div class="flex flex-col gap-2"> + <ul> + <li + v-for="trade in trades.slice(0, 2)" + :key="trade.id" + class="flex items-center justify-between" + > + <div class="flex items-center gap-2 max-w-[calc(100%-25px)]"> + <NeoIcon + class="text-k-grey opacity-20 !text-[0.4rem]" + icon="circle" + pack="fass" + size="small" + /> + <div class="flex items-center gap-2 text-sm truncate"> + <nuxt-link + v-if="trade.type === TradeType.SWAP" + :to="`${urlPrefix}/gallery/${trade.offered.id}`" + > + <span> + {{ trade.offered.name }} + </span> + </nuxt-link> + <Money + v-else + :value="trade.price" + inline + /> + + <span class="text-k-grey capitalize"> + {{ $t('for') }} + </span> + + <nuxt-link + v-if="trade.desired" + :to="`${urlPrefix}/gallery/${trade.desired.id}`" + > + <span> + {{ trade.desired.name }} + </span> + </nuxt-link> + <nuxt-link + v-else + :to="`${urlPrefix}/collection/${trade.considered.id}`" + > + <span> + {{ trade.considered.name }} + </span> + </nuxt-link> + </div> + </div> + <span class="text-k-grey text-sm"> + {{ formatDistanceToNow(trade.createdAt) }} + </span> + </li> + </ul> + </div> + </div> + + <div class="flex items-center justify-between"> + <div class="text-sm flex gap-2"> + <span class="text-k-grey"> + {{ $t('count') }}: + </span> + <span> {{ trades.length }} </span> + </div> + + <NeoButton + variant="pill" + size="small" + class="px-4 py-1" + icon="arrow-right" + @click="viewAll" + > + {{ $t('helper.viewAll') }} + </NeoButton> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { NeoIcon, NeoButton, NeoSkeleton } from '@kodadot1/brick' +import { TradeType, type TradeNftItem } from '@/components/trade/types' +import { TRADE_TYPE_TO_PROFILE_TAB_MAP } from '@/components/profile/utils' +import { formatDistanceToNow } from '@/utils/datetime' + +const tradeTypes = [ + TradeType.OFFER, + TradeType.SWAP, +] + +const trades = ref<TradeNftItem[]>([]) +const loadings = ref<boolean[]>([]) +const refetches = ref<ReturnType<typeof useTrades>['refetch'][]>([]) + +const { accountId } = useAuth() +const { urlPrefix } = usePrefix() +const { data: ownedCollections, isFetching, isPending } = useOwnedCollections(accountId) + +const loadingOwnedCollections = computed(() => isPending.value || isFetching.value) +const disabledTrades = computed(() => loadingOwnedCollections.value) +const where = computed(() => buildIncomingTradesQuery(accountId.value, ownedCollections.value?.map(({ id }) => id) || [])) +const loading = computed(() => loadings.value.some(Boolean) || loadingOwnedCollections.value) + +const clear = () => { + loadings.value = new Array(tradeTypes.length).fill(true) + trades.value = [] +} + +const refetch = async () => { + clear() + await Promise.all(refetches.value.map(refetch => refetch())) +} + +const getTradeTypeWithMoreIncomingTrades = (): TradeType | null => { + const groups = Object.groupBy(trades.value, item => item.type) + + const swapLength = groups[TradeType.SWAP]?.length || 0 + const offerLength = groups[TradeType.OFFER]?.length || 0 + + if (swapLength == offerLength) { + return null + } + + return (swapLength > offerLength) + ? TradeType.SWAP + : TradeType.OFFER +} + +const viewAll = () => { + const tab = TRADE_TYPE_TO_PROFILE_TAB_MAP[getTradeTypeWithMoreIncomingTrades() || trades.value[0].type] + + navigateTo(`/${urlPrefix.value}/u/${accountId.value}?tab=${tab}&filter=incoming`) +} + +const init = () => { + clear() + + tradeTypes.forEach((tradeType, index) => { + const { items, loading: tradeLoading, refetch } = useTrades({ + where: where, + disabled: disabledTrades, + type: tradeType, + minimal: true, + }) + + refetches.value[index] = refetch + + watch([tradeLoading, items], ([isLoading, items]) => { + if (!isLoading) { + trades.value = [...trades.value, ...items] + .filter((trade, index, self) => index === self.findIndex(t => t.id === trade.id)) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + loadings.value[index] = false + } + }, { immediate: true }) + }) +} + +onBeforeMount(init) +</script> diff --git a/components/common/SearchInput.vue b/components/common/SearchInput.vue new file mode 100644 index 0000000000..6795eaa7c2 --- /dev/null +++ b/components/common/SearchInput.vue @@ -0,0 +1,73 @@ +<template> + <div> + <NeoAutocomplete + v-model="search" + :data="data" + root-class="neo-input" + debounce-typing="300" + open-on-focus + clearable + item-class="hover:!bg-k-accent-light" + :placeholder="placeholder" + @typing="onSearchFn" + @select="onSelect" + > + <template + v-if="loading" + #header + > + <div class="!text-k-grey"> + {{ $t('loading') }}... + </div> + </template> + <template + v-else-if="!data?.length" + #empty + > + <div class="!text-k-grey"> + {{ $t('general.searchNoResults') }} + </div> + </template> + <template + v-else + #default="{ option }" + > + <slot :item="option" /> + </template> + </NeoAutocomplete> + </div> +</template> + +<script setup lang="ts"> +import { NeoAutocomplete } from '@kodadot1/brick' + +const emit = defineEmits(['select']) +const props = defineProps<{ + placeholder?: string + onSearch: (search: string) => Promise<any[]> +}>() + +const search = ref('') +const loading = ref(false) +const items = ref() +const data = computed(() => loading.value ? [] : items.value) + +const onSearchFn = async () => { + loading.value = true + const response = await props.onSearch(search.value) + items.value = response + loading.value = false +} + +const onSelect = (selected) => { + emit('select', selected) +} + +onBeforeMount(onSearchFn) +</script> + +<style lang="scss" scoped> +.search-input { + @apply w-full; +} +</style> diff --git a/components/explore/tab/TabOnCollection.vue b/components/explore/tab/TabOnCollection.vue index ecdf0e1a0b..a9737bfa25 100644 --- a/components/explore/tab/TabOnCollection.vue +++ b/components/explore/tab/TabOnCollection.vue @@ -12,11 +12,15 @@ :to="`${collectionRute}/activity`" data-testid="collection-tab-activity" /> + <TabItem + :active="route.name === 'prefix-collection-id-offers'" + :text="`${$t('offers')}`" + :to="`${collectionRute}/offers`" + /> <TabItem :active="route.name === 'prefix-collection-id-swaps'" :text="`${$t('swaps')}`" :to="`${collectionRute}/swaps`" - data-testid="collection-tab-swaps" /> </div> </template> diff --git a/components/gallery/GalleryItemTabsPanel/GalleryItemOffers.vue b/components/gallery/GalleryItemTabsPanel/GalleryItemOffers.vue index 50c0d61e96..6bb0fe60ac 100644 --- a/components/gallery/GalleryItemTabsPanel/GalleryItemOffers.vue +++ b/components/gallery/GalleryItemTabsPanel/GalleryItemOffers.vue @@ -9,7 +9,7 @@ <script setup lang="ts"> import GalleryItemTradesTable from './GalleryItemTradesTable.vue' -import { TradeType } from '@/composables/useTrades' +import { TradeType } from '@/components/trade/types' defineProps<{ nftId: string diff --git a/components/gallery/GalleryItemTabsPanel/GalleryItemSwaps.vue b/components/gallery/GalleryItemTabsPanel/GalleryItemSwaps.vue index a22cff1fc5..28ec2a87d1 100644 --- a/components/gallery/GalleryItemTabsPanel/GalleryItemSwaps.vue +++ b/components/gallery/GalleryItemTabsPanel/GalleryItemSwaps.vue @@ -9,7 +9,7 @@ <script setup lang="ts"> import GalleryItemTradesTable from './GalleryItemTradesTable.vue' -import { TradeType } from '@/composables/useTrades' +import { TradeType } from '@/components/trade/types' defineProps<{ nftId: string diff --git a/components/gallery/GalleryItemTabsPanel/GalleryItemTradesTable.vue b/components/gallery/GalleryItemTabsPanel/GalleryItemTradesTable.vue index bdd7466268..84379a8a2b 100644 --- a/components/gallery/GalleryItemTabsPanel/GalleryItemTradesTable.vue +++ b/components/gallery/GalleryItemTabsPanel/GalleryItemTradesTable.vue @@ -14,8 +14,8 @@ /> </div> <NeoTable - v-else-if="offers.length" - :data="offers" + v-else-if="trades.length" + :data="trades" hoverable class="py-5 max-md:!top-0" > @@ -113,7 +113,7 @@ <TradeOwnerButton class="max-md:!w-full" :trade="row as TradeNftItem" - @click="selectOffer" + @click:main="selectOffer" /> </NeoTableColumn> </NeoTable> @@ -126,9 +126,9 @@ </div> <TradeOverviewModal - v-model="isWithdrawTradeModalOpen" + v-model="isTradeModalOpen" :trade="selectedTrade!" - @close="closeTradeOverviewModal" + @close="closeTradeModal" /> </template> @@ -138,8 +138,7 @@ import { NeoTable, NeoTableColumn, } from '@kodadot1/brick' -import type { UnwrapRef } from 'vue' -import { TradeType } from '@/composables/useTrades' +import { type TradeNftItem, TradeType } from '@/components/trade/types' import { formatToNow } from '@/utils/format/time' import Identity from '@/components/identity/IdentityIndex.vue' import useSubscriptionGraphql from '@/composables/useSubscriptionGraphql' @@ -153,11 +152,15 @@ const props = defineProps<{ const { urlPrefix } = usePrefix() const { format } = useFormatAmount() -const isWithdrawTradeModalOpen = ref(false) -const loading = ref(false) -const offers = ref<UnwrapRef<ReturnType<typeof useTrades>['items']>>([]) +const isTradeModalOpen = ref(false) const selectedTrade = ref<TradeNftItem>() -const stopWatch = ref(() => {}) +const tradeIds = ref() + +const { items: trades, loading } = useTrades({ + where: computed(() => ({ id_in: tradeIds.value })), + disabled: computed(() => !Array.isArray(tradeIds.value)), + type: props.type, +}) useSubscriptionGraphql({ query: ` @@ -168,28 +171,17 @@ useSubscriptionGraphql({ id }`, onChange: ({ data }) => { - stopWatch.value?.() - offers.value = [] - - const { items: offersData, loading: offersLoading } = useTrades({ - where: { id_in: data.items?.map(offer => offer.id) }, - type: props.type, - }) - - stopWatch.value = watchEffect(() => { - loading.value = offersLoading.value - offers.value = offersData.value - }) + tradeIds.value = data.items?.map(trade => trade.id) }, }) const selectOffer = (offer: TradeNftItem) => { selectedTrade.value = offer - isWithdrawTradeModalOpen.value = true + isTradeModalOpen.value = true } -const closeTradeOverviewModal = () => { - isWithdrawTradeModalOpen.value = false +const closeTradeModal = () => { + isTradeModalOpen.value = false selectedTrade.value = undefined } </script> diff --git a/components/profile/ProfileDetail.vue b/components/profile/ProfileDetail.vue index 93cd5043ee..ad2d062991 100644 --- a/components/profile/ProfileDetail.vue +++ b/components/profile/ProfileDetail.vue @@ -433,7 +433,7 @@ /> <TradeActivityTable - v-if="[ProfileTab.SWAPS, ProfileTab.OFFERS].includes(activeTab)" + v-if="[ProfileTab.SWAPS, ProfileTab.OFFERS].includes(activeTab) && tradeQuery" :key="activeTab" :query="tradeQuery" :type="{ @@ -481,7 +481,7 @@ import { openProfileCreateModal } from '@/components/profile/create/openProfileM import { getHigherResolutionCloudflareImage } from '@/utils/ipfs' import { offerVisible, swapVisible } from '@/utils/config/permission.config' import { type TradeTableQuery } from '@/components/trade/TradeActivityTable.vue' -import { TradeType } from '@/composables/useTrades' +import { TradeType } from '@/components/trade/types' import { doAfterCheckCurrentChainVM } from '@/components/common/ConnectWallet/openReconnectWalletModal' const NuxtImg = resolveComponent('NuxtImg') @@ -523,9 +523,11 @@ const { isSub } = useIsChain(urlPrefix) const listingCartStore = useListingCartStore() const { vm } = useChain() const { params } = useRoute() - +const id = computed(() => route.params.id.toString() || '') const { hasProfile, userProfile, isFetchingProfile } = useProfile(computed(() => params?.id as string)) +const { data: ownedCollections } = useOwnedCollections(id) + const { data: followers, refresh: refreshFollowers } = useAsyncData( `followersof${route.params.id}`, () => @@ -547,10 +549,14 @@ const refresh = ({ fetchFollowing = true } = {}) => { const followersCount = computed(() => followers.value?.totalCount ?? 0) const followingCount = computed(() => following.value?.totalCount ?? 0) -const tradeQuery = computed<TradeTableQuery>(() => ({ - incoming: `{ status_eq: ACTIVE, desired: { currentOwner_eq: "${id.value}" } }`, - outgoing: `{ status_in: [ACTIVE, EXPIRED], caller_eq: "${id.value}" }`, -})) +const tradeQuery = computed<TradeTableQuery | null>(() => { + return ownedCollections.value + ? { + incoming: `${buildIncomingTradesQuery(id.value, ownedCollections.value.map(({ id }) => id), { stringify: true })}`, + outgoing: `{ status_in: [ACTIVE, EXPIRED], caller_eq: "${id.value}" }`, + } + : null +}) const editProfileConfig: ButtonConfig = { label: $i18n.t('profile.editProfile'), @@ -574,7 +580,6 @@ const followButton = ref() const counts = ref({}) const hasAssetPrefixMap = ref<Partial<Record<ProfileTab, Prefix[]>>>({}) const loadingOtherNetwork = ref(false) -const id = computed(() => route.params.id.toString() || '') const email = ref('') const twitter = ref('') const displayName = ref('') diff --git a/components/profile/utils.ts b/components/profile/utils.ts index 4c1f95b847..0362898b3a 100644 --- a/components/profile/utils.ts +++ b/components/profile/utils.ts @@ -1,3 +1,6 @@ +import { ProfileTab } from './types' +import { TradeType } from '@/components/trade/types' + type LinkableBlock = { id: string regex: RegExp @@ -37,3 +40,8 @@ export const getBioWithLinks = (text: string): string => { .map(processSegment) .join('') } + +export const TRADE_TYPE_TO_PROFILE_TAB_MAP: Record<TradeType, ProfileTab> = { + [TradeType.OFFER]: ProfileTab.OFFERS, + [TradeType.SWAP]: ProfileTab.SWAPS, +} diff --git a/components/shared/filters/Filters.vue b/components/shared/filters/Filters.vue index 6b2520fa64..c7f3391c82 100644 --- a/components/shared/filters/Filters.vue +++ b/components/shared/filters/Filters.vue @@ -34,7 +34,7 @@ /> <TradeFilter - v-if="isCollectionSwaps" + v-if="isCollectionTrades" expanded fluid-padding /> @@ -52,7 +52,7 @@ const route = useRoute() const isCollectionItems = computed(() => route.name === 'prefix-collection-id') const isCollectionActivity = computed(() => route.name === 'prefix-collection-id-activity') -const isCollectionSwaps = computed(() => route.name === 'prefix-collection-id-swaps') +const isCollectionTrades = computed(() => ['prefix-collection-id-swaps', 'prefix-collection-id-offers'].includes(route.name)) const isExploreItems = computed(() => route.name === 'prefix-explore-items') const isPageWithItems = computed(() => isExploreItems.value || isCollectionItems.value) </script> diff --git a/components/shared/filters/modules/TradeFilter.vue b/components/shared/filters/modules/TradeFilter.vue index 5bcf35a439..1ebc0be6f7 100644 --- a/components/shared/filters/modules/TradeFilter.vue +++ b/components/shared/filters/modules/TradeFilter.vue @@ -1,6 +1,6 @@ <template> <SiderbarFilterSection - :title="$t('filters.tradeType', [$t('swap.swap')])" + :title="$t('filters.tradeType', [tradeName])" :expanded="expanded" :fluid-padding="fluidPadding" > @@ -9,7 +9,7 @@ v-model="entire_collection" data-testid="filter-checkbox-buynow" > - {{ $t('filters.tradeCollection', [$t('swaps')]) }} + {{ $t('filters.tradeCollection', [tradeName]) }} </NeoCheckbox> </NeoField> </SiderbarFilterSection> @@ -31,9 +31,12 @@ withDefaults( ) const route = useRoute() +const { $i18n } = useNuxtApp() const { replaceUrl: replaceURL } = useReplaceUrl() +const tradeName = computed(() => route.name === 'prefix-collection-id-swaps' ? $i18n.t('swaps') : $i18n.t('offers')) + const entire_collection = computed({ get: () => route.query?.trade_collection?.toString() === 'true', set: value => applyToUrl({ trade_collection: String(value) }), diff --git a/components/swap/landing.vue b/components/swap/landing.vue index 28a0f7ded4..607397a367 100644 --- a/components/swap/landing.vue +++ b/components/swap/landing.vue @@ -34,40 +34,88 @@ </p> </div> - <form @submit.prevent="handleSubmit"> - <AddressInput - v-model="traderAddress" - :is-invalid="isYourAddress" - :icon-right="!isTraderAddressValid || isYourAddress ? 'close' : undefined" - placeholder="Enter wallet address" - with-address-check - @check="handleAddressCheck" - /> - - <NeoButton - type="submit" - :label="label" - size="large" - class="text-base my-5 capitalize" - expanded - :disabled="disabled" - native-type="submit" - variant="primary" - /> - </form> + <div class="flex flex-col gap-7"> + <form @submit.prevent="handleSubmit"> + <AddressInput + v-model="traderAddress" + :is-invalid="isYourAddress" + :icon-right="!isTraderAddressValid || isYourAddress ? 'close' : undefined" + placeholder="Enter wallet address" + with-address-check + @check="handleAddressCheck" + /> + + <NeoButton + type="submit" + :label="label" + size="large" + class="text-base mt-5 capitalize" + :class="{ + 'mb-5': !showIncomingTrades, + }" + expanded + :disabled="disabled" + native-type="submit" + variant="primary" + /> + </form> + + <div + v-if="showIncomingTrades" + class="flex justify-center" + > + <NeoSkeleton + v-if="isLoadingIncomingTrades" + no-margin + class="!w-[226px]" + width="226px" + border-radius="20px" + height="40px" + /> + + <NeoButton + v-else + :tag="NuxtLink" + :to="`/${urlPrefix}/u/${accountId}?tab=swaps`" + variant="outlined-rounded" + > + {{ $t('swap.yourSwapOffers') }} + + <span class="text-k-grey"> + ({{ swapOffersCount }}) + </span> + + <NeoIcon + class="ml-2" + icon="arrow-right" + /> + </NeoButton> + </div> + </div> </div> </div> </template> <script lang="ts" setup> -import { NeoButton } from '@kodadot1/brick' +import { NeoButton, NeoIcon, NeoSkeleton } from '@kodadot1/brick' -const { isCurrentAccount } = useAuth() +const NuxtLink = resolveComponent('NuxtLink') + +const { isCurrentAccount, accountId } = useAuth() const { $i18n } = useNuxtApp() +const { urlPrefix } = usePrefix() const traderAddress = ref('') const isTraderAddressValid = ref(false) const isYourAddress = ref(false) +const isLoadingSwapOffersCount = ref(true) +const swapOffersCount = ref<number>() +const swapOfferSubscription = ref(() => {}) + +const { data: ownedCollections, isPending: isLoadingOwnedCollections } = useOwnedCollections(accountId) + +const isLoadingIncomingTrades = computed(() => isLoadingSwapOffersCount.value || isLoadingOwnedCollections.value) +const showIncomingTrades = computed(() => Boolean(accountId.value) && (isLoadingIncomingTrades.value || Boolean(swapOffersCount.value))) const isAddressEmpty = computed(() => !traderAddress.value) const disabled = computed(() => isAddressEmpty.value || isYourAddress.value || !isTraderAddressValid.value) @@ -96,4 +144,33 @@ const handleAddressCheck = (isValid: boolean) => { const handleSubmit = async () => { await navigateTo({ name: 'prefix-swap-id', params: { id: traderAddress.value } }) } + +watch([ownedCollections, accountId], ([ownedCollections, account]) => { + swapOffersCount.value = undefined + + if (!account) { + return + } + + if (ownedCollections !== undefined && ownedCollections.length) { + isLoadingSwapOffersCount.value = true + swapOfferSubscription.value = useSubscriptionGraphql<{ swapsConnection: { totalCount: number } }>({ + query: `swapsConnection( + orderBy: blockNumber_DESC, + where: { + OR: [ + ${buildIncomingTradesQuery(accountId.value, ownedCollections.map(({ id }) => id) || [], { stringify: true })}, + { caller_eq: "${accountId.value}" , status_in: [ACTIVE, EXPIRED ]} + ] + }) { + totalCount + }`, + onChange: ({ data: { swapsConnection: { totalCount } } }) => { + isLoadingSwapOffersCount.value = false + swapOffersCount.value = totalCount + swapOfferSubscription.value() + }, + }) + } +}, { immediate: true }) </script> diff --git a/components/trade/TradeActivityTable.vue b/components/trade/TradeActivityTable.vue index 1c8270a2fc..3327a0f833 100644 --- a/components/trade/TradeActivityTable.vue +++ b/components/trade/TradeActivityTable.vue @@ -86,6 +86,11 @@ <script lang="ts" setup> import { NeoButton } from '@kodadot1/brick' +import type { + TradeType, + Swap, + TradeNftItem, +} from '@/components/trade/types' type TradeTabType = 'outgoing' | 'incoming' export type TradeTableQuery = Record<TradeTabType, string> diff --git a/components/trade/TradeActivityTableRow.vue b/components/trade/TradeActivityTableRow.vue index a3b0c3de79..1127e0a0f3 100644 --- a/components/trade/TradeActivityTableRow.vue +++ b/components/trade/TradeActivityTableRow.vue @@ -202,6 +202,13 @@ import { import EventTag from '@/components/collection/activity/events/eventRow/EventTag.vue' import { TradeInteraction } from '@/composables/collectionActivity/types' import { fetchNft } from '@/components/items/ItemsGrid/useNftActions' +import { + type TradeToken, + type TradeConsidered, + type TradeNftItem, + TradeType, + TradeDesiredTokenType, +} from '@/components/trade/types' const EXPIRATION_FORMAT = 'dd.MM. HH:MM' @@ -218,10 +225,10 @@ const getRowConfig = () => { return props.target === 'from' ? { item: props.trade.offered, - desiredType: TradeDesiredType.TOKEN, + desiredType: TradeDesiredTokenType.SPECIFIC, } : { - item: props.trade.isEntireCollectionDesired ? props.trade.considered : props.trade.desired as TradeToken, + item: props.trade.isAnyTokenInCollectionDesired ? props.trade.considered : props.trade.desired as TradeToken, desiredType: props.trade.desiredType, } } @@ -242,13 +249,17 @@ const animationUrl = ref() const isDesktop = computed(() => props.variant === 'Desktop') -const isTradeCollection = computed(() => desiredType === TradeDesiredType.COLLECTION) -const itemPath = computed(() => isTradeCollection.value ? `/${urlPrefix.value}/collection/${item.id}` : `/${urlPrefix.value}/gallery/${item.id}`) +const isItemCollection = computed(() => desiredType === TradeDesiredTokenType.ANY_IN_COLLECTION) +const itemPath = computed(() => isItemCollection.value ? `/${urlPrefix.value}/collection/${item.id}` : `/${urlPrefix.value}/gallery/${item.id}`) const targetAddress = computed(() => props.target === 'to' ? item.currentOwner : props.trade.caller) const interactionName = computed(() => interactionNameMap()[interaction]) const getAvatar = async (nft) => { + if (!nft.metadata) { + return + } + const meta = await getNftMetadata(nft) image.value = meta.image animationUrl.value = meta.animationUrl @@ -257,8 +268,8 @@ const getAvatar = async (nft) => { // TODO imporve nft fetching onBeforeMount(() => { const fetchImageMap = { - [TradeDesiredType.TOKEN]: (item: Item) => fetchNft(item.id).then(getAvatar), - [TradeDesiredType.COLLECTION]: (item: Item) => image.value = sanitizeIpfsUrl(item.image), + [TradeDesiredTokenType.SPECIFIC]: (item: Item) => fetchNft(item.id).then(getAvatar), + [TradeDesiredTokenType.ANY_IN_COLLECTION]: (item: Item) => image.value = sanitizeIpfsUrl(item.image), } fetchImageMap[desiredType]?.(item) diff --git a/components/trade/TradeOwnerButton.vue b/components/trade/TradeOwnerButton.vue index ea00bf2dd7..1634e77ef6 100644 --- a/components/trade/TradeOwnerButton.vue +++ b/components/trade/TradeOwnerButton.vue @@ -7,7 +7,7 @@ :button="buttonConfig" /> - <template v-if="isTargetOfTrade && detailed && trade.type === TradeType.SWAP"> + <template v-if="isTargetOfTrade && detailed && trade.type === TradeType.SWAP && !trade.isAnyTokenInCollectionDesired"> <NeoTooltip position="top" content-class="capitalize" @@ -15,7 +15,6 @@ > <NeoButton variant="icon" - :disabled="trade.isEntireCollectionDesired" @click="emit('click:counter-swap')" > <NeoIcon @@ -30,12 +29,14 @@ <script setup lang="ts"> import { NeoButton, NeoIcon, NeoTooltip } from '@kodadot1/brick' import type { ButtonConfig } from '../profile/types' -import { TradeType } from '@/composables/useTrades' +import { TradeType, type TradeNftItem } from '@/components/trade/types' const emit = defineEmits(['click:main', 'click:counter-swap']) const props = defineProps<{ trade: TradeNftItem loading?: boolean + disabled?: boolean + label?: string mainClass?: string detailed?: boolean }>() @@ -60,7 +61,7 @@ const details = { }, } -const buttonConfig = computed<ButtonConfig | null>(() => { +const tradeButtonConfig = computed<ButtonConfig | null>(() => { if (props.trade.isExpired) { return isCreatorOfTrade.value ? { @@ -87,4 +88,18 @@ const buttonConfig = computed<ButtonConfig | null>(() => { return null }) + +const buttonConfig = computed<ButtonConfig | null>(() => { + if (!tradeButtonConfig.value) { + return null + } + + const config = { ...tradeButtonConfig.value } + + Object.assign(config, { disabled: props.disabled }) + + props.label && Object.assign(config, { label: props.label }) + + return config +}) </script> diff --git a/components/trade/overviewModal/CollectionItemDetails.vue b/components/trade/overviewModal/CollectionItemDetails.vue new file mode 100644 index 0000000000..a0aa2f52f5 --- /dev/null +++ b/components/trade/overviewModal/CollectionItemDetails.vue @@ -0,0 +1,16 @@ +<template> + <BaseCartItemDetails + :name="trade.considered.name" + :second-name="$t('collection')" + :image="sanitizeIpfsUrl(trade.considered.image)" + /> +</template> + +<script setup lang="ts"> +import { sanitizeIpfsUrl } from '@/utils/ipfs' +import { type TradeNftItem } from '@/components/trade/types' + +defineProps<{ + trade: TradeNftItem +}>() +</script> diff --git a/components/trade/overviewModal/Content.vue b/components/trade/overviewModal/Content.vue new file mode 100644 index 0000000000..ceaf112c76 --- /dev/null +++ b/components/trade/overviewModal/Content.vue @@ -0,0 +1,67 @@ +<template> + <div + class="py-5" + > + <div + class="flex flex-col gap-5" + :class="{ + 'flex-col-reverse': isMyTrade && isSwap, + }" + > + <!-- Desired --> + <TokenInCollection + v-if="trade.isAnyTokenInCollectionDesired" + :trade="trade" + :send-item="sendItem" + @send-item:select="$emit('send-item:select', $event)" + @send-item:clear="$emit('send-item:clear')" + /> + <TokenItemDetails + v-else-if="desired" + :nft="desired" + /> + + <template v-if="isSwap"> + <NeoIcon + class="rotate-90" + icon="arrow-right-arrow-left" + /> + + <!-- Offered --> + <TokenItemDetails + :nft="offered" + /> + </template> + </div> + + <hr class="!my-5"> + + <TradeOverviewModalDetails + :trade="trade" + :desired="desired" + /> + </div> +</template> + +<script setup lang="ts"> +import { NeoIcon } from '@kodadot1/brick' +import { useIsTradeOverview } from './utils' +import TokenItemDetails from './TokenItemDetails.vue' +import TokenInCollection from './TokenInCollection.vue' +import { + type TradeNftItem, + TradeType, +} from '@/components/trade/types' +import type { NFT } from '@/types' + +defineEmits(['send-item:select', 'send-item:clear']) +const props = defineProps<{ + desired?: NFT + offered: NFT + trade: TradeNftItem + sendItem?: NFT | null +}>() + +const { isMyTrade } = useIsTradeOverview(computed(() => props.trade)) +const isSwap = computed(() => props.trade.type === TradeType.SWAP) +</script> diff --git a/components/trade/overviewModal/Details.vue b/components/trade/overviewModal/Details.vue index 869521ff69..18c8f1acab 100644 --- a/components/trade/overviewModal/Details.vue +++ b/components/trade/overviewModal/Details.vue @@ -28,7 +28,7 @@ </div> <div - v-if="isIncomingTrade" + v-if="isIncomingTrade && desired" class="flex justify-between items-center" > <span class="text-k-grey text-xs"> @@ -45,6 +45,8 @@ <script setup lang="ts"> import { useIsTradeOverview } from './utils' import { formatToNow } from '@/utils/format/time' +import { blank } from '@/components/collection/activity/events/eventRow/common' +import { type TradeNftItem } from '@/components/trade/types' import type { NFT } from '@/types' const props = defineProps<{ @@ -56,6 +58,14 @@ const { decimals, chainSymbol } = useChain() const { isMyTrade, isIncomingTrade } = useIsTradeOverview(computed(() => props.trade)) const getFormattedDifference = (a: number, b: number) => { + if (b === 0 && a === 0) { + return blank + } + + if (b === 0 && a > 0) { + return '+100%' + } + const diff = ((b - a) / b) * 100 return diff > 0 @@ -63,7 +73,7 @@ const getFormattedDifference = (a: number, b: number) => { : `+${Math.abs(diff).toFixed()}%` } -const floorPrice = computed(() => Number(props.desired?.collection.floorPrice[0].price) || 0) +const floorPrice = computed(() => Number(props.desired?.collection.floorPrice[0]?.price) || 0) const diff = computed(() => getFormattedDifference(Number(props.trade.price || 0), floorPrice.value)) const { formatted: formmatedOffer, usd: offerUsd } = useAmount( diff --git a/components/trade/overviewModal/SubmitButton.vue b/components/trade/overviewModal/SubmitButton.vue new file mode 100644 index 0000000000..76c2b6d385 --- /dev/null +++ b/components/trade/overviewModal/SubmitButton.vue @@ -0,0 +1,22 @@ +<template> + <div + v-if="trade" + class="!pt-5" + > + <TradeOwnerButton + class="!w-full" + :trade="trade" + @click="$emit('submit')" + /> + </div> +</template> + +<script setup lang="ts"> +import { type TradeNftItem } from '@/components/trade/types' + +defineEmits(['submit']) +defineProps<{ + trade: TradeNftItem + disabled?: boolean +}>() +</script> diff --git a/components/trade/overviewModal/TokenInCollection.vue b/components/trade/overviewModal/TokenInCollection.vue new file mode 100644 index 0000000000..70f643cc0b --- /dev/null +++ b/components/trade/overviewModal/TokenInCollection.vue @@ -0,0 +1,76 @@ +<template> + <div + class="flex flex-col gap-4" + > + <div class="flex flex-col gap-2"> + <div class="text-sm font-semibold capitalize"> + Any in collection + </div> + <CollectionItemDetails + :trade="trade" + /> + </div> + <div + v-if="isTargetOfTrade" + class="flex flex-col gap-2" + > + <div class="flex justify-between items-center h-[1.5rem]"> + <div class="text-sm font-semibold capitalize"> + To trade + </div> + + <NeoButton + v-if="sendItem" + variant="icon" + @click="() => { + selected = undefined + $emit('send-item:clear') + }" + > + <NeoIcon + icon="close" + size="small" + /> + </NeoButton> + </div> + + <TokenItemDetails + v-if="selected" + :nft="sendItem" + /> + + <TokenSearchInput + v-else + :where="{ + currentOwner_eq: accountId, + collection: { + id_eq: trade.considered.id, + }, + }" + @select="sendItem => { + selected = sendItem + $emit('send-item:select', sendItem) + }" + /> + </div> + </div> +</template> + +<script setup lang="ts"> +import { NeoButton, NeoIcon } from '@kodadot1/brick' +import CollectionItemDetails from './CollectionItemDetails.vue' +import TokenSearchInput from './TokenSearchInput.vue' +import TokenItemDetails from './TokenItemDetails.vue' +import type { NFT } from '@/types' +import { type TradeNftItem } from '@/components/trade/types' + +defineEmits(['send-item:select', 'send-item:clear']) +const props = defineProps<{ + sendItem?: NFT | null + trade: TradeNftItem +}>() + +const selected = ref() +const { accountId } = useAuth() +const { isTargetOfTrade } = useIsTrade(computed(() => props.trade), accountId) +</script> diff --git a/components/trade/overviewModal/TokenItemDetails.vue b/components/trade/overviewModal/TokenItemDetails.vue new file mode 100644 index 0000000000..711d65c797 --- /dev/null +++ b/components/trade/overviewModal/TokenItemDetails.vue @@ -0,0 +1,36 @@ +<template> + <CartItemDetails + v-if="nft" + :nft="nftToOfferItem(nft)" + > + <template #right> + <div class="flex items-end flex-shrink-0"> + {{ formattedPrice }} + </div> + </template> + </CartItemDetails> + <BaseCartItemDetailsSkeleton v-else /> +</template> + +<script setup lang="ts"> +import { nftToOfferItem } from '@/components/common/shoppingCart/utils' +import type { NFT } from '@/types' + +const props = defineProps<{ + nft?: NFT | null +}>() + +const formattedPrice = ref() +const { decimals, chainSymbol } = useChain() + +watchEffect(() => { + const nft = props.nft + if (nft) { + formattedPrice.value = useAmount( + computed(() => nft.price), + decimals, + chainSymbol, + ).formatted.value + } +}) +</script> diff --git a/components/trade/overviewModal/TokenSearchInput.vue b/components/trade/overviewModal/TokenSearchInput.vue new file mode 100644 index 0000000000..7b213cd761 --- /dev/null +++ b/components/trade/overviewModal/TokenSearchInput.vue @@ -0,0 +1,59 @@ +<template> + <SearchInput + placeholder="Search by name" + :on-search="onSearch" + @select="s => $emit('select', s)" + > + <template + #default="{ item }" + > + <div + :key="item.id" + class="flex items-center gap-2" + > + <BaseMediaItem + class="border border-border-color w-8 h-8" + :src="sanitizeIpfsUrl(item.meta.image)" + :alt="item.name" + /> + <div class="flex flex-col"> + <div class="text-sm font-semibold text-text-color"> + {{ item.name }} + </div> + <div class="text-xs text-k-grey"> + {{ item.collection?.name }} + </div> + </div> + </div> + </template> + </SearchInput> +</template> + +<script setup lang="ts"> +import { sanitizeIpfsUrl } from '@/utils/ipfs' + +defineEmits(['select']) +const props = defineProps<{ + where: Record<string, unknown> +}>() + +const onSearch = async (searchKey: string) => { + const search = { + name_containsInsensitive: searchKey, + } + + if (props.where) { + Object.assign(search, props.where) + } + + const { data } = await useAsyncGraphql({ + query: 'nftListWithSearch', + variables: { + search: search, + first: 5, + }, + }) + + return data.value.nFTEntities +} +</script> diff --git a/components/trade/overviewModal/TradeOverviewModal.vue b/components/trade/overviewModal/TradeOverviewModal.vue index f9cc3d785b..a0b0d36bba 100644 --- a/components/trade/overviewModal/TradeOverviewModal.vue +++ b/components/trade/overviewModal/TradeOverviewModal.vue @@ -19,31 +19,28 @@ :loading="loading" @close="onClose" > - <div> - <ModalIdentityItem /> - - <template v-if="trade && nft"> - <TradeOverviewModalTypeSwap - v-if="trade.type === TradeType.SWAP" - :desired="nft.desired" - :offered="nft.offered" - :trade="trade" - /> - <TradeOverviewModalTypeOffer - v-if="trade.type === TradeType.OFFER && nft.desired" - :desired="nft.desired" - :trade="trade" - /> - </template> - </div> + <ModalIdentityItem /> + + <TradeOverviewModalContent + v-if="trade && nft" + :key="session" + :desired="nft.desired" + :offered="nft.offered" + :trade="trade" + :send-item="sendItem" + @send-item:select="selectSendItem" + @send-item:clear="clearSendItem" + /> <div v-if="trade" class="!pt-5" > <TradeOwnerButton - main-class="!w-full" + main-class="!w-full capitalize" :trade="trade" + :label="label" + :disabled="disabled" @click:main="execTransaction" /> </div> @@ -54,12 +51,21 @@ <script setup lang="ts"> import { NeoModal } from '@kodadot1/brick' -import { useIsTradeOverview, type OverviewMode } from './utils' +import { useQuery } from '@tanstack/vue-query' +import { + type OverviewMode, + type ExecTxParams, + useIsTradeOverview, + TradeTypeTx, +} from './utils' import ModalBody from '@/components/shared/modals/ModalBody.vue' import ModalIdentityItem from '@/components/shared/ModalIdentityItem.vue' -import nftById from '@/queries/subsquid/general/nftById.graphql' -import { TradeType } from '@/composables/useTrades' +import { + type TradeNftItem, + TradeType, +} from '@/components/trade/types' import type { NFT } from '@/types' +import { fetchNft } from '@/components/items/ItemsGrid/useNftActions' type OverviewModeDetails = { title: string @@ -74,24 +80,7 @@ type Details = { type TradeNFTs = { desired?: NFT, offered: NFT } -type ExecTxParams = { - trade: TradeNftItem -} - -const emit = defineEmits(['close']) -const props = defineProps<{ - modelValue: boolean - trade?: TradeNftItem -}>() - -const vModel = useVModel(props, 'modelValue') - -const { accountId } = useAuth() -const { urlPrefix, client } = usePrefix() -const { transaction, status, isError, isLoading } = useTransaction({ disableSuccessNotification: true }) const { $i18n } = useNuxtApp() -const { notification, lastSessionId, updateSession } = useLoadingNotfication() -const { mode } = useIsTradeOverview(computed(() => props.trade)) const TradeTypeOverviewModeDetails: Record<TradeType, Record<OverviewMode, OverviewModeDetails>> = { [TradeType.SWAP]: { @@ -131,51 +120,32 @@ const TradeTypeDetails: Record<TradeType, Details> = { }, } -const TradeTypeTx: Record<TradeType, Record<OverviewMode, (params: ExecTxParams) => void>> = { - [TradeType.SWAP]: { - owner: ({ trade }) => { - transaction({ - interaction: ShoppingActions.CANCEL_SWAP, - urlPrefix: urlPrefix.value, - offeredId: trade.offered.sn, - offeredCollectionId: trade.offered.collection.id, - }) - }, - incoming: ({ trade }) => { - transaction({ - interaction: ShoppingActions.ACCEPT_SWAP, - urlPrefix: urlPrefix.value, - receiveItem: trade.offered.sn, - receiveCollection: trade.offered.collection.id, - sendCollection: trade.considered.id, - sendItem: trade.desired?.sn, - price: trade.price, - surcharge: (trade as TradeNftItem<Swap>).surcharge, - }) - }, - }, - [TradeType.OFFER]: { - owner: ({ trade }) => { - transaction({ - interaction: ShoppingActions.CANCEL_OFFER, - urlPrefix: urlPrefix.value, - offeredId: trade.offered.sn, - }) - }, - incoming: ({ trade }) => { - transaction({ - interaction: ShoppingActions.ACCEPT_OFFER, - urlPrefix: urlPrefix.value, - receiveItem: trade.offered.sn, - sendCollection: trade.considered.id, - sendItem: trade.desired?.sn, - price: trade.price, - }) - }, - }, -} +const emit = defineEmits(['close']) +const props = defineProps<{ + modelValue: boolean + trade?: TradeNftItem +}>() + +const selectedSendItemId = ref<string>() +const session = ref<string>() +const vModel = useVModel(props, 'modelValue') + +const { accountId } = useAuth() +const { urlPrefix } = usePrefix() +const { transaction, status, isError, isLoading } = useTransaction({ disableSuccessNotification: true }) +const { notification, lastSessionId, updateSession } = useLoadingNotfication() +const { mode, isIncomingTrade } = useIsTradeOverview(computed(() => props.trade)) const trade = computed(() => props.trade) +const needsToSelectSendItem = computed(() => isIncomingTrade.value && !sendItem.value && Boolean(trade.value?.isAnyTokenInCollectionDesired)) +const disabled = computed(() => needsToSelectSendItem.value) + +const label = computed<string | undefined>(() => { + if (needsToSelectSendItem.value) { + return $i18n.t('trade.selectSendItem') + } + return undefined +}) const details = computed<Details & OverviewModeDetails>(() => trade.value @@ -191,48 +161,54 @@ const details = computed<Details & OverviewModeDetails>(() => transactionSuccessTab: '', }) -const { data: nft, pending: nftLoading } = await useAsyncData<TradeNFTs | null>(`tarde-nft-id-${trade.value?.id}`, async () => { - if (!trade.value) { - return null - } +const { data: nft, isLoading: nftLoading } = useQuery<TradeNFTs | null>({ + queryKey: ['trade-nft', computed(() => trade.value?.id)], + queryFn: async () => { + if (!trade.value) { + return null + } - const promises = [ - useAsyncQuery<{ nftEntity: NFT }>({ - query: nftById, - variables: { - id: trade.value.offered.id, - }, - clientId: client.value, - }), - ] - - if (trade.value.desired) { - promises.push( - useAsyncQuery<{ nftEntity: NFT }>({ - query: nftById, - variables: { - id: trade.value.desired.id, - }, - clientId: client.value, - }), - ) - } + const promises = [ + fetchNft(trade.value.offered.id), + ] - const [offered, desired] = await Promise.all(promises) + if (trade.value.desired) { + promises.push(fetchNft(trade.value.desired.id)) + } - return { - offered: offered.data.value?.nftEntity, - desired: desired?.data.value?.nftEntity, - } -}, { watch: [trade] }) + const [offered, desired] = await Promise.all(promises) + + return { + offered, + desired, + } + }, +}) + +const { data: sendItem } = useQuery({ + queryKey: ['send-item', selectedSendItemId], + queryFn: async () => { + if (!selectedSendItemId.value) { + return null + } + return await fetchNft(selectedSendItemId.value) + }, +}) const loading = computed(() => nftLoading.value || !nft.value) +const selectSendItem = item => selectedSendItemId.value = item.id +const clearSendItem = () => selectedSendItemId.value = undefined + const onClose = () => { vModel.value = false onModalAnimation(() => emit('close')) } +const reset = () => { + clearSendItem() +} + const execTransaction = () => { if (!nft.value || !trade.value) { return @@ -240,11 +216,27 @@ const execTransaction = () => { vModel.value = false - TradeTypeTx[trade.value.type][mode.value]({ - trade: props.trade, - } as ExecTxParams) + const params: ExecTxParams = { + trade: trade.value, + transaction, + urlPrefix: urlPrefix.value, + sendItem: trade.value.desired?.sn || sendItem.value?.sn as string, + } + + TradeTypeTx[trade.value.type][mode.value](params) } +const initSession = () => { + session.value = window.crypto.randomUUID() + reset() +} + +useModalIsOpenTracker({ + isOpen: vModel, + onClose: false, + onChange: initSession, +}) + useTransactionNotification({ status, isError, diff --git a/components/trade/overviewModal/TypeOffer.vue b/components/trade/overviewModal/TypeOffer.vue deleted file mode 100644 index 3b50bd86a4..0000000000 --- a/components/trade/overviewModal/TypeOffer.vue +++ /dev/null @@ -1,40 +0,0 @@ -<template> - <div - class="py-5" - > - <CartItemDetails - :nft="nftToOfferItem(desired)" - > - <template #right> - <div class="flex items-end flex-shrink-0"> - {{ desiredFormatted }} - </div> - </template> - </CartItemDetails> - - <hr class="!my-5"> - - <TradeOverviewModalDetails - :trade="trade" - :desired="desired" - /> - </div> -</template> - -<script setup lang="ts"> -import { nftToOfferItem } from '@/components/common/shoppingCart/utils' -import type { NFT } from '@/types' - -const props = defineProps<{ - desired: NFT - trade: TradeNftItem -}>() - -const { decimals, chainSymbol } = useChain() - -const { formatted: desiredFormatted } = useAmount( - computed(() => props.desired.price), - decimals, - chainSymbol, -) -</script> diff --git a/components/trade/overviewModal/TypeSwap.vue b/components/trade/overviewModal/TypeSwap.vue deleted file mode 100644 index b7f02a72c5..0000000000 --- a/components/trade/overviewModal/TypeSwap.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> - <div - class="py-5" - > - <div - class="flex flex-col gap-5" - :class="{ - 'flex-col-reverse': isMyTrade, - }" - > - <BaseCartItemDetails - v-if="trade.isEntireCollectionDesired" - :name="trade.considered.name" - :second-name="$t('collection')" - :image="sanitizeIpfsUrl(trade.considered.image)" - /> - <CartItemDetails - v-else-if="desired" - :nft="nftToOfferItem(desired)" - > - <template #right> - <div class="flex items-end flex-shrink-0"> - {{ desiredFormatted }} - </div> - </template> - </CartItemDetails> - - <NeoIcon - class="rotate-90" - icon="arrow-right-arrow-left" - /> - - <CartItemDetails - :nft="nftToOfferItem(offered)" - > - <template #right> - <div class="flex items-end flex-shrink-0"> - {{ oferredFormatted }} - </div> - </template> - </CartItemDetails> - </div> - - <hr class="!my-5"> - - <TradeOverviewModalDetails - :trade="trade" - :desired="desired" - /> - </div> -</template> - -<script setup lang="ts"> -import { NeoIcon } from '@kodadot1/brick' -import { useIsTradeOverview } from './utils' -import { nftToOfferItem } from '@/components/common/shoppingCart/utils' -import { sanitizeIpfsUrl } from '@/utils/ipfs' -import type { NFT } from '@/types' - -const props = defineProps<{ - desired?: NFT - offered: NFT - trade: TradeNftItem -}>() - -const desiredFormatted = ref('') - -const { isMyTrade } = useIsTradeOverview(computed(() => props.trade)) -const { decimals, chainSymbol } = useChain() - -if (!props.trade.isEntireCollectionDesired) { - desiredFormatted.value = useAmount( - computed(() => props.desired?.price), - decimals, - chainSymbol, - ).formatted.value -} - -const { formatted: oferredFormatted } = useAmount( - computed(() => props.offered.price), - decimals, - chainSymbol, -) -</script> diff --git a/components/trade/overviewModal/utils.ts b/components/trade/overviewModal/utils.ts index 2b43eecf52..b5bef60d42 100644 --- a/components/trade/overviewModal/utils.ts +++ b/components/trade/overviewModal/utils.ts @@ -1,3 +1,10 @@ +import type { Prefix } from '@kodadot1/static' +import { + type Swap, + type TradeNftItem, + TradeType, +} from '@/components/trade/types' + export type OverviewMode = 'owner' | 'incoming' export const useIsTradeOverview = (trade: ComputedRef<TradeNftItem | undefined>) => { @@ -15,3 +22,54 @@ export const useIsTradeOverview = (trade: ComputedRef<TradeNftItem | undefined>) mode, } } + +export type ExecTxParams = { + trade: TradeNftItem + sendItem?: string + transaction: ReturnType<typeof useTransaction>['transaction'] + urlPrefix: Prefix +} + +export const TradeTypeTx: Record<TradeType, Record<OverviewMode, (params: ExecTxParams) => void>> = { + [TradeType.SWAP]: { + owner: ({ trade, urlPrefix, transaction }) => { + transaction({ + interaction: ShoppingActions.CANCEL_SWAP, + urlPrefix: urlPrefix, + offeredId: trade.offered.sn, + offeredCollectionId: trade.offered.collection.id, + }) + }, + incoming: ({ trade, sendItem, urlPrefix, transaction }) => { + transaction({ + interaction: ShoppingActions.ACCEPT_SWAP, + urlPrefix: urlPrefix, + receiveItem: trade.offered.sn, + receiveCollection: trade.offered.collection.id, + sendCollection: trade.considered.id, + sendItem: sendItem, + price: trade.price, + surcharge: (trade as TradeNftItem<Swap>).surcharge, + }) + }, + }, + [TradeType.OFFER]: { + owner: ({ trade, transaction, urlPrefix }) => { + transaction({ + interaction: ShoppingActions.CANCEL_OFFER, + urlPrefix: urlPrefix, + offeredId: trade.offered.sn, + }) + }, + incoming: ({ trade, sendItem, transaction, urlPrefix }) => { + transaction({ + interaction: ShoppingActions.ACCEPT_OFFER, + urlPrefix: urlPrefix, + receiveItem: trade.offered.sn, + sendCollection: trade.considered.id, + sendItem: sendItem, + price: trade.price, + }) + }, + }, +} diff --git a/components/trade/types.ts b/components/trade/types.ts index cc8c213060..da0e2c3d86 100644 --- a/components/trade/types.ts +++ b/components/trade/types.ts @@ -18,3 +18,69 @@ export type MakingOfferItem = { metadata: string sn: string } + +export enum TradeStatus { + ACTIVE = 'ACTIVE', + EXPIRED = 'EXPIRED', + WITHDRAWN = 'WITHDRAWN', + ACCEPTED = 'ACCEPTED', +} + +export type TradeToken = { + id: string + name: string + sn: string + currentOwner: string + image: string + collection: { + id: string + } + meta: Record<string, unknown> +} + +export type TradeConsidered = { + id: string + name: string + currentOwner: string + image: string +} + +export type BaseTrade = { + id: string + price: string + expiration: string + blockNumber: string + status: TradeStatus + caller: string + offered: TradeToken + desired: TradeToken | null + considered: TradeConsidered + createdAt: Date +} + +export enum TradeDesiredTokenType { + SPECIFIC, + ANY_IN_COLLECTION, +} + +export enum TradeType { + SWAP = 'swap', + OFFER = 'offer', +} + +export type Swap = BaseTrade & { + surcharge: string | null +} + +export type Offer = BaseTrade + +type Trade = Swap | Offer + +export type TradeNftItem<T = Trade> = T & { + expirationDate: Date + type: TradeType + desiredType: TradeDesiredTokenType + isAnyTokenInCollectionDesired: boolean + targets: string[] + isExpired: boolean +} diff --git a/composables/transaction/transactionMintToken.ts b/composables/transaction/transactionMintToken.ts index f21e3a48cb..ff9b98b3c7 100644 --- a/composables/transaction/transactionMintToken.ts +++ b/composables/transaction/transactionMintToken.ts @@ -1,9 +1,9 @@ +import type { Prefix } from '@kodadot1/static' import type { MintTokenParams, SubstrateMintTokenParams } from './types' import { execMintStatemine } from './mintToken/transactionMintStatemine' export function execMintToken({ item, ...params }: MintTokenParams) { - // item.urlPrefix === 'ahr' - if (item.urlPrefix === 'ahk' || item.urlPrefix === 'ahp') { + if (isAssetHub(item.urlPrefix as Prefix)) { return execMintStatemine({ item, ...params, diff --git a/composables/transaction/transactionOffer.ts b/composables/transaction/transactionOffer.ts index 178cf410ef..bdd4115bd7 100644 --- a/composables/transaction/transactionOffer.ts +++ b/composables/transaction/transactionOffer.ts @@ -17,7 +17,8 @@ export const getOfferCollectionId = (prefix: Prefix) => { export const OFFER_MINT_PRICE = 5e8 -export const BLOCKS_PER_DAY = 300 * 24 // 12sec /block --> 300blocks/hr +export const BLOCKS_PER_HOUR = 300 +export const BLOCKS_PER_DAY = BLOCKS_PER_HOUR * 24 // 12sec /block --> 300blocks/hr async function execMakingOffer(item: ActionOffer, api: ApiPromise, executeTransaction) { const { accountId } = useAuth() diff --git a/composables/transaction/utils.ts b/composables/transaction/utils.ts index deee6ad00b..e51583bf2c 100644 --- a/composables/transaction/utils.ts +++ b/composables/transaction/utils.ts @@ -60,7 +60,7 @@ export function isActionValid(action: Actions): boolean { [ShoppingActions.ACCEPT_SWAP]: (action: ActionAcceptSwap) => Boolean(action.receiveItem) && Boolean(action.receiveCollection) && Boolean(action.sendItem) && Boolean(action.sendCollection), [ShoppingActions.ACCEPT_OFFER]: (action: ActionAcceptOffer) => - Boolean(action.sendItem && action.sendCollection && action.price && action.receiveItem), + Boolean(action.sendCollection && action.price && action.receiveItem), [Interaction.MINT]: (action: ActionMintCollection) => Boolean(action.collection), [Collections.DELETE]: (action: ActionDeleteCollection) => diff --git a/composables/useIsTrade.ts b/composables/useIsTrade.ts index 77e6ebbea2..bc9ce176ed 100644 --- a/composables/useIsTrade.ts +++ b/composables/useIsTrade.ts @@ -1,6 +1,8 @@ +import type { TradeNftItem } from '@/components/trade/types' + export default function (trade: ComputedRef<TradeNftItem | undefined>, target: MaybeRef<string>) { const isCreatorOfTrade = computed(() => trade.value?.caller === unref(target)) - const isTargetOfTrade = computed(() => (trade.value?.isEntireCollectionDesired ? trade.value?.considered : trade.value?.desired)?.currentOwner === unref(target)) + const isTargetOfTrade = computed(() => trade.value?.targets.includes(unref(target))) return { isCreatorOfTrade, diff --git a/composables/useOwnedCollections.ts b/composables/useOwnedCollections.ts new file mode 100644 index 0000000000..2c2425e706 --- /dev/null +++ b/composables/useOwnedCollections.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/vue-query' +import collectionIdList from '@/queries/subsquid/general/collectionIdList.graphql' + +export function useOwnedCollections(id: Ref<string>, { client = usePrefix().client } = {}) { + return useQuery({ + queryKey: ['ownedCollections', id], + queryFn: async () => { + const { data } = await useAsyncQuery<{ collectionEntities: { id: string }[] }>({ + query: collectionIdList, + variables: { + search: { + nfts_some: { + currentOwner_eq: id.value, + }, + }, + }, + clientId: client.value, + }) + + return data.value.collectionEntities + }, + }) +} diff --git a/composables/useSubscriptionGraphql.ts b/composables/useSubscriptionGraphql.ts index 1460d3b3ac..8d82899429 100644 --- a/composables/useSubscriptionGraphql.ts +++ b/composables/useSubscriptionGraphql.ts @@ -1,7 +1,7 @@ import isEqual from 'lodash/isEqual' import { apolloClientConfig } from '@/utils/constants' -export default function ({ +export default function useSubscriptionGraphql<T = any>({ clientName = '', query, onChange, @@ -12,7 +12,7 @@ export default function ({ }: { clientName?: string query: string - onChange: (data) => void + onChange: (data: { data: T }) => void onError?: (error) => void pollingInterval?: number disabled?: ComputedRef<boolean> @@ -27,7 +27,7 @@ export default function ({ return () => {} } - let lastQueryResult = null + let lastQueryResult: T | null = null let intervalId: number | null = null const isPolling = ref(false) @@ -45,7 +45,7 @@ export default function ({ }, }) - const newResult = response.data as any + const newResult = response.data as T if (!isEqual(newResult, lastQueryResult)) { if (!lastQueryResult ? immediate : true) { diff --git a/composables/useTrades.ts b/composables/useTrades.ts index 4d7a12b8f9..ce14b3abc6 100644 --- a/composables/useTrades.ts +++ b/composables/useTrades.ts @@ -1,69 +1,24 @@ import type { DocumentNode } from 'graphql' -import { addHours } from 'date-fns' +import { addSeconds, subSeconds } from 'date-fns' import swapsList from '@/queries/subsquid/general/swapsList.graphql' import offersList from '@/queries/subsquid/general/offersList.graphql' - -export enum TradeStatus { - ACTIVE = 'ACTIVE', - EXPIRED = 'EXPIRED', - WITHDRAWN = 'WITHDRAWN', - ACCEPTED = 'ACCEPTED', -} - -export type TradeToken = { +import { BLOCKS_PER_HOUR } from '@/composables/transaction/transactionOffer' +import { + type Offer, + type Swap, + type BaseTrade, + type TradeNftItem, + TradeType, + TradeDesiredTokenType, + TradeStatus, +} from '@/components/trade/types' + +type CollectionWithTokenOwners = { id: string - name: string - sn: string - currentOwner: string - image: string - collection: { + nfts: { id: string - } - meta: Record<string, unknown> -} - -export type TradeConsidered = { - id: string - name: string - currentOwner: string - image: string -} - -type BaseTrade = { - id: string - price: string - expiration: string - blockNumber: string - status: TradeStatus - caller: string - offered: TradeToken - desired: TradeToken | null - considered: TradeConsidered -} - -export enum TradeDesiredType { - TOKEN, - COLLECTION, -} - -export enum TradeType { - SWAP, - OFFER, -} - -export type Swap = BaseTrade & { - surcharge: string | null -} - -type Offer = BaseTrade - -type Trade = Swap | Offer - -export type TradeNftItem<T = Trade> = T & { - expirationDate?: Date - type: TradeType - desiredType: TradeDesiredType - isEntireCollectionDesired: boolean + currentOwner: string + }[] isExpired: boolean } @@ -78,58 +33,131 @@ export const TRADES_QUERY_MAP: Record<TradeType, { queryDocument: DocumentNode, }, } -const BLOCKS_PER_HOUR = 300 +const SECONDS_PER_BLOCK = 3600 / BLOCKS_PER_HOUR -export default function ({ where = {}, limit = 100, disabled = computed(() => false), type = TradeType.SWAP }: { - where?: MaybeRef<Record<string, unknown>> +export type UseTradesParams = { + where?: MaybeRef<Record<string, unknown> | string> limit?: number disabled?: ComputedRef<boolean> type?: TradeType -}) { + minimal?: boolean + orderBy?: string[] +} + +export default function ({ + where = {}, + limit = 100, + disabled = computed(() => false), + type = TradeType.SWAP, + minimal = false, + orderBy = ['blockNumber_DESC'], +}: UseTradesParams) { const { queryDocument, dataKey } = TRADES_QUERY_MAP[type] + const items = ref<TradeNftItem[]>([]) + const targetsOfTrades = ref<Map<string, string[]>>() + const ownersSubscription = ref(() => { }) + const { client } = usePrefix() const currentBlock = useCurrentBlock() - const variables = computed(() => ({ - where: unref(where), - limit: limit, - })) - const { result: data, loading: fetching, + refetch, } = useQuery<{ offers: Offer[] } | { swpas: Swap[] }>( queryDocument, - variables, + computed(() => ({ + where: unref(where), + limit: limit, + orderBy: orderBy, + })), computed(() => ({ enabled: !disabled.value, clientId: client.value, })), ) - const items = computed<TradeNftItem[]>(() => { - return data.value?.[dataKey]?.map((trade) => { - const desiredType = trade.desired ? TradeDesiredType.TOKEN : TradeDesiredType.COLLECTION + const dataItems = computed<Offer[] | Swap[]>(() => data.value?.[dataKey] || []) + const hasTargetsOfTrades = computed(() => Boolean(targetsOfTrades.value)) + const tradeKeys = computed<string>(() => dataItems.value.map(item => item.id).join('-')) + const needsToSubscribe = computed(() => minimal ? false : !hasTargetsOfTrades.value) + const loading = computed(() => !currentBlock.value || fetching.value || needsToSubscribe.value) + + const subscribeToTargetsOfTrades = (trades: BaseTrade[]) => { + ownersSubscription.value = useSubscriptionGraphql<{ collections: CollectionWithTokenOwners[] }>({ + query: ` + collections: collectionEntities(where: { + id_in: ${JSON.stringify(trades.map(item => item.considered.id))} + }) { + id + nfts { + id + currentOwner + } + } + `, + onChange: ({ data: { collections } }) => { + const map = new Map() + + const collectionMap: Record<string, CollectionWithTokenOwners['nfts']> = Object.fromEntries( + collections.map(collection => [collection.id, collection.nfts]), + ) + + trades.forEach((trade) => { + const tradeDesired = trade.desired + map.set(trade.id, + tradeDesired + ? [collectionMap[tradeDesired.collection.id].find(nft => nft.id === tradeDesired.id)?.currentOwner] + : collectionMap[trade.considered.id].map(nft => nft.currentOwner), + ) + }) + + targetsOfTrades.value = map + }, + }) + } + + if (!minimal) { + watch([tradeKeys, () => Boolean(data.value)], ([newTradeKeys, hasFetched], [oldTradeKeys]) => { + const hasSubscription = targetsOfTrades.value !== undefined + const tradeKeysChanged = newTradeKeys !== oldTradeKeys + + if (hasFetched && (!hasSubscription || tradeKeysChanged)) { + ownersSubscription.value() + targetsOfTrades.value = undefined + subscribeToTargetsOfTrades(dataItems.value) + } + }) + } + + watchEffect(() => { + if (needsToSubscribe.value || !currentBlock.value) { + return + } + + items.value = dataItems.value.map((trade) => { + const desiredType = trade.desired ? TradeDesiredTokenType.SPECIFIC : TradeDesiredTokenType.ANY_IN_COLLECTION return { ...trade, - expirationDate: currentBlock.value ? addHours(new Date(), (Number(trade.expiration) - currentBlock.value) / BLOCKS_PER_HOUR) : undefined, + expirationDate: addSeconds(new Date(), ((Number(trade.expiration) - currentBlock.value) * SECONDS_PER_BLOCK)), offered: trade.nft, desiredType: desiredType, - isEntireCollectionDesired: desiredType === TradeDesiredType.COLLECTION, + isAnyTokenInCollectionDesired: desiredType === TradeDesiredTokenType.ANY_IN_COLLECTION, // Check block number to handle trades that are expired but not yet updated in indexer // @see https://github.com/kodadot/stick/blob/9eac12938c47bf0e66e93760231208e4249d8637/src/mappings/utils/cache.ts#L127 isExpired: trade.status === TradeStatus.EXPIRED || currentBlock.value > Number(trade.expiration), type, + targets: targetsOfTrades.value?.get(trade.id) || [], + createdAt: subSeconds(new Date(), ((currentBlock.value - Number(trade.blockNumber)) * SECONDS_PER_BLOCK)), } as TradeNftItem - }) || [] + }) }) - const loading = computed(() => !currentBlock.value || fetching.value) - return { items, loading, + refetch, } } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 0434ab0940..2b53a36ac0 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -285,6 +285,7 @@ "decline": "Decline", "notice": "We use cookies for better service, see {0} for details." }, + "count": "Count", "create": "Create", "create collection": "Create Collection", "createDropdown": { @@ -930,6 +931,7 @@ "resending": "Resending", "resetAll": "Reset All", "search": "Search", + "searchNoResults": "No results", "searchNoResultsText": "Give it another shot with different parameters or check back later - New awesome NFTs are added all the time", "searchNoResultsTitle": "Whoops, Couldn't find anything matching your search", "searchPlaceholder": "Search by Collection or NFT", @@ -1530,6 +1532,7 @@ "invalidPrice": "Your offer must greater than 0.0001", "manageOffers": "Manage Offers", "newOffer": "New Offer", + "offer": "Offer", "offerAccept": "Accepting Offer", "offerCancellation": "Offer Cancellation", "offerCreation": "Offer Creation", @@ -1842,6 +1845,7 @@ "connectTraderInfo": "Enter the wallet address of the trader you want to engage with, and we’ll guide you through the secure process of making a swap offer.", "counterSwap": "Counter Swap", "counterparty": "Counterparty", + "createSwap": "Create Swap", "created": "Swap Created", "creatingSwap": "Creating Swap", "incomingSwap": "Incoming Swap", @@ -1870,7 +1874,8 @@ "yourAddress": "Your Address", "yourOffer": "Your offer", "yourSwap": "Your Swap", - "yourSwapList": "Your Swap List" + "yourSwapList": "Your Swap List", + "yourSwapOffers": "Your Swap Offers" }, "swaps": "Swaps", "tabs": { @@ -2001,6 +2006,8 @@ "week": "7D" } }, + "trade": { "selectSendItem": "Select NFT First" }, + "trades": { "incomingTrades": "Incoming Offers/Trades" }, "transaction": { "acceptOffer": "Accept Offer", "acceptSwap": "Accept Swap", diff --git a/pages/[prefix]/collection/[id]/offers.vue b/pages/[prefix]/collection/[id]/offers.vue new file mode 100644 index 0000000000..e07c28f4c7 --- /dev/null +++ b/pages/[prefix]/collection/[id]/offers.vue @@ -0,0 +1,13 @@ +<template> + <ExploreLayoutWithSidebar> + <CollectionTrades :trade-type="TradeType.OFFER" /> + </ExploreLayoutWithSidebar> +</template> + +<script setup lang="ts"> +import { TradeType } from '@/components/trade/types' + +definePageMeta({ + layout: 'explore-layout', +}) +</script> diff --git a/pages/[prefix]/collection/[id]/swaps.vue b/pages/[prefix]/collection/[id]/swaps.vue index d2bb971e23..ed4fe5e20c 100644 --- a/pages/[prefix]/collection/[id]/swaps.vue +++ b/pages/[prefix]/collection/[id]/swaps.vue @@ -1,10 +1,12 @@ <template> <ExploreLayoutWithSidebar> - <CollectionSwaps /> + <CollectionTrades :trade-type="TradeType.SWAP" /> </ExploreLayoutWithSidebar> </template> <script setup lang="ts"> +import { TradeType } from '@/components/trade/types' + definePageMeta({ layout: 'explore-layout', }) diff --git a/queries/subsquid/general/collectionIdList.graphql b/queries/subsquid/general/collectionIdList.graphql new file mode 100644 index 0000000000..1b81b8aaba --- /dev/null +++ b/queries/subsquid/general/collectionIdList.graphql @@ -0,0 +1,11 @@ +query collectionIdList( + $search: CollectionEntityWhereInput + $orderBy: [CollectionEntityOrderByInput!] = [blockNumber_DESC] +) { + collectionEntities( + orderBy: $orderBy + where: $search + ) { + id + } +} diff --git a/utils/datetime.ts b/utils/datetime.ts index 22f445447b..b2702ef7d9 100644 --- a/utils/datetime.ts +++ b/utils/datetime.ts @@ -1,4 +1,5 @@ -import { isWithinInterval, subDays } from 'date-fns' +import { isWithinInterval, subDays, formatDistanceToNow as dfnsFormatDistanceToNow } from 'date-fns' +import { enUS } from 'date-fns/locale' export function parseDate(ts: number | Date): string { return new Date(ts).toLocaleString() @@ -9,3 +10,25 @@ export const isDateWithinLastDays = (date: Date, days: number) => start: subDays(new Date(), days), end: new Date(), }) + +export const formatDistanceToNow = (date: Date) => { + return dfnsFormatDistanceToNow(date, { + addSuffix: false, + locale: { + ...enUS, + formatDistance: (token, count) => { + const formats = { + xSeconds: count => `${count}s`, + xMinutes: count => `${count}m`, + xHours: count => `${count}h`, + xDays: count => `${count}d`, + lessThanXSeconds: count => `<${count}s`, + lessThanXMinutes: count => `<${count}m`, + aboutXHours: count => `${count}h`, + } + + return formats[token](count) + }, + }, + }) +} diff --git a/utils/swap.ts b/utils/swap.ts index 7326fbca94..d2a638b655 100644 --- a/utils/swap.ts +++ b/utils/swap.ts @@ -1,4 +1,5 @@ import { SwapStep } from '@/components/swap/types' +import type { TradeToken } from '@/components/trade/types' import type { NFT } from '@/types' export const SWAP_ROUTE_NAME_STEP_MAP = { diff --git a/utils/trades.ts b/utils/trades.ts new file mode 100644 index 0000000000..cf82da020e --- /dev/null +++ b/utils/trades.ts @@ -0,0 +1,33 @@ +export const buildIncomingTradesQuery = (address: string, considereds: string[], { stringify = false } = {}) => { + const query = { + AND: [ + { + status_eq: 'ACTIVE', + caller_not_eq: address, + }, + { + OR: [ + { desired: { currentOwner_eq: address } }, + { + considered: { + id_in: considereds, + }, + desired_isNull: true, + }, + ], + }, + ], + } + + // Both version of this query are needed, one as string and one as object + // super hacky, but it works + if (stringify) { + return JSON.stringify(query) + // Remove quotes around keys (e.g., "status_eq": becomes status_eq:) + .replace(/"(\w+)":/g, '$1:') + // Remove quotes around uppercase values (e.g., "ACTIVE" becomes ACTIVE) + .replace(/"([A-Z]+)"/g, '$1') + } + + return query +}