From 68a58ffc0f89c289e813147f54afdb64153c6598 Mon Sep 17 00:00:00 2001 From: lanford33 Date: Sat, 29 Jul 2023 20:11:14 +0800 Subject: [PATCH] feat: integrate nftstorefront --- components/common/Siderbar.js | 15 +- components/storefront/Listing.js | 35 ++++ components/storefront/ListingGroup.js | 35 ++++ flow/config.js | 1 + flow/storefront_scripts.js | 14 ++ flow/storefront_transactions.js | 94 ++++++++++ pages/account/[account]/storefront/index.js | 175 ++++++++++++++++++ public/scripts/storefront/get_listings.cdc | 112 +++++++++++ .../storefront/cleanup_expired.cdc | 18 ++ .../storefront/cleanup_ghosted.cdc | 20 ++ .../storefront/cleanup_purchased.cdc | 20 ++ publicConfig.js | 4 + 12 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 components/storefront/Listing.js create mode 100644 components/storefront/ListingGroup.js create mode 100644 flow/storefront_scripts.js create mode 100644 flow/storefront_transactions.js create mode 100644 pages/account/[account]/storefront/index.js create mode 100644 public/scripts/storefront/get_listings.cdc create mode 100644 public/transactions/storefront/cleanup_expired.cdc create mode 100644 public/transactions/storefront/cleanup_ghosted.cdc create mode 100644 public/transactions/storefront/cleanup_purchased.cdc diff --git a/components/common/Siderbar.js b/components/common/Siderbar.js index 1ef78bd..810d246 100644 --- a/components/common/Siderbar.js +++ b/components/common/Siderbar.js @@ -10,15 +10,16 @@ export default function Sidebar(props) { let menuItems = [ { id: "0", label: `Basic`, link: { pathname: "/account/[account]", query: { account: account } } }, { id: "1", label: `Key`, link: { pathname: "/account/[account]/key", query: { account: account } } }, - { id: "2", label: `Token`, link: { pathname: "/account/[account]/fungible_token", query: { account: account } } }, - { id: "3", label: `Staking`, link: { pathname: "/account/[account]/staking", query: { account: account } } }, + { id: "2", label: `Staking`, link: { pathname: "/account/[account]/staking", query: { account: account } } }, + { id: "3", label: `Token`, link: { pathname: "/account/[account]/fungible_token", query: { account: account } } }, { id: "4", label: `Collection`, link: { pathname: "/account/[account]/collection", query: { account: account } } }, - { id: "5", label: `Contract`, link: { pathname: "/account/[account]/contract", query: { account: account } } }, + { id: "5", label: `Storefront`, link: { pathname: "/account/[account]/storefront", query: { account: account } } }, + { id: "6", label: `Contract`, link: { pathname: "/account/[account]/contract", query: { account: account } } }, { - id: "6", label: "Storage", subItems: [ - { id: "6-0", isSubItem: true, label: "Public Items", smLabel: "Public", link: { pathname: "/account/[account]/public", query: { account: account } } }, - { id: "6-1", isSubItem: true, label: "Stored Items", smLabel: "Stored", link: { pathname: "/account/[account]/storage", query: { account: account } } }, - { id: "6-2", isSubItem: true, label: "Private Items", smLabel: "Private", link: { pathname: "/account/[account]/private", query: { account: account } } }, + id: "7", label: "Storage", subItems: [ + { id: "7-0", isSubItem: true, label: "Public Items", smLabel: "Public", link: { pathname: "/account/[account]/public", query: { account: account } } }, + { id: "7-1", isSubItem: true, label: "Stored Items", smLabel: "Stored", link: { pathname: "/account/[account]/storage", query: { account: account } } }, + { id: "7-2", isSubItem: true, label: "Private Items", smLabel: "Private", link: { pathname: "/account/[account]/private", query: { account: account } } }, ] } // { id: "5", label: `Analyzer`, link: { pathname: "/account/[account]/analyzer", query: { account: account } } }, diff --git a/components/storefront/Listing.js b/components/storefront/Listing.js new file mode 100644 index 0000000..6b23ec2 --- /dev/null +++ b/components/storefront/Listing.js @@ -0,0 +1,35 @@ +import Decimal from "decimal.js" +import Image from "next/image" +import { useEffect, useState } from "react" + +// const getImageUrl = (listing) => { +// const url = listing?.nft?.metadata?.imageUrl ?? "/token_placeholder.png" +// return url +// } + +const getPaymentTokenSymbol = (listing) => { + const symbol = listing?.paymentTokenInfo?.symbol ?? "UNKN" + return symbol.toUpperCase() +} + +export default function Listing(props) { + const { listing, typeId } = props + const symbol = getPaymentTokenSymbol(listing) + + return ( +
+ {/*
+ +
*/} + + + +
+ ) +} \ No newline at end of file diff --git a/components/storefront/ListingGroup.js b/components/storefront/ListingGroup.js new file mode 100644 index 0000000..9b9c964 --- /dev/null +++ b/components/storefront/ListingGroup.js @@ -0,0 +1,35 @@ +import Listing from "./Listing" + +const getTypeId = (listing) => { + if (!listing) { + return "Unknown" + } + + const rawTypeId = listing.details.nftType.typeID + const comps = rawTypeId.split(".") + return comps[2] +} + +export default function ListingGroup(props) { + const { listings } = props + const firstListing = listings[0] + const typeId = getTypeId(firstListing) + + return ( +
+ +
+ { + listings.map((listing, index) => { + return ( + + ) + }) + } +
+ +
+ ) +} \ No newline at end of file diff --git a/flow/config.js b/flow/config.js index 24cf742..55091b7 100644 --- a/flow/config.js +++ b/flow/config.js @@ -17,4 +17,5 @@ config({ "0xFungibleTokenSwitchboard": publicConfig.fungibleTokenSwitchboardAddress, "0xFlowbox": publicConfig.flowboxAddress, "0xFlowviewAccountBookmark": publicConfig.accountBookmarkAddress, + "0xNFTStorefrontV2": publicConfig.nftStorefrontV2Address, }) \ No newline at end of file diff --git a/flow/storefront_scripts.js b/flow/storefront_scripts.js new file mode 100644 index 0000000..b53fdad --- /dev/null +++ b/flow/storefront_scripts.js @@ -0,0 +1,14 @@ +import * as fcl from "@onflow/fcl" + +export const getListings = async (address) => { + const code = await (await fetch("/scripts/storefront/get_listings.cdc")).text() + + const result = await fcl.query({ + cadence: code, + args: (arg, t) => [ + arg(address, t.Address) + ] + }) + + return result +} diff --git a/flow/storefront_transactions.js b/flow/storefront_transactions.js new file mode 100644 index 0000000..83d6435 --- /dev/null +++ b/flow/storefront_transactions.js @@ -0,0 +1,94 @@ +import * as fcl from "@onflow/fcl" +import { txHandler } from "./transactions" + +export const cleanupGhosted = async ( + account, + listingIds, + setTransactionInProgress, + setTransactionStatus +) => { + const txFunc = async () => { + return await doCleanupGhosted(account, listingIds) + } + + return await txHandler(txFunc, setTransactionInProgress, setTransactionStatus) +} + +const doCleanupGhosted = async (account, listingIds) => { + const code = await (await fetch("/transactions/storefront/cleanup_ghosted.cdc")).text() + + const transactionId = fcl.mutate({ + cadence: code, + args: (arg, t) => [ + arg(account, t.Address), + arg(listingIds, t.Array(t.UInt64)) + ], + proposer: fcl.currentUser, + payer: fcl.currentUser, + limit: 9999 + }) + + return transactionId +} + +export const cleanupPurchased = async ( + account, + listingIds, + setTransactionInProgress, + setTransactionStatus +) => { + const txFunc = async () => { + return await doCleanupPurchased(account, listingIds) + } + + return await txHandler(txFunc, setTransactionInProgress, setTransactionStatus) +} + +const doCleanupPurchased = async (account, listingIds) => { + const code = await (await fetch("/transactions/storefront/cleanup_purchased.cdc")).text() + + const transactionId = fcl.mutate({ + cadence: code, + args: (arg, t) => [ + arg(account, t.Address), + arg(listingIds, t.Array(t.UInt64)) + ], + proposer: fcl.currentUser, + payer: fcl.currentUser, + limit: 9999 + }) + + return transactionId +} + + +export const cleanupExpired = async ( + account, + fromIndex, toIndex, + setTransactionInProgress, + setTransactionStatus +) => { + const txFunc = async () => { + return await doCleanupExpired(account, fromIndex, toIndex) + } + + return await txHandler(txFunc, setTransactionInProgress, setTransactionStatus) +} + +const doCleanupExpired = async (account, fromIndex, toIndex) => { + const code = await (await fetch("/transactions/storefront/cleanup_expired.cdc")).text() + + const transactionId = fcl.mutate({ + cadence: code, + args: (arg, t) => [ + arg(account, t.Address), + arg(fromIndex, t.UInt64), + arg(toIndex, t.UInt64) + ], + proposer: fcl.currentUser, + payer: fcl.currentUser, + limit: 9999 + }) + + return transactionId +} \ No newline at end of file diff --git a/pages/account/[account]/storefront/index.js b/pages/account/[account]/storefront/index.js new file mode 100644 index 0000000..7186dda --- /dev/null +++ b/pages/account/[account]/storefront/index.js @@ -0,0 +1,175 @@ +import { CodeIcon } from "@heroicons/react/outline" +import * as fcl from "@onflow/fcl" +import Image from "next/image" +import { useRouter } from "next/router" +import { useEffect, useState } from "react" +import useSWR from "swr" +import Layout from "../../../../components/common/Layout" +import Spinner from "../../../../components/common/Spinner" +import { isValidFlowAddress } from "../../../../lib/utils" +import publicConfig from "../../../../publicConfig" +import Custom404 from "../404" +import { getListings } from "../../../../flow/storefront_scripts" +import ListingGroup from "../../../../components/storefront/ListingGroup" +import { useRecoilState } from "recoil" +import { + transactionStatusState, + transactionInProgressState +} from "../../../../lib/atoms" +import { cleanupGhosted, cleanupPurchased, cleanupExpired } from "../../../../flow/storefront_transactions" +import { useSWRConfig } from 'swr' + +const listingsFetcher = async (funcName, address) => { + const listings = await getListings(address) + return listings +} + +const groupListings = (listings) => { + let grouped = listings.reduce((acc, listing) => { + const typeId = listing.details.nftType.typeID + if (!acc[typeId]) { + acc[typeId] = []; + } + + acc[typeId].push(listing) + + return acc + }, {}) + + for (let type in grouped) { + grouped[type].sort((a, b) => b.listingResourceId.localeCompare(a.listingResourceId)) + } + + let sortedGroups = Object.keys(grouped) + .sort() + .map(key => grouped[key]); + + return sortedGroups +} + +export default function Storefront(props) { + const [transactionInProgress, setTransactionInProgress] = useRecoilState(transactionInProgressState) + const [, setTransactionStatus] = useRecoilState(transactionStatusState) + const router = useRouter() + const { account } = router.query + const { mutate } = useSWRConfig() + + const [listings, setListings] = useState(null) + const [listingGroups, setListingGroups] = useState(null) + const [user, setUser] = useState({ loggedIn: null }) + + useEffect(() => fcl.currentUser.subscribe(setUser), []) + + const { data: itemsData, error: itemsError } = useSWR( + account && isValidFlowAddress(account) ? ["listingsFetcher", account] : null, listingsFetcher + ) + + useEffect(() => { + if (itemsData) { + setListings(itemsData) + const groupedListings = groupListings(itemsData.validItems) + setListingGroups(groupedListings) + } + }, [itemsData]) + + if (!account) { + return
+ } + + if (!isValidFlowAddress(account)) { + return + } + + const showItems = () => { + if (!listingGroups) { + return ( +
+ +
+ ) + } + + return ( + <> + {listingGroups.length > 0 ? + listingGroups.map((listings, index) => { + return ( + + ) + }) : +
+ Nothing found +
+ } + + ) + } + + return ( +
+ +
+
+ +
+
+

+ {`Listings (${listingGroups ? listingGroups.flat().length : 0})`} +

+ { + listings && listings.invalidItems.length > 0 ? + : null + } +
+
+ + + +
+
+ +
+
+
+ {showItems()} +
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/public/scripts/storefront/get_listings.cdc b/public/scripts/storefront/get_listings.cdc new file mode 100644 index 0000000..4c35ce8 --- /dev/null +++ b/public/scripts/storefront/get_listings.cdc @@ -0,0 +1,112 @@ +import NFTStorefrontV2 from 0x4eb8a10cb9f87357 +import NonFungibleToken from 0x1d7e57aa55817448 +import HWGarageCardV2 from 0xd0bcefdf1e67ea85 +import FTRegistry from 0x097bafa4e0b48eef + +pub struct FTInfo { + pub let symbol: String + pub let icon: String? + + init(symbol: String, icon: String?) { + self.symbol = symbol + self.icon = icon + } +} + +pub struct Listings { + pub let validItems: [Item] + pub let invalidItems: [Item] + + init(validItems: [Item], invalidItems: [Item]) { + self.validItems = validItems + self.invalidItems = invalidItems + } +} + +pub struct Item { + pub let listingResourceId: UInt64 + pub let details: NFTStorefrontV2.ListingDetails + pub let isGhosted: Bool + pub let isPurchased: Bool + pub let isExpired: Bool + pub let nft: &NonFungibleToken.NFT? + pub let paymentTokenInfo: FTInfo? + + init( + listingResourceId: UInt64, + details: NFTStorefrontV2.ListingDetails, + isGhosted: Bool, + isPurchased: Bool, + isExpired: Bool, + nft: &NonFungibleToken.NFT?, + paymentTokenInfo: FTInfo? + ) { + self.listingResourceId = listingResourceId + self.details = details + self.isGhosted = isGhosted + self.isPurchased = isPurchased + self.isExpired = isExpired + self.nft = nft + self.paymentTokenInfo = paymentTokenInfo + } +} + +pub fun main(account: Address): Listings { + let storefrontRef = getAccount(account) + .getCapability<&NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic}>( + NFTStorefrontV2.StorefrontPublicPath + ) + .borrow() + ?? panic("Could not borrow public storefront from address") + + let validItems: [Item] = [] + let invalidItems: [Item] = [] + let listingIds = storefrontRef.getListingIDs() + for id in listingIds { + let listing = storefrontRef.borrowListing(listingResourceID: id) + ?? panic("No item with that ID") + + let details = listing.getDetails() + // SEE: https://discord.com/channels/613813861610684416/1134479469457973318/1134714856277291088 + let isGhosted = !listing.hasListingBecomeGhosted() + let isPurchased = details.purchased + let isExpired = details.expiry <= UInt64(getCurrentBlock().timestamp) + if (isPurchased || isExpired || isGhosted) { + let item = Item( + listingResourceId: id, + details: details, + isGhosted: isGhosted, + isPurchased: isPurchased, + isExpired: isExpired, + nft: nil, + paymentTokenInfo: nil + ) + invalidItems.append(item) + continue + } + + let nft = listing.borrowNFT() + if (nft == nil) { + continue + } + + var ftInfo: FTInfo? = nil + let rawFtInfo = FTRegistry.getFTInfoByTypeIdentifier(details.salePaymentVaultType.identifier) + if let _rawFtInfo = rawFtInfo { + ftInfo = FTInfo(symbol: _rawFtInfo.alias, icon: _rawFtInfo.icon) + } + + let item = Item( + listingResourceId: id, + details: details, + isGhosted: isGhosted, + isPurchased: isPurchased, + isExpired: isExpired, + nft: nft, + paymentTokenInfo: ftInfo + ) + validItems.append(item) + } + + return Listings(validItems: validItems, invalidItems: invalidItems) +} \ No newline at end of file diff --git a/public/transactions/storefront/cleanup_expired.cdc b/public/transactions/storefront/cleanup_expired.cdc new file mode 100644 index 0000000..38fecae --- /dev/null +++ b/public/transactions/storefront/cleanup_expired.cdc @@ -0,0 +1,18 @@ +import NFTStorefrontV2 from 0x4eb8a10cb9f87357 + +transaction(account: Address, from: UInt64, to: UInt64) { + let storefrontRef: &NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic} + + prepare(acct: AuthAccount) { + self.storefrontRef = getAccount(account) + .getCapability<&NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic}>( + NFTStorefrontV2.StorefrontPublicPath + ) + .borrow() + ?? panic("Could not borrow public storefront from address") + } + + execute { + self.storefrontRef.cleanupExpiredListings(fromIndex: from, toIndex: to) + } +} \ No newline at end of file diff --git a/public/transactions/storefront/cleanup_ghosted.cdc b/public/transactions/storefront/cleanup_ghosted.cdc new file mode 100644 index 0000000..e7c7a0c --- /dev/null +++ b/public/transactions/storefront/cleanup_ghosted.cdc @@ -0,0 +1,20 @@ +import NFTStorefrontV2 from 0x4eb8a10cb9f87357 + +transaction(account: Address, listingResourceIds: [UInt64]) { + let storefrontRef: &NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic} + + prepare(acct: AuthAccount) { + self.storefrontRef = getAccount(account) + .getCapability<&NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic}>( + NFTStorefrontV2.StorefrontPublicPath + ) + .borrow() + ?? panic("Could not borrow public storefront from address") + } + + execute { + for id in listingResourceIds { + self.storefrontRef.cleanupGhostListings(listingResourceID: id) + } + } +} \ No newline at end of file diff --git a/public/transactions/storefront/cleanup_purchased.cdc b/public/transactions/storefront/cleanup_purchased.cdc new file mode 100644 index 0000000..0aa5fdb --- /dev/null +++ b/public/transactions/storefront/cleanup_purchased.cdc @@ -0,0 +1,20 @@ +import NFTStorefrontV2 from 0x4eb8a10cb9f87357 + +transaction(account: Address, listingResourceIds: [UInt64]) { + let storefrontRef: &NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic} + + prepare(acct: AuthAccount) { + self.storefrontRef = getAccount(account) + .getCapability<&NFTStorefrontV2.Storefront{NFTStorefrontV2.StorefrontPublic}>( + NFTStorefrontV2.StorefrontPublicPath + ) + .borrow() + ?? panic("Could not borrow public storefront from address") + } + + execute { + for id in listingResourceIds { + self.storefrontRef.cleanupPurchasedListings(listingResourceID: id) + } + } +} \ No newline at end of file diff --git a/publicConfig.js b/publicConfig.js index a632631..a257cb9 100644 --- a/publicConfig.js +++ b/publicConfig.js @@ -55,6 +55,9 @@ if (!incrementURL) throw "Missing NEXT_PUBLIC_INCREMENT_URL" const linkURL = process.env.NEXT_PUBLIC_LINK_URL if (!linkURL) throw "Missing NEXT_PUBLIC_LINK_URL" +const nftStorefrontV2Address = process.env.NEXT_PUBLIC_NFTSTOREFRONTV2_ADDRESS +if (!nftStorefrontV2Address) throw "Missing NEXT_PUBLIC_NFTSTOREFRONTV2_ADDRESS" + const publicConfig = { chainEnv, accessNodeAPI, @@ -69,6 +72,7 @@ const publicConfig = { fungibleTokenSwitchboardAddress, flowboxAddress, accountBookmarkAddress, + nftStorefrontV2Address, flownsURL, findURL, bayouURL,