From d0a9e3d6d45dd210c526aede98f5ee41e0b238ef Mon Sep 17 00:00:00 2001 From: escobarjonatan Date: Wed, 24 Jul 2024 23:50:10 -0500 Subject: [PATCH] FEATURE: Marketplace Tab V1 Implementation (#675) * STUD-247: Add Marketplace tab to view details song page * STUD-251: Add wallet connect to marketplace tab (#679) * STUD-249 UI for active sale (#696) * STUD-251: Add wallet connect to marketplace tab * STUD-251: Add the ability to filter wallet by given songs and apply it to the marketplace tab * STUD-251: Rename component * STUD-251: Add wallet as a dependency to fetch new song tokens * STUD-249: Added active sale and create sale UI along with endpoints * STUD-252: Add ability to create stream token sale * STUD-252: Add ping functionality for sale end and start * STUD-252: Add initial logic for sale start and end pending state * STUD-252: Fix minor details * STUD-252: Update min sale value * Stud 275 sale confirmation modal (#697) * STUD-275: Add start sale summary modal with price breakdown * STUD-275: Fix Typography component coming from newm vs mui * STUD-286: Add sale end and sale start ui and modify polling logic (#698) * STUD-286: Add sale end and sale start ui and modify polling logic * STUD-293: Add feature flag endpoints for studio and marketplace (#699) * STUD-293: Add feature flag endpoints for studio and marketplace * STUD-289: Update change address reference and fix minor refresh bug on ping sale end (#701) --- apps/marketplace/src/api/constants.ts | 1 + .../src/components/ArtistSongs.tsx | 2 +- apps/marketplace/src/components/MoreSongs.tsx | 3 +- .../src/components/RecentSongs.tsx | 2 +- apps/marketplace/src/components/Sale.tsx | 2 +- .../src/components/SaleMetadata.tsx | 2 +- apps/marketplace/src/components/Sales.tsx | 2 +- .../src/components/SearchResults.tsx | 3 +- .../src/components/SimilarSongs.tsx | 3 +- apps/marketplace/src/modules/content/api.ts | 31 +++ apps/marketplace/src/modules/content/index.ts | 2 + apps/marketplace/src/modules/content/types.ts | 9 + apps/marketplace/src/modules/sale/api.ts | 4 +- apps/marketplace/src/modules/sale/index.ts | 2 - apps/marketplace/src/modules/sale/thunks.ts | 2 +- apps/marketplace/src/modules/sale/utils.ts | 23 -- apps/marketplace/src/temp/data.ts | 2 +- .../src/modules/wallet/thunks.ts | 6 +- .../src/modules/wallet/utils.ts | 24 --- apps/studio/src/App.tsx | 4 + apps/studio/src/api/constants.ts | 7 + apps/studio/src/api/newm/api.ts | 1 + apps/studio/src/api/newm/types.ts | 3 +- apps/studio/src/api/utils.ts | 18 +- apps/studio/src/common/constants.ts | 26 +++ apps/studio/src/common/types.ts | 9 + apps/studio/src/components/index.ts | 3 + .../src/components/sales/PingSaleEnd.tsx | 121 +++++++++++ .../src/components/sales/PingSaleStart.tsx | 137 ++++++++++++ .../skeletons/MarketplaceTabSkeleton.tsx | 22 ++ apps/studio/src/modules/content/api.ts | 25 ++- apps/studio/src/modules/content/types.ts | 9 + apps/studio/src/modules/crypto/api.ts | 50 +++++ apps/studio/src/modules/crypto/index.ts | 2 + apps/studio/src/modules/crypto/types.ts | 9 + apps/studio/src/modules/sale/api.ts | 163 +++++++++++++++ apps/studio/src/modules/sale/index.ts | 3 + apps/studio/src/modules/sale/thunks.ts | 194 +++++++++++++++++ apps/studio/src/modules/sale/types.ts | 74 +++++++ apps/studio/src/modules/song/types.ts | 29 +++ .../library/MarketplaceTab/ActiveSale.tsx | 88 ++++++++ .../library/MarketplaceTab/ConnectWallet.tsx | 46 ++++ .../library/MarketplaceTab/CreateSale.tsx | 196 ++++++++++++++++++ .../library/MarketplaceTab/EndSaleModal.tsx | 73 +++++++ .../library/MarketplaceTab/MarketplaceTab.tsx | 49 +++++ .../home/library/MarketplaceTab/Sale.tsx | 94 +++++++++ .../library/MarketplaceTab/SaleEndPending.tsx | 30 +++ .../MarketplaceTab/SaleStartPending.tsx | 64 ++++++ .../library/MarketplaceTab/StartSaleModal.tsx | 195 +++++++++++++++++ .../home/library/MarketplaceTab/index.ts | 1 + .../src/pages/home/library/ViewDetails.tsx | 25 +++ package-lock.json | 8 +- package.json | 2 +- packages/elements/src/lib/DropdownSelect.tsx | 5 +- .../src/lib/form/DropdownSelectField.tsx | 2 + packages/types/src/index.ts | 1 + .../types/src/lib/sale.ts | 6 +- packages/utils/src/index.ts | 1 + packages/utils/src/lib/sales.ts | 66 ++++++ packages/utils/src/lib/wallet.ts | 25 +++ 60 files changed, 1924 insertions(+), 87 deletions(-) create mode 100644 apps/marketplace/src/modules/content/api.ts create mode 100644 apps/marketplace/src/modules/content/index.ts create mode 100644 apps/marketplace/src/modules/content/types.ts delete mode 100644 apps/marketplace/src/modules/sale/utils.ts delete mode 100644 apps/mobile-wallet-connector/src/modules/wallet/utils.ts create mode 100644 apps/studio/src/components/sales/PingSaleEnd.tsx create mode 100644 apps/studio/src/components/sales/PingSaleStart.tsx create mode 100644 apps/studio/src/components/skeletons/MarketplaceTabSkeleton.tsx create mode 100644 apps/studio/src/modules/crypto/api.ts create mode 100644 apps/studio/src/modules/crypto/index.ts create mode 100644 apps/studio/src/modules/crypto/types.ts create mode 100644 apps/studio/src/modules/sale/api.ts create mode 100644 apps/studio/src/modules/sale/index.ts create mode 100644 apps/studio/src/modules/sale/thunks.ts create mode 100644 apps/studio/src/modules/sale/types.ts create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/ActiveSale.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/ConnectWallet.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/CreateSale.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/EndSaleModal.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/MarketplaceTab.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/Sale.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/SaleEndPending.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/SaleStartPending.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/StartSaleModal.tsx create mode 100644 apps/studio/src/pages/home/library/MarketplaceTab/index.ts rename apps/marketplace/src/modules/sale/types.ts => packages/types/src/lib/sale.ts (97%) create mode 100644 packages/utils/src/lib/sales.ts diff --git a/apps/marketplace/src/api/constants.ts b/apps/marketplace/src/api/constants.ts index ef91a5610..ee39d7e74 100644 --- a/apps/marketplace/src/api/constants.ts +++ b/apps/marketplace/src/api/constants.ts @@ -7,6 +7,7 @@ export const recaptchaEndpointActionMap: Record = { generateTransaction: "generate_order_transaction", getArtist: "get_artist", getArtists: "get_artists", + getMarketplaceClientConfig: "marketplace_config", getSale: "get_sale", getSales: "get_sales", }; diff --git a/apps/marketplace/src/components/ArtistSongs.tsx b/apps/marketplace/src/components/ArtistSongs.tsx index 8378cfd3b..c27938cdc 100644 --- a/apps/marketplace/src/components/ArtistSongs.tsx +++ b/apps/marketplace/src/components/ArtistSongs.tsx @@ -1,8 +1,8 @@ import { Box } from "@mui/material"; import { FunctionComponent } from "react"; +import { SaleStatus } from "@newm-web/types"; import Sales from "./Sales"; import { useGetSalesQuery } from "../modules/sale/api"; -import { SaleStatus } from "../modules/sale"; interface ArtistSongsProps { readonly artistId: string; diff --git a/apps/marketplace/src/components/MoreSongs.tsx b/apps/marketplace/src/components/MoreSongs.tsx index 8ff2367d1..75d114cb9 100644 --- a/apps/marketplace/src/components/MoreSongs.tsx +++ b/apps/marketplace/src/components/MoreSongs.tsx @@ -1,7 +1,8 @@ import { FunctionComponent } from "react"; import { Box, Typography, useTheme } from "@mui/material"; +import { SaleStatus } from "@newm-web/types"; import Sales from "./Sales"; -import { SaleStatus, useGetSalesQuery } from "../modules/sale"; +import { useGetSalesQuery } from "../modules/sale"; interface MoreSongsProps { readonly artistId?: string; diff --git a/apps/marketplace/src/components/RecentSongs.tsx b/apps/marketplace/src/components/RecentSongs.tsx index 7342ace06..c3caf7b22 100644 --- a/apps/marketplace/src/components/RecentSongs.tsx +++ b/apps/marketplace/src/components/RecentSongs.tsx @@ -1,8 +1,8 @@ import { Box } from "@mui/material"; import { FunctionComponent } from "react"; +import { SaleStatus } from "@newm-web/types"; import Sales from "./Sales"; import { useGetSalesQuery } from "../modules/sale/api"; -import { SaleStatus } from "../modules/sale"; const RecentSongs: FunctionComponent = () => { const { data, isLoading } = useGetSalesQuery({ diff --git a/apps/marketplace/src/components/Sale.tsx b/apps/marketplace/src/components/Sale.tsx index 999c4eb45..4f6fee9c8 100644 --- a/apps/marketplace/src/components/Sale.tsx +++ b/apps/marketplace/src/components/Sale.tsx @@ -16,8 +16,8 @@ import { Form, Formik } from "formik"; import { formatNewmAmount } from "@newm-web/utils"; import { useRouter } from "next/navigation"; import { usePlayAudioUrl } from "@newm-web/audio"; +import { Sale as SaleItem } from "@newm-web/types"; import { SaleSkeleton } from "../components"; -import { Sale as SaleItem } from "../modules/sale"; import { usePurchaseStreamTokensThunk } from "../modules/sale/thunks"; interface FormValues { diff --git a/apps/marketplace/src/components/SaleMetadata.tsx b/apps/marketplace/src/components/SaleMetadata.tsx index 2962ee1a5..11fd55fb6 100644 --- a/apps/marketplace/src/components/SaleMetadata.tsx +++ b/apps/marketplace/src/components/SaleMetadata.tsx @@ -2,7 +2,7 @@ import { FunctionComponent } from "react"; import { Link, Stack, Typography } from "@mui/material"; import theme from "@newm-web/theme"; import moment from "moment"; -import { Sale } from "../modules/sale"; +import { Sale } from "@newm-web/types"; interface SaleMetaDataProps { readonly sale?: Sale; diff --git a/apps/marketplace/src/components/Sales.tsx b/apps/marketplace/src/components/Sales.tsx index 8badfcdcb..dcb44bc0e 100644 --- a/apps/marketplace/src/components/Sales.tsx +++ b/apps/marketplace/src/components/Sales.tsx @@ -3,8 +3,8 @@ import { Box, Grid, Stack, Typography } from "@mui/material"; import { SongCard } from "@newm-web/components"; import { useRouter } from "next/navigation"; import { usePlayAudioUrl } from "@newm-web/audio"; +import { Sale } from "@newm-web/types"; import SalesSkeleton from "./skeletons/SalesSkeleton"; -import { Sale } from "../modules/sale/types"; interface SalesProps { readonly hasTitle?: boolean; diff --git a/apps/marketplace/src/components/SearchResults.tsx b/apps/marketplace/src/components/SearchResults.tsx index bb21edea1..0b2986a21 100644 --- a/apps/marketplace/src/components/SearchResults.tsx +++ b/apps/marketplace/src/components/SearchResults.tsx @@ -1,7 +1,8 @@ import { FunctionComponent } from "react"; import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material"; +import { SaleStatus } from "@newm-web/types"; import Sales from "./Sales"; -import { SaleStatus, useGetSalesQuery } from "../modules/sale"; +import { useGetSalesQuery } from "../modules/sale"; interface SearchResultsProps { readonly query: string; diff --git a/apps/marketplace/src/components/SimilarSongs.tsx b/apps/marketplace/src/components/SimilarSongs.tsx index 319b98f36..799306484 100644 --- a/apps/marketplace/src/components/SimilarSongs.tsx +++ b/apps/marketplace/src/components/SimilarSongs.tsx @@ -1,7 +1,8 @@ import { FunctionComponent } from "react"; import { Box } from "@mui/material"; +import { SaleStatus } from "@newm-web/types"; import Sales from "./Sales"; -import { SaleStatus, useGetSalesQuery } from "../modules/sale"; +import { useGetSalesQuery } from "../modules/sale"; interface SimilarSongsProps { readonly currentArtistId?: string; diff --git a/apps/marketplace/src/modules/content/api.ts b/apps/marketplace/src/modules/content/api.ts new file mode 100644 index 000000000..663ec17f7 --- /dev/null +++ b/apps/marketplace/src/modules/content/api.ts @@ -0,0 +1,31 @@ +import { GetStudioClientConfigResponse } from "./types"; +import { newmApi } from "../../api"; +import { setToastMessage } from "../../modules/ui"; + +export const extendedApi = newmApi.injectEndpoints({ + endpoints: (build) => ({ + getMarketplaceClientConfig: build.query< + GetStudioClientConfigResponse, + void + >({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while fetching app config", + severity: "error", + }) + ); + } + }, + + query: () => ({ method: "GET", url: "v1/client-config/marketplace" }), + }), + }), +}); + +export const { useGetMarketplaceClientConfigQuery } = extendedApi; + +export default extendedApi; diff --git a/apps/marketplace/src/modules/content/index.ts b/apps/marketplace/src/modules/content/index.ts new file mode 100644 index 000000000..1d58e1a79 --- /dev/null +++ b/apps/marketplace/src/modules/content/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./types"; diff --git a/apps/marketplace/src/modules/content/types.ts b/apps/marketplace/src/modules/content/types.ts new file mode 100644 index 000000000..88c486158 --- /dev/null +++ b/apps/marketplace/src/modules/content/types.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line +interface FeatureFlags { + // When a feature flag is added it will have the following format: + // readonly exampleFlag: boolean; +} + +export interface GetStudioClientConfigResponse { + readonly featureFlags: FeatureFlags; +} diff --git a/apps/marketplace/src/modules/sale/api.ts b/apps/marketplace/src/modules/sale/api.ts index 1cefab89e..386ab392a 100644 --- a/apps/marketplace/src/modules/sale/api.ts +++ b/apps/marketplace/src/modules/sale/api.ts @@ -1,3 +1,4 @@ +import { transformApiSale } from "@newm-web/utils"; import { ApiSale, GenerateOrderRequest, @@ -7,8 +8,7 @@ import { GetSaleResponse, GetSalesParams, GetSalesResponse, -} from "./types"; -import { transformApiSale } from "./utils"; +} from "@newm-web/types"; import { newmApi } from "../../api"; import { setToastMessage } from "../../modules/ui"; import { Tags } from "../../api/newm/types"; diff --git a/apps/marketplace/src/modules/sale/index.ts b/apps/marketplace/src/modules/sale/index.ts index 01c6c9d82..d158c5764 100644 --- a/apps/marketplace/src/modules/sale/index.ts +++ b/apps/marketplace/src/modules/sale/index.ts @@ -1,3 +1 @@ export * from "./api"; -export * from "./types"; -export * from "./utils"; diff --git a/apps/marketplace/src/modules/sale/thunks.ts b/apps/marketplace/src/modules/sale/thunks.ts index 213d44f0f..a675ac376 100644 --- a/apps/marketplace/src/modules/sale/thunks.ts +++ b/apps/marketplace/src/modules/sale/thunks.ts @@ -5,7 +5,7 @@ import { getWalletChangeAddress, signWalletTransaction, } from "@newm.io/cardano-dapp-wallet-connector"; -import { GenerateOrderRequest } from "./types"; +import { GenerateOrderRequest } from "@newm-web/types"; import saleApi from "./api"; import { setToastMessage } from "../ui"; diff --git a/apps/marketplace/src/modules/sale/utils.ts b/apps/marketplace/src/modules/sale/utils.ts deleted file mode 100644 index cc4e24bee..000000000 --- a/apps/marketplace/src/modules/sale/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ApiSale, Sale } from "./types"; - -/** - * Creates a sale object from the sale API object with - * the song.parentalAdvisory string field replaced with - * a song.isExplicit boolean field. - */ -export const transformApiSale = (apiSale: ApiSale): Sale => { - const { - song: { parentalAdvisory, ...song }, - ...sale - } = apiSale; - - return { - ...sale, - // TODO: look into back-end returning value with correct decimal places - costAmount: sale.costAmount / 1000000, - song: { - ...song, - isExplicit: parentalAdvisory === "Explicit", - }, - }; -}; diff --git a/apps/marketplace/src/temp/data.ts b/apps/marketplace/src/temp/data.ts index 8fad2a3b2..c34f4b232 100644 --- a/apps/marketplace/src/temp/data.ts +++ b/apps/marketplace/src/temp/data.ts @@ -1,4 +1,4 @@ -import { Sale, SaleStatus } from "../modules/sale/types"; +import { Sale, SaleStatus } from "@newm-web/types"; export const mockArtist = { coverImageUrl: diff --git a/apps/mobile-wallet-connector/src/modules/wallet/thunks.ts b/apps/mobile-wallet-connector/src/modules/wallet/thunks.ts index 03999e0b6..5578216fa 100644 --- a/apps/mobile-wallet-connector/src/modules/wallet/thunks.ts +++ b/apps/mobile-wallet-connector/src/modules/wallet/thunks.ts @@ -1,12 +1,12 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; import { EnabledWallet, + getWalletChangeAddress, signWalletTransaction, } from "@newm.io/cardano-dapp-wallet-connector"; -import { asThunkHook } from "@newm-web/utils"; +import { asThunkHook, encodeAddress } from "@newm-web/utils"; import { extendedApi as newmApi } from "./api"; import { ChallengeMethod } from "./types"; -import { encodeAddress } from "./utils"; import { setConnectionData } from "./slice"; import { setToastMessage } from "../ui"; @@ -24,7 +24,7 @@ export const connectFromMobile = createAsyncThunk( let connection; const adresses = await wallet.getRewardAddresses(); const stakeAddress = encodeAddress(adresses[0]); - const changeAddress = encodeAddress(await wallet.getChangeAddress()); + const changeAddress = await getWalletChangeAddress(wallet); const utxoCborHexList = await wallet.getUtxos("1a001e8480"); if (isHardwareWallet) { diff --git a/apps/mobile-wallet-connector/src/modules/wallet/utils.ts b/apps/mobile-wallet-connector/src/modules/wallet/utils.ts deleted file mode 100644 index 25dad1150..000000000 --- a/apps/mobile-wallet-connector/src/modules/wallet/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { bech32 } from "bech32"; - -export const encodeAddress = (hex: string) => { - // https://cips.cardano.org/cip/CIP-19 - let prefix; - switch (hex[0]) { - case "8": - throw new Error("Byron addresses not supported"); - case "e": - case "E": - prefix = "stake"; - break; - default: - prefix = "addr"; - break; - } - if (hex[1] === "0") { - prefix += "_test"; - } - const bytes = Buffer.from(hex, "hex"); - const words = bech32.toWords(bytes); - - return bech32.encode(prefix, words, 1000); -}; diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 66ffd4632..02352225c 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -15,6 +15,8 @@ import { IdenfyPingUserStatus, IdenfySuccessSession, InvitesModal, + PingSaleEnd, + PingSaleStart, PrivateRoute, ProgressBarModal, Toast, @@ -51,6 +53,8 @@ const App = () => { + + diff --git a/apps/studio/src/api/constants.ts b/apps/studio/src/api/constants.ts index 045b5f3eb..e828bffd6 100644 --- a/apps/studio/src/api/constants.ts +++ b/apps/studio/src/api/constants.ts @@ -5,8 +5,15 @@ export const recaptchaEndpointActionMap: Record = { appleLogin: "login_apple", createAccount: "signup", + endSaleAmount: "generate_sale_end_amount", + endSaleTransaction: "generate_sale_end_transaction", + getSale: "get_sale", + getSales: "get_sales", + getStudioClientConfig: "studio_config", googleLogin: "login_google", login: "login", resetPassword: "password_reset", sendVerificationEmail: "auth_code", + startSaleAmount: "generate_sale_start_amount", + startSaleTransaction: "generate_sale_start_transaction", }; diff --git a/apps/studio/src/api/newm/api.ts b/apps/studio/src/api/newm/api.ts index 3a924da53..547285cb3 100644 --- a/apps/studio/src/api/newm/api.ts +++ b/apps/studio/src/api/newm/api.ts @@ -19,6 +19,7 @@ const api = createApi({ Tags.Languages, Tags.Profile, Tags.Roles, + Tags.Sale, Tags.Song, ], }); diff --git a/apps/studio/src/api/newm/types.ts b/apps/studio/src/api/newm/types.ts index 0973c7386..712c0018f 100644 --- a/apps/studio/src/api/newm/types.ts +++ b/apps/studio/src/api/newm/types.ts @@ -4,5 +4,6 @@ export enum Tags { Languages = "Languages", Profile = "Profile", Roles = "Roles", - Song = "Song", + Sale = "Sale", + Song = "Song" } diff --git a/apps/studio/src/api/utils.ts b/apps/studio/src/api/utils.ts index d18885c0d..440ae417b 100644 --- a/apps/studio/src/api/utils.ts +++ b/apps/studio/src/api/utils.ts @@ -1,9 +1,9 @@ import { BaseQueryApi } from "@reduxjs/toolkit/dist/query/baseQueryTypes"; import Cookies from "js-cookie"; import { Mutex } from "async-mutex"; -import axios, { AxiosError, AxiosRequestConfig } from "axios"; +import { AxiosRequestConfig } from "axios"; import { executeRecaptcha } from "@newm-web/utils"; -import { AxiosBaseQueryParams, BaseQuery } from "@newm-web/types"; +import { BaseQuery } from "@newm-web/types"; import { logOutExpiredSession, receiveRefreshToken } from "./actions"; import { recaptchaEndpointActionMap } from "./constants"; import { RootState } from "../store"; @@ -105,18 +105,12 @@ export const getAuthHeaders = (api: BaseQueryApi) => { */ export const getRecaptchaHeaders = async (api: BaseQueryApi) => { const { endpoint } = api; - const state = api.getState() as RootState; - const { isLoggedIn } = state.session; const action = recaptchaEndpointActionMap[endpoint] || endpoint; - if (!isLoggedIn) { - return { - "g-recaptcha-platform": "Web", - "g-recaptcha-token": await executeRecaptcha(action), - }; - } - - return {}; + return { + "g-recaptcha-platform": "Web", + "g-recaptcha-token": await executeRecaptcha(action), + }; }; /** diff --git a/apps/studio/src/common/constants.ts b/apps/studio/src/common/constants.ts index be353e38f..a668bfee9 100644 --- a/apps/studio/src/common/constants.ts +++ b/apps/studio/src/common/constants.ts @@ -1,3 +1,5 @@ +import { isProd } from "@newm-web/env"; + /** * NEWM External Links and Support */ @@ -35,3 +37,27 @@ export const SKIP_FETCH_INVITE_PATH_LIST = [ "/idenfy-success-session", "/idenfy-fail-session", ]; + +export const NEWM_MARKETPLACE_URL = isProd + ? "https://marketplace.newm.io" + : "https://fan.square.newm.io"; + +export const LOCAL_STORAGE_SALE_START_PENDING_KEY = "saleStartSongs"; +export const SALE_START_UPDATED_EVENT = "saleStartUpdated"; + +export const LOCAL_STORAGE_SALE_END_PENDING_KEY = "saleEndSongIds"; +export const SALE_END_UPDATED_EVENT = "saleEndUpdated"; +/** + * 15 seconds in milliseconds + */ +export const PENDING_SALE_POLLING_INTERVAL = 15000; + +/** + * 5 minutes in milliseconds + */ +export const PENDING_SALE_PING_TIMEOUT = 300000; + +/** + * Stream token sale default bundle amount + */ +export const SALE_DEFAULT_BUNDLE_AMOUNT = 1; diff --git a/apps/studio/src/common/types.ts b/apps/studio/src/common/types.ts index 710ead359..8b9e3a3bb 100644 --- a/apps/studio/src/common/types.ts +++ b/apps/studio/src/common/types.ts @@ -26,3 +26,12 @@ export interface PlayerState { readonly loadingSongId?: string; readonly song?: Song; } + +interface SaleDetails { + tokensToSell: string; + totalSaleValue: string; +} + +export interface SaleStartPendingSongs { + [key: string]: SaleDetails; +} diff --git a/apps/studio/src/components/index.ts b/apps/studio/src/components/index.ts index e4432ed15..19b08d42b 100644 --- a/apps/studio/src/components/index.ts +++ b/apps/studio/src/components/index.ts @@ -11,6 +11,8 @@ export { default as WalletEnvMismatchModal } from "./WalletEnvMismatchModal"; export { default as AppleLogin } from "./AppleLogin"; export { default as GoogleLogin } from "./GoogleLogin"; export * from "./idenfy"; +export { default as PingSaleStart } from "./sales/PingSaleStart"; +export { default as PingSaleEnd } from "./sales/PingSaleEnd"; export { default as InvitesModal } from "./invites/InvitesModal"; export { default as NEWMLogo } from "./NEWMLogo"; export { default as SelectWalletItem } from "./uploadSong/SelectWalletItem"; @@ -24,6 +26,7 @@ export { default as ProgressBar } from "./ProgressBar"; export { default as ProgressBarModal } from "./ProgressBarModal"; export { default as ResponsiveNEWMLogo } from "./ResponsiveNEWMLogo"; export { default as ProfileSkeleton } from "./skeletons/ProfileSkeleton"; +export { default as MarketplaceTabSkeleton } from "./skeletons/MarketplaceTabSkeleton"; export { default as LogoutButton } from "./home/LogoutButton"; export { default as SquareGridCard } from "./SquareGridCard"; export { default as Toast } from "./Toast"; diff --git a/apps/studio/src/components/sales/PingSaleEnd.tsx b/apps/studio/src/components/sales/PingSaleEnd.tsx new file mode 100644 index 000000000..069eca8e3 --- /dev/null +++ b/apps/studio/src/components/sales/PingSaleEnd.tsx @@ -0,0 +1,121 @@ +import { FunctionComponent, useCallback, useEffect, useState } from "react"; +import { SaleStatus } from "@newm-web/types"; +import { useGetSalesQuery } from "../../modules/sale"; +import { + LOCAL_STORAGE_SALE_END_PENDING_KEY, + PENDING_SALE_PING_TIMEOUT, + PENDING_SALE_POLLING_INTERVAL, + SALE_END_UPDATED_EVENT, +} from "../../common"; + +const PingSaleEnd: FunctionComponent = () => { + const [currentPollingInterval, setPollingInterval] = useState(); + const [saleEndSongIds, setSaleEndSongIds] = useState(); + const [timeoutId, setTimeoutId] = useState(null); + + const { data: sales = [], isLoading: isGetSalesLoading } = useGetSalesQuery( + { + saleStatuses: [SaleStatus.Started], + songIds: saleEndSongIds || undefined, + }, + { + pollingInterval: currentPollingInterval, + skip: + saleEndSongIds === null || + (saleEndSongIds && saleEndSongIds.length === 0), + } + ); + + const handleSaleEndPending = useCallback(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY + ); + + if (pendingSales) { + const parsedPendingSales = JSON.parse(pendingSales); + + setSaleEndSongIds(parsedPendingSales); + setPollingInterval(PENDING_SALE_POLLING_INTERVAL); + } else { + setSaleEndSongIds(null); + setPollingInterval(undefined); + } + }, []); + + /** + * Initialize and manage the event listener for pending sales updates. + * It also calls handleSaleEndPending to initialize the state based on localStorage. + */ + useEffect(() => { + handleSaleEndPending(); + window.addEventListener(SALE_END_UPDATED_EVENT, handleSaleEndPending); + + return () => { + window.removeEventListener(SALE_END_UPDATED_EVENT, handleSaleEndPending); + + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSaleEndPending]); + + /** + * Reset the 5-minute timeout whenever the saleEndSongIds state changes. + */ + useEffect(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (saleEndSongIds?.length) { + const newTimeoutId = setTimeout(() => { + setPollingInterval(undefined); + }, PENDING_SALE_PING_TIMEOUT); + + setTimeoutId(newTimeoutId); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saleEndSongIds]); + + /** + * Update saleEndSongIds in localStorage whenever sales data is updated. + */ + useEffect(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY + ); + + if (pendingSales && !isGetSalesLoading) { + const parsedPendingSales: string[] = JSON.parse(pendingSales); + + if (parsedPendingSales.length > 0) { + // Filter out any song IDs that are no longer in the sales data + const updatedPendingSales = parsedPendingSales.filter((songId) => + sales.find((sale) => sale.song.id === songId) + ); + + localStorage.setItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY, + JSON.stringify(updatedPendingSales) + ); + + window.dispatchEvent(new Event(SALE_END_UPDATED_EVENT)); + } else { + localStorage.removeItem(LOCAL_STORAGE_SALE_END_PENDING_KEY); + + window.dispatchEvent(new Event(SALE_END_UPDATED_EVENT)); + } + } + }, [isGetSalesLoading, sales]); + + return null; +}; + +export default PingSaleEnd; diff --git a/apps/studio/src/components/sales/PingSaleStart.tsx b/apps/studio/src/components/sales/PingSaleStart.tsx new file mode 100644 index 000000000..bfec4c86a --- /dev/null +++ b/apps/studio/src/components/sales/PingSaleStart.tsx @@ -0,0 +1,137 @@ +import { FunctionComponent, useCallback, useEffect, useState } from "react"; +import { SaleStatus } from "@newm-web/types"; +import { useGetSalesQuery } from "../../modules/sale"; +import { + LOCAL_STORAGE_SALE_START_PENDING_KEY, + PENDING_SALE_PING_TIMEOUT, + PENDING_SALE_POLLING_INTERVAL, + SALE_START_UPDATED_EVENT, + SaleStartPendingSongs, +} from "../../common"; + +const PingSaleStart: FunctionComponent = () => { + const [currentPollingInterval, setPollingInterval] = useState(); + const [saleStartSongIds, setSaleStartSongIds] = useState([]); + const [timeoutId, setTimeoutId] = useState(null); + + const { data: sales = [], isLoading: isGetSalesLoading } = useGetSalesQuery( + { + saleStatuses: [SaleStatus.Started], + songIds: saleStartSongIds, + }, + { + pollingInterval: currentPollingInterval, + skip: !saleStartSongIds || saleStartSongIds.length === 0, + } + ); + + const handleSaleStartPending = useCallback(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY + ); + + if (pendingSales) { + const parsedPendingSales: SaleStartPendingSongs = + JSON.parse(pendingSales); + const songIds = Object.keys(parsedPendingSales); + + if (songIds.length > 0) { + setSaleStartSongIds(songIds); + setPollingInterval(PENDING_SALE_POLLING_INTERVAL); + return; + } + } + + setSaleStartSongIds([]); + setPollingInterval(undefined); + }, []); + + /** + * Initialize and manage the event listener for pending sales updates. + * It also calls handleSaleStartPending to initialize the state based on localStorage. + */ + useEffect(() => { + handleSaleStartPending(); + window.addEventListener(SALE_START_UPDATED_EVENT, handleSaleStartPending); + + return () => { + window.removeEventListener( + SALE_START_UPDATED_EVENT, + handleSaleStartPending + ); + + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSaleStartPending]); + + /** + * Reset the 5-minute timeout whenever the saleStartSongIds state changes. + */ + useEffect(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (saleStartSongIds.length > 0) { + const newTimeoutId = setTimeout(() => { + setPollingInterval(undefined); + }, PENDING_SALE_PING_TIMEOUT); + + setTimeoutId(newTimeoutId); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saleStartSongIds]); + + /** + * Update saleStartSongIds in localStorage whenever sales data is updated. + */ + useEffect(() => { + if (!isGetSalesLoading) { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY + ); + + if (pendingSales) { + const parsedPendingSales: SaleStartPendingSongs = + JSON.parse(pendingSales); + + if (Object.keys(parsedPendingSales).length > 0) { + // Remove the songIds that have been successfully started + const updatedPendingSales = Object.keys(parsedPendingSales) + .filter((songId) => !sales.find((sale) => sale.song.id === songId)) + .reduce((acc: SaleStartPendingSongs, songId) => { + acc[songId] = parsedPendingSales[songId]; + return acc; + }, {}); + + if (Object.keys(updatedPendingSales).length > 0) { + localStorage.setItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY, + JSON.stringify(updatedPendingSales) + ); + } else { + localStorage.removeItem(LOCAL_STORAGE_SALE_START_PENDING_KEY); + } + + window.dispatchEvent(new Event(SALE_START_UPDATED_EVENT)); + } else { + localStorage.removeItem(LOCAL_STORAGE_SALE_START_PENDING_KEY); + window.dispatchEvent(new Event(SALE_START_UPDATED_EVENT)); + } + } + } + }, [sales, isGetSalesLoading]); + + return null; +}; + +export default PingSaleStart; diff --git a/apps/studio/src/components/skeletons/MarketplaceTabSkeleton.tsx b/apps/studio/src/components/skeletons/MarketplaceTabSkeleton.tsx new file mode 100644 index 000000000..4957dc540 --- /dev/null +++ b/apps/studio/src/components/skeletons/MarketplaceTabSkeleton.tsx @@ -0,0 +1,22 @@ +import { FunctionComponent } from "react"; +import { Skeleton, Stack } from "@mui/material"; +import theme from "@newm-web/theme"; + +const MarketplaceTabSkeleton: FunctionComponent = () => ( + <> + + + + + + + + +); +export default MarketplaceTabSkeleton; diff --git a/apps/studio/src/modules/content/api.ts b/apps/studio/src/modules/content/api.ts index 8c21e4cfa..8b78e2e75 100644 --- a/apps/studio/src/modules/content/api.ts +++ b/apps/studio/src/modules/content/api.ts @@ -1,4 +1,10 @@ -import { Country, Genre, Language, Role } from "./types"; +import { + Country, + Genre, + GetStudioClientConfigResponse, + Language, + Role, +} from "./types"; import { Tags, newmApi } from "../../api"; import { setToastMessage } from "../../modules/ui"; @@ -121,6 +127,22 @@ export const extendedApi = newmApi.injectEndpoints({ return extracted.sort((a, b) => a.localeCompare(b)); }, }), + getStudioClientConfig: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while fetching app config", + severity: "error", + }) + ); + } + }, + + query: () => ({ method: "GET", url: "v1/client-config/studio" }), + }), }), }); @@ -131,6 +153,7 @@ export const { useGetLanguagesQuery, useGetMoodsQuery, useGetRolesQuery, + useGetStudioClientConfigQuery, } = extendedApi; export default extendedApi; diff --git a/apps/studio/src/modules/content/types.ts b/apps/studio/src/modules/content/types.ts index 2e97a2510..77265e6fd 100644 --- a/apps/studio/src/modules/content/types.ts +++ b/apps/studio/src/modules/content/types.ts @@ -23,3 +23,12 @@ export interface Country { readonly country_name: string; readonly state?: State[]; } + +interface FeatureFlags { + readonly claimWalletRoyaltiesEnabled: boolean; + readonly manageMarketplaceSalesEnabled: boolean; +} + +export interface GetStudioClientConfigResponse { + readonly featureFlags: FeatureFlags; +} diff --git a/apps/studio/src/modules/crypto/api.ts b/apps/studio/src/modules/crypto/api.ts new file mode 100644 index 000000000..4e986972e --- /dev/null +++ b/apps/studio/src/modules/crypto/api.ts @@ -0,0 +1,50 @@ +import { GetADAPriceResponse, GetNEWMPriceResponse } from "./types"; +import { newmApi } from "../../api"; +import { setToastMessage } from "../../modules/ui"; + +export const extendedApi = newmApi.injectEndpoints({ + endpoints: (build) => ({ + getADAPrice: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred fetching ADA price", + severity: "error", + }) + ); + } + }, + + query: () => ({ + method: "GET", + url: "v1/cardano/prices/ada", + }), + }), + getNEWMPrice: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred fetching NEWM price", + severity: "error", + }) + ); + } + }, + + query: () => ({ + method: "GET", + url: "v1/cardano/prices/newm", + }), + }), + }), +}); + +export const { useGetNEWMPriceQuery, useGetADAPriceQuery } = extendedApi; + +export default extendedApi; diff --git a/apps/studio/src/modules/crypto/index.ts b/apps/studio/src/modules/crypto/index.ts new file mode 100644 index 000000000..1d58e1a79 --- /dev/null +++ b/apps/studio/src/modules/crypto/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./types"; diff --git a/apps/studio/src/modules/crypto/types.ts b/apps/studio/src/modules/crypto/types.ts new file mode 100644 index 000000000..db0f401ca --- /dev/null +++ b/apps/studio/src/modules/crypto/types.ts @@ -0,0 +1,9 @@ +export interface GetNEWMPriceResponse { + // NEWM token price in USD (6 decimal places). For example, a value of 1234567 means $1.234567 USD + readonly usdPrice: number; +} + +export interface GetADAPriceResponse { + // ADA price in USD (6 decimal places). For example, a value of 1234567 means $1.234567 USD + readonly usdPrice: number; +} diff --git a/apps/studio/src/modules/sale/api.ts b/apps/studio/src/modules/sale/api.ts new file mode 100644 index 000000000..65f043cac --- /dev/null +++ b/apps/studio/src/modules/sale/api.ts @@ -0,0 +1,163 @@ +import { ApiSale, GetSalesParams, GetSalesResponse } from "@newm-web/types"; +import { transformApiSale } from "@newm-web/utils"; +import { + EndSaleAmountRequest, + EndSaleAmountResponse, + EndSaleTransactionRequest, + EndSaleTransactionResponse, + StartSaleAmountRequest, + StartSaleAmountResponse, + StartSaleTransactionRequest, + StartSaleTransactionResponse, +} from "./types"; +import { newmApi } from "../../api"; +import { setToastMessage } from "../../modules/ui"; +import { Tags } from "../../api/newm/types"; + +export const extendedApi = newmApi.injectEndpoints({ + endpoints: (build) => ({ + endSaleAmount: build.mutation({ + invalidatesTags: [Tags.Sale], + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while generating the sale end amount", + severity: "error", + }) + ); + } + }, + + query: (body) => ({ + body, + method: "POST", + url: "v1/marketplace/sales/end/amount", + }), + }), + endSaleTransaction: build.mutation< + EndSaleTransactionResponse, + EndSaleTransactionRequest + >({ + invalidatesTags: [Tags.Sale], + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: + "An error occurred while generating the sale end transaction", + severity: "error", + }) + ); + } + }, + + query: (body) => ({ + body, + method: "POST", + url: "v1/marketplace/sales/end/transaction", + }), + }), + + getSales: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while fetching sale data", + severity: "error", + }) + ); + } + }, + + providesTags: [Tags.Sale], + + query: ({ + ids, + artistIds, + genres, + moods, + songIds, + saleStatuses, + ...rest + } = {}) => ({ + method: "GET", + params: { + ...(ids ? { ids: ids.join(",") } : {}), + ...(artistIds ? { artistIds: artistIds.join(",") } : {}), + ...(genres ? { genres: genres.join(",") } : {}), + ...(moods ? { moods: moods.join(",") } : {}), + ...(songIds ? { songIds: songIds.join(",") } : {}), + ...(saleStatuses ? { saleStatuses: saleStatuses.join(",") } : {}), + ...rest, + }, + url: "v1/marketplace/sales", + }), + + transformResponse: (apiSales: ReadonlyArray) => { + return apiSales.map(transformApiSale); + }, + }), + startSaleAmount: build.mutation< + StartSaleAmountResponse, + StartSaleAmountRequest + >({ + invalidatesTags: [Tags.Sale], + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: + "An error occurred while generating the sale start amount", + severity: "error", + }) + ); + } + }, + + query: (body) => ({ + body, + method: "POST", + url: "v1/marketplace/sales/start/amount", + }), + }), + startSaleTransaction: build.mutation< + StartSaleTransactionResponse, + StartSaleTransactionRequest + >({ + invalidatesTags: [Tags.Sale], + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: + "An error occurred while generating the sale start transaction", + severity: "error", + }) + ); + } + }, + + query: (body) => ({ + body, + method: "POST", + url: "v1/marketplace/sales/start/transaction", + }), + }), + }), +}); + +export const { useGetSalesQuery } = extendedApi; + +export default extendedApi; diff --git a/apps/studio/src/modules/sale/index.ts b/apps/studio/src/modules/sale/index.ts new file mode 100644 index 000000000..dc6928b29 --- /dev/null +++ b/apps/studio/src/modules/sale/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./thunks"; +export * from "./types"; diff --git a/apps/studio/src/modules/sale/thunks.ts b/apps/studio/src/modules/sale/thunks.ts new file mode 100644 index 000000000..cc34517a0 --- /dev/null +++ b/apps/studio/src/modules/sale/thunks.ts @@ -0,0 +1,194 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { + enableWallet, + getWalletChangeAddress, + signWalletTransaction, +} from "@newm.io/cardano-dapp-wallet-connector"; +import { NEWM_DECIMAL_CONVERSION, asThunkHook } from "@newm-web/utils"; +import { extendedApi as saleApi } from "./api"; +import { EndSaleThunkRequest, StartSaleThunkRequest } from "./types"; +import { setToastMessage } from "../ui"; +import { + LOCAL_STORAGE_SALE_END_PENDING_KEY, + LOCAL_STORAGE_SALE_START_PENDING_KEY, + SALE_END_UPDATED_EVENT, + SALE_START_UPDATED_EVENT, +} from "../../common"; + +/** + * Starts a sale for a song. + */ +export const startSale = createAsyncThunk( + "sale/start", + async (body: StartSaleThunkRequest, { dispatch }) => { + try { + const wallet = await enableWallet(); + const changeAddress = await getWalletChangeAddress(wallet); + + const startSaleAmountResp = await dispatch( + saleApi.endpoints.startSaleAmount.initiate({ + bundleAmount: body.bundleAmount, + bundleAssetName: body.bundleAssetName, + bundlePolicyId: body.bundlePolicyId, + costAmount: body.costAmount * NEWM_DECIMAL_CONVERSION, + ownerAddress: changeAddress, + totalBundleQuantity: body.totalBundleQuantity, + }) + ); + + if ("error" in startSaleAmountResp || !startSaleAmountResp.data) return; + + const { saleId, amountCborHex } = startSaleAmountResp.data; + + const utxoCborHexList = await wallet.getUtxos(amountCborHex); + + if (!utxoCborHexList) { + throw new Error( + "Insufficient NEWM in wallet. Please add NEWM to your wallet and try again." + ); + } + + const startSaleTransactionResp = await dispatch( + saleApi.endpoints.startSaleTransaction.initiate({ + changeAddress, + saleId, + utxoCborHexList, + }) + ); + + if ("error" in startSaleTransactionResp || !startSaleTransactionResp.data) + return; + + const signedTransaction = await signWalletTransaction( + wallet, + startSaleTransactionResp.data.txCborHex, + true + ); + + await wallet.submitTx(signedTransaction); + + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY + ); + const newSale = { + tokensToSell: body.totalBundleQuantity, + totalSaleValue: body.costAmount, + }; + + if (pendingSales) { + const parsedPendingSales = JSON.parse(pendingSales); + parsedPendingSales[body.songId] = newSale; + localStorage.setItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY, + JSON.stringify(parsedPendingSales) + ); + } else { + localStorage.setItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY, + JSON.stringify({ [body.songId]: newSale }) + ); + } + + window.dispatchEvent(new Event(SALE_START_UPDATED_EVENT)); + + dispatch( + setToastMessage({ + message: "You have commenced creation of the stream token sale.", + severity: "success", + }) + ); + } catch (error) { + // non-endpoint related error occur, show toast + if (error instanceof Error) { + dispatch( + setToastMessage({ + message: error.message, + severity: "error", + }) + ); + } + } + } +); + +/** + * Ends a sale for a song. + */ +export const endSale = createAsyncThunk( + "sale/end", + async (body: EndSaleThunkRequest, { dispatch }) => { + try { + const wallet = await enableWallet(); + const changeAddress = await getWalletChangeAddress(wallet); + + const endSaleAmountResp = await dispatch( + saleApi.endpoints.endSaleAmount.initiate({ + saleId: body.saleId, + }) + ); + + if ("error" in endSaleAmountResp || !endSaleAmountResp.data) return; + + const { amountCborHex } = endSaleAmountResp.data; + + const utxoCborHexList = await wallet.getUtxos(amountCborHex); + + const endSaleTransactionResp = await dispatch( + saleApi.endpoints.endSaleTransaction.initiate({ + changeAddress, + saleId: body.saleId, + utxoCborHexList, + }) + ); + + if ("error" in endSaleTransactionResp || !endSaleTransactionResp.data) + return; + + const signedTransaction = await signWalletTransaction( + wallet, + endSaleTransactionResp.data.txCborHex, + true + ); + + await wallet.submitTx(signedTransaction); + + const pendingSaleSongIds = localStorage.getItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY + ); + + if (pendingSaleSongIds) { + localStorage.setItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY, + JSON.stringify([...JSON.parse(pendingSaleSongIds), body.songId]) + ); + } else { + localStorage.setItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY, + JSON.stringify([body.songId]) + ); + } + + window.dispatchEvent(new Event(SALE_END_UPDATED_EVENT)); + + dispatch( + setToastMessage({ + message: "You have successfully ended the stream token sale.", + severity: "success", + }) + ); + } catch (error) { + // non-endpoint related error occur, show toast + if (error instanceof Error) { + dispatch( + setToastMessage({ + message: error.message, + severity: "error", + }) + ); + } + } + } +); + +export const useStartSaleThunk = asThunkHook(startSale); +export const useEndSaleThunk = asThunkHook(endSale); diff --git a/apps/studio/src/modules/sale/types.ts b/apps/studio/src/modules/sale/types.ts new file mode 100644 index 000000000..679a61be6 --- /dev/null +++ b/apps/studio/src/modules/sale/types.ts @@ -0,0 +1,74 @@ +export interface EndSaleAmountResponse { + // CBOR format-encoded generated amount. + readonly amountCborHex: string; +} + +export interface EndSaleAmountRequest { + // UUID of the sale. + readonly saleId: string; +} + +export interface EndSaleTransactionResponse { + // CBOR format-encoded generated transaction. + readonly txCborHex: string; +} + +export interface EndSaleTransactionRequest { + // Cardano wallet change address. + readonly changeAddress: string; + // UUID of the pending sale. + readonly saleId: string; + // CBOR format-encoded list of UTXOs. + readonly utxoCborHexList: string; +} + +export interface EndSaleThunkRequest extends EndSaleAmountRequest { + readonly songId: string; +} + +export interface StartSaleAmountResponse { + // CBOR format-encoded generated amount. + readonly amountCborHex: string; + // UUID of the pending sale associated with the generate amount. + // This value is ONLY useful to invoke Generate Marketplace Sale-Start Transaction. + readonly saleId: string; +} + +export interface StartSaleAmountRequest { + // Amount of tokens in one unit of sale. + readonly bundleAmount: number; + // Asset Name (hex-encoded) of the bundle token. + readonly bundleAssetName: string; + // Policy ID of the bundle token. + readonly bundlePolicyId: string; + // Token amount (6 decimal places) in one unit of cost. + readonly costAmount: number; + // Asset Name (hex-encoded) of the cost token. If missing, defaults to NEWM token. + readonly costAssetName?: string; + // Policy ID of the cost token. If missing, defaults to NEWM token. + readonly costPolicyId?: string; + // The owner address (typically the stake address) of the caller. + readonly ownerAddress: string; + // Total quantity of bundles for sale. + readonly totalBundleQuantity: number; +} + +export interface StartSaleThunkRequest + extends Omit { + ownerAddress?: string; + songId: string; +} + +export interface StartSaleTransactionResponse { + // CBOR format-encoded generated transaction. + readonly txCborHex: string; +} + +export interface StartSaleTransactionRequest { + // Cardano wallet change address. + readonly changeAddress: string; + // UUID of the pending sale. + readonly saleId: string; + // CBOR format-encoded list of UTXOs. + readonly utxoCborHexList: string; +} diff --git a/apps/studio/src/modules/song/types.ts b/apps/studio/src/modules/song/types.ts index 206a50974..78d528d69 100644 --- a/apps/studio/src/modules/song/types.ts +++ b/apps/studio/src/modules/song/types.ts @@ -357,9 +357,38 @@ export interface GetEarliestReleaseDateResponse { } export interface GetUserWalletSongsRequest { + // Comma-separated list of song genres for filtering results. + // Prefix each value with - to exclude and + (optional) to include. Defaults to inclusion if no prefix is specified. + readonly genres?: string[]; + // Comma-separated list of song UUID's for filtering results. + // Prefix each value with - to exclude and + (optional) to include. Defaults to inclusion if no prefix is specified. + readonly ids?: string[]; + // Maximum number of paginated results to retrieve. Default is 25. readonly limit?: number; + // Comma-separated list of song Song minting statuses for filtering results. + // Prefix each value with - to exclude and + (optional) to include. Defaults to inclusion if no prefix is specified. + readonly mintingStatuses?: MintingStatus[]; + // Comma-separated list of song moods to for filtering results. + // Prefix each value with - to exclude and + (optional) to include. Defaults to inclusion if no prefix is specified. + readonly moods?: string[]; + // ISO-8601 formated newest (minimum) timestamp to filter-out results. + // If missing, defaults to no filtering out. + readonly newerThan?: string; + // Start offset of paginated results to retrieve. Default is 0. readonly offset?: number; + // ISO-8601 formated oldest (maximum) timestamp to filter-out results. If missing, defaults to no filtering out. + readonly olderThan?: string; + // Comma-separated list of owner UUID's for filtering results. A value of "me" can be used instead of + // the caller's UUID.Prefix each value with - to exclude and + (optional) to include. + // Defaults to inclusion if no prefix is specified. + readonly ownerIds?: string[]; + // Case-insensitive phrase to filter out songs by searching the Song title, description, album and + // nftName fields as well as the Song Owner nickname if set, otherwise firstName and lastName. + readonly phrase?: string; + // Sort order of the results based on createdAt field. + // Valid values are desc (newest first) and asc (oldest first). Default is asc. readonly sortOrder?: SortOrder; + // The list of UTxOs from walletApi.getUtxos(). readonly utxoCborHexList: ReadonlyArray; } diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/ActiveSale.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/ActiveSale.tsx new file mode 100644 index 000000000..5798539c3 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/ActiveSale.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import currency from "currency.js"; +import { AlertTitle, Stack, Typography } from "@mui/material"; +import { Alert, Button, HorizontalLine } from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { SaleStatus } from "@newm-web/types"; +import { EndSaleModal } from "./EndSaleModal"; +import { MarketplaceTabSkeleton } from "../../../../components"; +import { SongRouteParams } from "../types"; +import { useEndSaleThunk, useGetSalesQuery } from "../../../../modules/sale"; +import { NEWM_MARKETPLACE_URL } from "../../../../common"; + +export const ActiveSale = () => { + const [isEndSaleModalOpen, setIsEndSaleModalOpen] = useState(false); + const { songId } = useParams<"songId">() as SongRouteParams; + const { data: sales = [], isLoading } = useGetSalesQuery({ + saleStatuses: [SaleStatus.Started], + songIds: [songId], + }); + const [endSale, { isLoading: isEndSaleLoading }] = useEndSaleThunk(); + const saleId = sales[0].id; + + const handleEndSale = async () => { + await endSale({ saleId, songId }); + + setIsEndSaleModalOpen(false); + }; + + if (isLoading) { + return ; + } + + if (!sales.length) { + return null; + } + + return ( + + + + You already have an active stream token sale for this track on the + Marketplace! + + + { `There are ${currency(sales[0].availableBundleQuantity, { + precision: 0, + symbol: "", + }).format()} remaining stream tokens available for sale.` } + + + + You can choose to end this sale, and we'll return the unsold stream + tokens to your wallet. + + + + + + + setIsEndSaleModalOpen(false) } + handleEndSale={ handleEndSale } + isLoading={ isEndSaleLoading } + isOpen={ isEndSaleModalOpen } + /> + + ); +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/ConnectWallet.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/ConnectWallet.tsx new file mode 100644 index 000000000..f84f39c32 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/ConnectWallet.tsx @@ -0,0 +1,46 @@ +import { Stack, Typography } from "@mui/material"; +import { useConnectWallet } from "@newm.io/cardano-dapp-wallet-connector"; +import { Alert, Button } from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { useAppDispatch } from "../../../../common"; +import { setIsConnectWalletModalOpen } from "../../../../modules/ui"; + +export const ConnectWallet = () => { + const dispatch = useAppDispatch(); + const { wallet, disconnect } = useConnectWallet(); + + const handleConnectWallet = () => { + if (wallet) { + disconnect(); + } + + dispatch(setIsConnectWalletModalOpen(true)); + }; + + return ( + + + + { wallet + ? "Your current wallet does not contain this track's stream " + + "tokens. To create a sale on the Marketplace, please first " + + "connect a wallet containing stream tokens for this track." + : "Please connect a wallet containing stream tokens for " + + "this track to create a sale on the Marketplace." } + + + + + ); +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/CreateSale.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/CreateSale.tsx new file mode 100644 index 000000000..408316a17 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/CreateSale.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import currency from "currency.js"; +import * as Yup from "yup"; +import { AlertTitle, Stack, Typography } from "@mui/material"; +import { Form, Formik, FormikValues } from "formik"; +import { useConnectWallet } from "@newm.io/cardano-dapp-wallet-connector"; +import { + Alert, + Button, + HorizontalLine, + TextInputField, +} from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { + calculateOwnershipPerecentage, + formatNewmAmount, + formatPercentageAdaptive, + useWindowDimensions, +} from "@newm-web/utils"; +import StartSaleModal from "./StartSaleModal"; +import { SALE_DEFAULT_BUNDLE_AMOUNT } from "../../../../common"; +import { SongRouteParams } from "../types"; +import { useGetUserWalletSongsThunk } from "../../../../modules/song"; +import { useStartSaleThunk } from "../../../../modules/sale"; + +export const CreateSale = () => { + const [isSaleSummaryModalOpen, setIsSaleSummaryModalOpen] = useState(false); + const windowWidth = useWindowDimensions()?.width; + const { songId } = useParams<"songId">() as SongRouteParams; + const [ + getUserWalletSongs, + { data: walletSongsResponse, isLoading: isGetWalletSongsLoading }, + ] = useGetUserWalletSongsThunk(); + const [startSale, { isLoading: isStartSaleLoading }] = useStartSaleThunk(); + const { wallet } = useConnectWallet(); + const currentSong = walletSongsResponse?.data?.songs[0]; + + const handleCreateSale = async (values: FormikValues) => { + if ( + !currentSong || + !currentSong.song.nftPolicyId || + !currentSong.song.nftName + ) + return; + + await startSale({ + bundleAmount: SALE_DEFAULT_BUNDLE_AMOUNT, + bundleAssetName: currentSong.song.nftName, + bundlePolicyId: currentSong.song.nftPolicyId, + costAmount: values.totalSaleValue, + songId, + totalBundleQuantity: values.tokensToSell, + }); + + setIsSaleSummaryModalOpen(false); + }; + + useEffect(() => { + getUserWalletSongs({ + ids: [songId], + limit: 1, + }); + }, [getUserWalletSongs, songId, wallet]); + + const streamTokensInWallet = currentSong?.token_amount || 0; + const streamTokensPercentage = formatPercentageAdaptive( + calculateOwnershipPerecentage(streamTokensInWallet) + ); + const formattedStreamTokensInWallet = currency(streamTokensInWallet, { + precision: 0, + symbol: "", + }).format(); + const isOnlyOneTokenAvailable = streamTokensInWallet === 1; + + const validationSchema = Yup.object({ + tokensToSell: Yup.number() + .required("This field is required") + .integer("You must sell a whole number of stream tokens") + .min(1, "You must sell at least 1 stream token") + .max( + streamTokensInWallet, + `You only have ${formattedStreamTokensInWallet} stream tokens available to sell.` + ), + totalSaleValue: Yup.number() + .required("This field is required") + .min(0.01, "You must sell at least 0.01 Ɲ"), + }); + + return ( + <> + + + { `You currently have ${formattedStreamTokensInWallet} stream token${ + isOnlyOneTokenAvailable ? "" : "s" + } for this track available to sell.` } + + + { `This accounts for ${streamTokensPercentage}% of this track's total minted stream tokens.` } + + + + + { ({ + values: { tokensToSell = 0, totalSaleValue = 0 }, + isValid, + submitForm, + setTouched, + }) => ( +
+ + + Ɲ } + helperText={ + !!totalSaleValue && !!tokensToSell + ? `Price per stream token: ${formatNewmAmount( + totalSaleValue / tokensToSell, + true + )}` + : "" + } + isOptional={ false } + label="TOTAL SALE VALUE" + name="totalSaleValue" + placeholder="0" + tooltipText="The total amount (Ɲ) to be earned once the sale is fulfilled." + type="number" + /> + + + + setIsSaleSummaryModalOpen(false) } + handleStartSale={ submitForm } + isLoading={ isStartSaleLoading } + isOpen={ isSaleSummaryModalOpen } + totalTokensOwnedByUser={ streamTokensInWallet } + values={ { tokensToSell, totalSaleValue } } + /> + + ) } +
+ + ); +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/EndSaleModal.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/EndSaleModal.tsx new file mode 100644 index 000000000..8a278787e --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/EndSaleModal.tsx @@ -0,0 +1,73 @@ +import { FunctionComponent } from "react"; +import { Box, Stack } from "@mui/material"; +import { Button, Modal, Typography } from "@newm-web/elements"; +import theme from "@newm-web/theme"; + +interface EndSaleModalProps { + readonly handleClose: () => void; + readonly handleEndSale: () => void; + readonly isLoading: boolean; + readonly isOpen: boolean; +} + +export const EndSaleModal: FunctionComponent = ({ + handleClose, + isOpen, + handleEndSale, + isLoading, +}) => { + return ( + + + + + + Are you sure you want to end your stream token sale? + + + + The sale will be removed from the Marketplace, and all unsold + stream tokens will be returned to the wallet used when creating + the sale. + + + + + + + + + + ); +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/MarketplaceTab.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/MarketplaceTab.tsx new file mode 100644 index 000000000..5cb41781a --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/MarketplaceTab.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useParams } from "react-router"; +import { Box } from "@mui/material"; +import { useConnectWallet } from "@newm.io/cardano-dapp-wallet-connector"; +import theme from "@newm-web/theme"; +import { ConnectWallet } from "./ConnectWallet"; +import { Sale } from "./Sale"; +import { MarketplaceTabSkeleton } from "../../../../components"; +import { SongRouteParams } from "../types"; +import { useGetUserWalletSongsThunk } from "../../../../modules/song"; + +export const MarketplaceTab = () => { + const { songId } = useParams<"songId">() as SongRouteParams; + const [getUserWalletSongs, { data: walletSongsResponse, isLoading }] = + useGetUserWalletSongsThunk(); + const { wallet } = useConnectWallet(); + + const hasStreamTokens = + walletSongsResponse?.data?.songs[0]?.song?.id === songId; + + useEffect(() => { + getUserWalletSongs({ + ids: [songId], + limit: 1, + }); + }, [getUserWalletSongs, songId, wallet]); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + { hasStreamTokens ? : } + + ); +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/Sale.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/Sale.tsx new file mode 100644 index 000000000..3cbf0597f --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/Sale.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { SaleStatus } from "@newm-web/types"; +import { ActiveSale } from "./ActiveSale"; +import { CreateSale } from "./CreateSale"; +import SaleEndPending from "./SaleEndPending"; +import SaleStartPending from "./SaleStartPending"; +import { + LOCAL_STORAGE_SALE_END_PENDING_KEY, + LOCAL_STORAGE_SALE_START_PENDING_KEY, + SALE_END_UPDATED_EVENT, + SALE_START_UPDATED_EVENT, + SaleStartPendingSongs, +} from "../../../../common"; +import { MarketplaceTabSkeleton } from "../../../../components"; +import { useGetSalesQuery } from "../../../../modules/sale"; +import { SongRouteParams } from "../types"; + +export const Sale = () => { + const { songId } = useParams<"songId">() as SongRouteParams; + const [isSaleEndPending, setIsSaleEndPending] = useState(false); + const [isSaleStartPending, setIsSaleStartPending] = useState(false); + const [isPendingSalesLoading, setIsPendingSalesLoading] = useState(true); + const { data: sales = [], isLoading } = useGetSalesQuery({ + saleStatuses: [SaleStatus.Started], + songIds: [songId], + }); + + /** + * Handle the pending state for sale start. + */ + const handleSaleStartPending = useCallback(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY + ); + if (pendingSales) { + const parsedPendingSales: SaleStartPendingSongs = + JSON.parse(pendingSales); + setIsSaleStartPending(!!parsedPendingSales[songId]); + } else { + setIsSaleStartPending(false); + } + }, [songId]); + + /** + * Handle the pending state for sale end. + */ + const handleSaleEndPending = useCallback(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_END_PENDING_KEY + ); + if (pendingSales) { + const parsedPendingSales: string[] = JSON.parse(pendingSales); + setIsSaleEndPending(parsedPendingSales.includes(songId)); + } else { + setIsSaleEndPending(false); + } + }, [songId]); + + /** + * Initialize and manage the event listeners for pending sales updates. + */ + useEffect(() => { + handleSaleEndPending(); + handleSaleStartPending(); + + window.addEventListener(SALE_END_UPDATED_EVENT, handleSaleEndPending); + window.addEventListener(SALE_START_UPDATED_EVENT, handleSaleStartPending); + + setIsPendingSalesLoading(false); + + return () => { + window.removeEventListener(SALE_END_UPDATED_EVENT, handleSaleEndPending); + window.removeEventListener( + SALE_START_UPDATED_EVENT, + handleSaleStartPending + ); + }; + }, [handleSaleEndPending, handleSaleStartPending]); + + if (isLoading || isPendingSalesLoading) { + return ; + } + + if (isSaleStartPending) { + return ; + } + + if (isSaleEndPending) { + return ; + } + + return sales.length > 0 ? : ; +}; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/SaleEndPending.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/SaleEndPending.tsx new file mode 100644 index 000000000..7e112f6f0 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/SaleEndPending.tsx @@ -0,0 +1,30 @@ +import { AlertTitle, Stack, Typography } from "@mui/material"; +import { Alert, HorizontalLine } from "@newm-web/elements"; +import theme from "@newm-web/theme"; + +const SaleEndPending = () => ( + + + + You have a stream token sale for this track that is pending + cancellation! + + + Your sale is now being removed from the Marketplace. + + + + Stream token sales may take several minutes to be removed from the + Marketplace. You will receive an email notification once the sale has been + successfully canceled. Once the cancellation is finalized, you can create + a new stream token sale for this track. + + + +); + +export default SaleEndPending; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/SaleStartPending.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/SaleStartPending.tsx new file mode 100644 index 000000000..2c4e5c967 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/SaleStartPending.tsx @@ -0,0 +1,64 @@ +import { useParams } from "react-router-dom"; +import currency from "currency.js"; +import { AlertTitle, Stack, Typography } from "@mui/material"; +import { Alert, HorizontalLine } from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { useEffect, useState } from "react"; +import { SongRouteParams } from "../types"; +import { + LOCAL_STORAGE_SALE_START_PENDING_KEY, + SaleStartPendingSongs, +} from "../../../../common"; + +const SaleStartPending = () => { + const { songId } = useParams<"songId">() as SongRouteParams; + const [tokensToSell, setTokensToSell] = useState(); + + useEffect(() => { + const pendingSales = localStorage.getItem( + LOCAL_STORAGE_SALE_START_PENDING_KEY + ); + + if (pendingSales) { + const parsedPendingSales: SaleStartPendingSongs = + JSON.parse(pendingSales); + + setTokensToSell(parsedPendingSales[songId]?.tokensToSell); + } + }, [songId]); + + return ( + + + + You already have a pending stream token sale for this track! + + + { ` + ${ + tokensToSell + ? currency(tokensToSell, { + precision: 0, + symbol: "", + }).format() + : "Your" + } + stream tokens are being made available for sale on the Marketplace.` } + + + + Stream token sales may take several minutes to appear on the + Marketplace. You will receive an email notification once the sale has + been successfully created. Once available, you can choose to end the + sale, and we'll return the unsold stream tokens to your wallet. + + + + ); +}; + +export default SaleStartPending; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/StartSaleModal.tsx b/apps/studio/src/pages/home/library/MarketplaceTab/StartSaleModal.tsx new file mode 100644 index 000000000..61bb8e5a8 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/StartSaleModal.tsx @@ -0,0 +1,195 @@ +import { FunctionComponent } from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import { Button, HorizontalLine, Modal } from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { FormikValues } from "formik"; +import currency from "currency.js"; +import { + FULL_OWNERSHIP_STREAM_TOKENS, + NEWM_DECIMAL_CONVERSION, + formatNewmAmount, + formatPercentageAdaptive, +} from "@newm-web/utils"; +import { useGetNEWMPriceQuery } from "../../../../modules/crypto"; + +interface StartSaleModalProps { + readonly handleClose: () => void; + readonly handleStartSale: (values: FormikValues) => void; + readonly isLoading: boolean; + readonly isOpen: boolean; + readonly totalTokensOwnedByUser: number; + readonly values: FormikValues; +} + +const StartSaleModal: FunctionComponent = ({ + handleClose, + isOpen, + handleStartSale, + isLoading, + totalTokensOwnedByUser, + values, +}) => { + const { data: NEWMPriceData } = useGetNEWMPriceQuery(); + // Set the NEWM price in USD to 0 if the price data is not available + const NEWMPriceInUSD = NEWMPriceData?.usdPrice + ? NEWMPriceData?.usdPrice / NEWM_DECIMAL_CONVERSION + : 0; + const isNEWMPriceUnavailable = NEWMPriceInUSD === 0; + const totalSaleValueInUSD = values.totalSaleValue * NEWMPriceInUSD; + const pricePerStreamTokenInUSD = totalSaleValueInUSD / values.tokensToSell; + + return ( + + + + + Stream Token Sale Summary + + + + + Stream tokens to sell + + + { currency(values.tokensToSell, { + precision: 0, + symbol: "", + }).format() } + + + + + % of artist's stream token ownership + + + { formatPercentageAdaptive( + (values.tokensToSell / totalTokensOwnedByUser) * 100 + ) } + % + + + + + % of track's total streaming royalty rights + + + { formatPercentageAdaptive( + (values.tokensToSell / FULL_OWNERSHIP_STREAM_TOKENS) * 100 + ) } + % + + + + + Total sale value + + + { isNEWMPriceUnavailable + ? "(≈ $ N/A)" + : `(≈ ${currency(totalSaleValueInUSD, { + precision: 3, + symbol: "$", + }).format()})` } + + { formatNewmAmount(values.totalSaleValue, true) } + + + + + Price per stream token + + + + { isNEWMPriceUnavailable + ? "(≈ $ N/A)" + : `(≈ ${currency(pricePerStreamTokenInUSD, { + precision: 3, + symbol: "$", + }).format()})` } + + { formatNewmAmount( + values.totalSaleValue / values.tokensToSell, + true + ) } + + + + + + Stream token sales may take several minutes to appear on the + Marketplace. You will receive an email notification once the sale + has been successfully created. + + + + + + + + + + + ); +}; + +export default StartSaleModal; diff --git a/apps/studio/src/pages/home/library/MarketplaceTab/index.ts b/apps/studio/src/pages/home/library/MarketplaceTab/index.ts new file mode 100644 index 000000000..84200eb51 --- /dev/null +++ b/apps/studio/src/pages/home/library/MarketplaceTab/index.ts @@ -0,0 +1 @@ +export { MarketplaceTab } from "./MarketplaceTab"; diff --git a/apps/studio/src/pages/home/library/ViewDetails.tsx b/apps/studio/src/pages/home/library/ViewDetails.tsx index b1a6bbb2d..bf0e8323b 100644 --- a/apps/studio/src/pages/home/library/ViewDetails.tsx +++ b/apps/studio/src/pages/home/library/ViewDetails.tsx @@ -7,9 +7,11 @@ import { useNavigate, useParams } from "react-router-dom"; import { Button, ProfileImage, Tooltip } from "@newm-web/elements"; import theme from "@newm-web/theme"; import { resizeCloudinaryImage } from "@newm-web/utils"; +import { MintingStatus } from "@newm-web/types"; import MintSong from "./MintSong"; import SongInfo from "./SongInfo"; import { SongRouteParams } from "./types"; +import { MarketplaceTab } from "./MarketplaceTab"; import { NEWM_SUPPORT_EMAIL, isSongEditable } from "../../../common"; import { setToastMessage } from "../../../modules/ui"; import { @@ -17,6 +19,7 @@ import { useGetSongQuery, useHasSongAccess, } from "../../../modules/song"; +import { useGetStudioClientConfigQuery } from "../../../modules/content"; interface TabPanelProps { children: ReactNode; @@ -50,6 +53,7 @@ const TabPanel: FunctionComponent = ({ const colorMap: ColorMap = { 0: "music", 1: "crypto", + 2: "partners", }; const ViewDetails: FunctionComponent = () => { @@ -66,7 +70,22 @@ const ViewDetails: FunctionComponent = () => { isLoading, } = useGetSongQuery(songId); + const { data: clientConfig, isLoading: isClientConfigLoading } = + useGetStudioClientConfigQuery(); + const hasAccess = useHasSongAccess(songId); + const isSongMintedOrReleased = [ + MintingStatus.Minted, + MintingStatus.Released, + ].includes(mintingStatus); + + const isManageMarketplaceSalesEnabled = + clientConfig?.featureFlags?.manageMarketplaceSalesEnabled ?? false; + + const shouldRenderMarketplaceTab = + !isClientConfigLoading && + isSongMintedOrReleased && + isManageMarketplaceSalesEnabled; const handleChange = (event: SyntheticEvent, nextTab: number) => { setTab(nextTab); @@ -164,6 +183,9 @@ const ViewDetails: FunctionComponent = () => { > + { shouldRenderMarketplaceTab && ( + + ) } @@ -172,6 +194,9 @@ const ViewDetails: FunctionComponent = () => { + + + ); diff --git a/package-lock.json b/package-lock.json index 2910828ba..f7373b4ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@mui/icons-material": "^5.2.5", "@mui/material": "^5.13.5", "@mui/material-nextjs": "^5.15.6", - "@newm.io/cardano-dapp-wallet-connector": "^1.0.11", + "@newm.io/cardano-dapp-wallet-connector": "^1.0.16", "@react-oauth/google": "^0.11.0", "@reduxjs/toolkit": "^2.1.0", "@sentry/cli": "^2.21.2", @@ -4446,9 +4446,9 @@ } }, "node_modules/@newm.io/cardano-dapp-wallet-connector": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@newm.io/cardano-dapp-wallet-connector/-/cardano-dapp-wallet-connector-1.0.15.tgz", - "integrity": "sha512-e0DHuZvWfG67Gpc+p9ffK65eDiVib6YzoYcLFtlCDlvGCR7KfaajulMgBEZ1J2KQOGnVsaGSqRF4ujO4SAW20A==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@newm.io/cardano-dapp-wallet-connector/-/cardano-dapp-wallet-connector-1.0.16.tgz", + "integrity": "sha512-UrVkkzgO1p/IQicZTctHtM37+gTK/Xe5EC9g3JQ6ECFWBBSiDxylo+E3P6fZ64mLRwYbTVuFnxR1sd/oGs6TYA==", "dependencies": { "bech32": "^2.0.0", "buffer": "^6.0.3", diff --git a/package.json b/package.json index 2fc4ac496..c381c3538 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@mui/icons-material": "^5.2.5", "@mui/material": "^5.13.5", "@mui/material-nextjs": "^5.15.6", - "@newm.io/cardano-dapp-wallet-connector": "^1.0.11", + "@newm.io/cardano-dapp-wallet-connector": "^1.0.16", "@react-oauth/google": "^0.11.0", "@reduxjs/toolkit": "^2.1.0", "@sentry/cli": "^2.21.2", diff --git a/packages/elements/src/lib/DropdownSelect.tsx b/packages/elements/src/lib/DropdownSelect.tsx index 6a85a0484..1de8b4a2f 100644 --- a/packages/elements/src/lib/DropdownSelect.tsx +++ b/packages/elements/src/lib/DropdownSelect.tsx @@ -9,13 +9,14 @@ import { import { useAutocomplete } from "@mui/base/useAutocomplete"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import theme from "@newm-web/theme"; -import { Box } from "@mui/material"; +import { Box, SxProps } from "@mui/material"; import TextInput from "./TextInput"; import ResultsList from "./styled/ResultsList"; import NoResultsText from "./styled/NoResultsText"; import { DropdownSelectFieldProps } from "./form/DropdownSelectField"; interface DropdownSelectProps extends DropdownSelectFieldProps { + readonly containerSxOverrides?: SxProps; readonly errorMessage?: string; readonly onChange?: ( event: SyntheticEvent, @@ -38,6 +39,7 @@ const DropdownSelect: ForwardRefRenderFunction< placeholder, value = null, widthType, + containerSxOverrides, ...rest }, ref: ForwardedRef @@ -87,6 +89,7 @@ const DropdownSelect: ForwardRefRenderFunction< maxWidth: widthType === "default" ? theme.inputField.maxWidth : null, position: "relative", width: "100%", + ...containerSxOverrides, } } >
diff --git a/packages/elements/src/lib/form/DropdownSelectField.tsx b/packages/elements/src/lib/form/DropdownSelectField.tsx index 8faf51c18..e529c2639 100644 --- a/packages/elements/src/lib/form/DropdownSelectField.tsx +++ b/packages/elements/src/lib/form/DropdownSelectField.tsx @@ -1,10 +1,12 @@ import { ForwardRefRenderFunction, HTMLProps, forwardRef } from "react"; import { Field, FieldProps } from "formik"; import { WidthType } from "@newm-web/utils"; +import { SxProps } from "@mui/material"; import DropdownSelect from "../DropdownSelect"; export interface DropdownSelectFieldProps extends Omit, "as" | "ref" | "onChange"> { + readonly containerSxOverrides?: SxProps; readonly disabled?: boolean; readonly isOptional?: boolean; readonly label?: string; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c6eb99565..54737dd2b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,2 +1,3 @@ export * from "./lib/song"; export * from "./lib/api"; +export * from "./lib/sale"; diff --git a/apps/marketplace/src/modules/sale/types.ts b/packages/types/src/lib/sale.ts similarity index 97% rename from apps/marketplace/src/modules/sale/types.ts rename to packages/types/src/lib/sale.ts index 7a78030e9..4182fb07d 100644 --- a/apps/marketplace/src/modules/sale/types.ts +++ b/packages/types/src/lib/sale.ts @@ -28,14 +28,14 @@ export interface Sale { // Maximum bundle size allowed readonly maxBundleSize: number; // The song associated with the sale - readonly song: Song; + readonly song: MarketplaceSong; // Sale status. Valid valid values are: Started, SoldOut & Ended readonly status: SaleStatus; // Total quantity of bundles originally for sale readonly totalBundleQuantity: number; } -export interface Song { +export interface MarketplaceSong { // UUID of the song artist readonly artistId: string; // Stage name of the song artist @@ -142,6 +142,6 @@ export interface ApiSale extends Omit { readonly song: ApiSong; } -export interface ApiSong extends Omit { +export interface ApiSong extends Omit { readonly parentalAdvisory: "Explicit" | "Non-Explicit"; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5a2f263d6..4554215aa 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -17,3 +17,4 @@ export * from "./lib/form"; export * from "./lib/tests"; export * from "./lib/wallet"; export * from "./lib/image"; +export * from "./lib/sales"; diff --git a/packages/utils/src/lib/sales.ts b/packages/utils/src/lib/sales.ts new file mode 100644 index 000000000..76bde4a03 --- /dev/null +++ b/packages/utils/src/lib/sales.ts @@ -0,0 +1,66 @@ +import { ApiSale, Sale } from "@newm-web/types"; + +/** + * Full ownership stream tokens, used for calculating ownership percentage. + * This represents 100% ownership of a song. + */ +export const FULL_OWNERSHIP_STREAM_TOKENS = 100000000; + +/** + * Conversion factor for converting between NEWM values. + * TODO: look into back-end returning value with correct decimal places + */ +export const NEWM_DECIMAL_CONVERSION = 1000000; + +/** + * Creates a sale object from the sale API object with + * the song.parentalAdvisory string field replaced with + * a song.isExplicit boolean field. + */ +export const transformApiSale = (apiSale: ApiSale): Sale => { + const { + song: { parentalAdvisory, ...song }, + ...sale + } = apiSale; + + return { + ...sale, + costAmount: sale.costAmount / NEWM_DECIMAL_CONVERSION, + song: { + ...song, + isExplicit: parentalAdvisory === "Explicit", + }, + }; +}; + +/** + * Calculates the ownership percentage based on the number of tokens given and + * the pre-defined number of tokens that equals to 100% ownership. + * + * @param {number} tokens - The number of tokens owned. + * @returns {number} The ownership percentage relative to full ownership. + */ +export const calculateOwnershipPerecentage = (tokens: number) => { + return (tokens / FULL_OWNERSHIP_STREAM_TOKENS) * 100; +}; + +/** + * Formats a given percentage value. Smaller values are shown with more decimal places + * to preserve their significance, whereas larger values are displayed with fewer decimals. + */ +export const formatPercentageAdaptive = (percentage: number) => { + let formattedPercentage; + + if (percentage < 0.01) { + // Very small percentages display with more precision + formattedPercentage = percentage.toFixed(8); + } else if (percentage < 1) { + // Small but not tiny percentages + formattedPercentage = percentage.toFixed(4); + } else { + // Larger percentages + formattedPercentage = percentage.toFixed(2); + } + + return parseFloat(formattedPercentage); +}; diff --git a/packages/utils/src/lib/wallet.ts b/packages/utils/src/lib/wallet.ts index 6e525d1c7..b36d5ee57 100644 --- a/packages/utils/src/lib/wallet.ts +++ b/packages/utils/src/lib/wallet.ts @@ -1,3 +1,5 @@ +import { Buffer } from "buffer"; +import { bech32 } from "bech32"; import { EnabledWallet } from "@newm.io/cardano-dapp-wallet-connector"; import { isProd } from "@newm-web/env"; @@ -7,3 +9,26 @@ export const getIsWalletEnvMismatch = async (wallet: EnabledWallet) => { return isProd !== isWalletProd; }; + +export const encodeAddress = (hex: string) => { + // https://cips.cardano.org/cip/CIP-19 + let prefix; + switch (hex[0]) { + case "8": + throw new Error("Byron addresses not supported"); + case "e": + case "E": + prefix = "stake"; + break; + default: + prefix = "addr"; + break; + } + if (hex[1] === "0") { + prefix += "_test"; + } + const bytes = Buffer.from(hex, "hex"); + const words = bech32.toWords(bytes); + + return bech32.encode(prefix, words, 1000); +};