From dfae82f1c677b30ff871722b0a4bd470aed3b5fb Mon Sep 17 00:00:00 2001 From: Trevor Scandalios <5877597+scandycuz@users.noreply.github.com> Date: Sun, 5 May 2024 22:21:47 -0700 Subject: [PATCH] MRKT-24: sale data (#608) * replaces mock content for artist spotlight and item page * renames songs to sales to reflect data * various refactors * adds optional params to getSales endpoint * minor updates * fixes errant import * minor updates * renames currency lib * prevents song card price from wrapping * minor update to song card font size * resolves linting issues * updates approximately equal symbol * adds decimal place to newm amounts * makes additional updates to reference API data * resolves linting issue * adds comments * misc refactors * minor refactors and adds Toast component --- apps/marketplace/src/api/constants.ts | 8 + apps/marketplace/src/api/index.ts | 1 + apps/marketplace/src/api/newm/api.ts | 19 + apps/marketplace/src/api/newm/types.ts | 3 + apps/marketplace/src/api/utils.ts | 32 ++ .../src/app/artist/[artistId]/page.tsx | 8 +- apps/marketplace/src/app/layout.tsx | 3 + apps/marketplace/src/app/page.tsx | 6 +- .../[songId] => sale/[saleId]}/layout.tsx | 8 +- .../{item/[songId] => sale/[saleId]}/page.tsx | 138 +++++-- apps/marketplace/src/app/search/page.tsx | 2 +- apps/marketplace/src/buildParams.ts | 4 + .../src/components/ArtistSongs.tsx | 17 + apps/marketplace/src/components/MoreSongs.tsx | 7 +- apps/marketplace/src/components/Sales.tsx | 94 +++++ .../src/components/SimilarSongs.tsx | 8 +- apps/marketplace/src/components/Songs.tsx | 85 ---- apps/marketplace/src/components/Toast.tsx | 25 ++ apps/marketplace/src/components/index.ts | 3 +- .../src/components/skeletons/ItemSkeleton.tsx | 8 +- apps/marketplace/src/modules/sale/api.ts | 86 ++++ apps/marketplace/src/modules/sale/index.ts | 3 + apps/marketplace/src/modules/sale/types.ts | 117 ++++++ apps/marketplace/src/modules/sale/utils.ts | 21 + apps/marketplace/src/modules/ui/index.ts | 4 + apps/marketplace/src/modules/ui/selectors.ts | 3 + apps/marketplace/src/modules/ui/slice.ts | 29 ++ apps/marketplace/src/modules/ui/types.ts | 7 + apps/marketplace/src/store.ts | 9 +- apps/marketplace/src/temp/data.ts | 371 +++++++++++++----- .../src/api/newm/api.ts | 3 +- apps/mobile-wallet-connector/src/api/utils.ts | 59 +-- apps/studio/src/api/cloudinary/api.ts | 2 +- apps/studio/src/api/lambda/api.ts | 7 +- apps/studio/src/api/newm/api.ts | 7 +- apps/studio/src/api/utils.ts | 56 --- .../dspPricing/PricingPlansDialog.tsx | 6 +- .../components/minting/SelectCoCreators.tsx | 2 +- packages/components/src/index.ts | 8 +- packages/components/src/lib/SongCard.tsx | 107 ++--- .../src/lib/skeletons}/SongCardSkeleton.tsx | 7 +- packages/elements/src/lib/ResponsiveImage.tsx | 20 +- packages/elements/src/lib/skeleton/index.ts | 1 - packages/utils/src/index.ts | 3 + packages/utils/src/lib/api.ts | 58 +++ packages/utils/src/lib/audio.ts | 77 ++++ packages/utils/src/lib/crypto.ts | 15 + 47 files changed, 1132 insertions(+), 435 deletions(-) create mode 100644 apps/marketplace/src/api/constants.ts create mode 100644 apps/marketplace/src/api/index.ts create mode 100644 apps/marketplace/src/api/newm/api.ts create mode 100644 apps/marketplace/src/api/newm/types.ts create mode 100644 apps/marketplace/src/api/utils.ts rename apps/marketplace/src/app/{item/[songId] => sale/[saleId]}/layout.tsx (50%) rename apps/marketplace/src/app/{item/[songId] => sale/[saleId]}/page.tsx (56%) create mode 100644 apps/marketplace/src/components/ArtistSongs.tsx create mode 100644 apps/marketplace/src/components/Sales.tsx delete mode 100644 apps/marketplace/src/components/Songs.tsx create mode 100644 apps/marketplace/src/components/Toast.tsx create mode 100644 apps/marketplace/src/modules/sale/api.ts create mode 100644 apps/marketplace/src/modules/sale/index.ts create mode 100644 apps/marketplace/src/modules/sale/types.ts create mode 100644 apps/marketplace/src/modules/sale/utils.ts create mode 100644 apps/marketplace/src/modules/ui/index.ts create mode 100644 apps/marketplace/src/modules/ui/selectors.ts create mode 100644 apps/marketplace/src/modules/ui/slice.ts create mode 100644 apps/marketplace/src/modules/ui/types.ts rename packages/{elements/src/lib/skeleton => components/src/lib/skeletons}/SongCardSkeleton.tsx (76%) create mode 100644 packages/utils/src/lib/api.ts create mode 100644 packages/utils/src/lib/audio.ts create mode 100644 packages/utils/src/lib/crypto.ts diff --git a/apps/marketplace/src/api/constants.ts b/apps/marketplace/src/api/constants.ts new file mode 100644 index 000000000..ccfe30b6c --- /dev/null +++ b/apps/marketplace/src/api/constants.ts @@ -0,0 +1,8 @@ +/** + * Maps RTKQuery API endpoint names with name that + * back-end expects for recaptcha action argument. + */ +export const recaptchaEndpointActionMap: Record = { + getSale: "get_sale", + getSales: "get_sales", +}; diff --git a/apps/marketplace/src/api/index.ts b/apps/marketplace/src/api/index.ts new file mode 100644 index 000000000..5cf5b3752 --- /dev/null +++ b/apps/marketplace/src/api/index.ts @@ -0,0 +1 @@ +export { default as newmApi } from "./newm/api"; diff --git a/apps/marketplace/src/api/newm/api.ts b/apps/marketplace/src/api/newm/api.ts new file mode 100644 index 000000000..790993d89 --- /dev/null +++ b/apps/marketplace/src/api/newm/api.ts @@ -0,0 +1,19 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; +import { axiosBaseQuery } from "@newm-web/utils"; +import { Tags } from "./types"; +import { prepareHeaders } from "../utils"; +import { baseUrls } from "../../buildParams"; + +export const baseQuery = axiosBaseQuery({ + baseUrl: baseUrls.newm, + prepareHeaders, +}); + +const api = createApi({ + baseQuery, + endpoints: () => ({}), + reducerPath: "newmApi", + tagTypes: [Tags.Sale], +}); + +export default api; diff --git a/apps/marketplace/src/api/newm/types.ts b/apps/marketplace/src/api/newm/types.ts new file mode 100644 index 000000000..ec71d7140 --- /dev/null +++ b/apps/marketplace/src/api/newm/types.ts @@ -0,0 +1,3 @@ +export enum Tags { + Sale = "Sale", +} diff --git a/apps/marketplace/src/api/utils.ts b/apps/marketplace/src/api/utils.ts new file mode 100644 index 000000000..5904ab724 --- /dev/null +++ b/apps/marketplace/src/api/utils.ts @@ -0,0 +1,32 @@ +import { BaseQueryApi } from "@reduxjs/toolkit/dist/query/baseQueryTypes"; +import { AxiosRequestConfig } from "axios"; +import { executeRecaptcha } from "@newm-web/utils"; +import { recaptchaEndpointActionMap } from "./constants"; + +/** + * Returns recaptcha headers. + */ +export const getRecaptchaHeaders = async (api: BaseQueryApi) => { + const { endpoint } = api; + const action = recaptchaEndpointActionMap[endpoint] || endpoint; + + return { + "g-recaptcha-platform": "Web", + "g-recaptcha-token": await executeRecaptcha(action), + }; +}; + +/** + * Adds necessary authentication headers to requests. + */ +export const prepareHeaders = async ( + api: BaseQueryApi, + headers: AxiosRequestConfig["headers"] +) => { + const recaptchaHeaders = await getRecaptchaHeaders(api); + + return { + ...recaptchaHeaders, + ...headers, + }; +}; diff --git a/apps/marketplace/src/app/artist/[artistId]/page.tsx b/apps/marketplace/src/app/artist/[artistId]/page.tsx index e1c38b5c8..db3a168b4 100644 --- a/apps/marketplace/src/app/artist/[artistId]/page.tsx +++ b/apps/marketplace/src/app/artist/[artistId]/page.tsx @@ -3,8 +3,8 @@ import { Box, Container, Stack, Typography, useTheme } from "@mui/material"; import { FunctionComponent, useState } from "react"; import { resizeCloudinaryImage, useBetterMediaQuery } from "@newm-web/utils"; import { ProfileHeader, ProfileModal } from "@newm-web/components"; -import { SimilarArtists, Songs } from "../../../components"; -import { mockArtist, mockSongs } from "../../../temp/data"; +import { ArtistSongs, SimilarArtists } from "../../../components"; +import { mockArtist } from "../../../temp/data"; interface ArtistProps { readonly params: { @@ -59,9 +59,7 @@ const Artist: FunctionComponent = ({ params }) => { onClickAbout={ () => setIsAboutModalOpen(true) } /> - - - + diff --git a/apps/marketplace/src/app/layout.tsx b/apps/marketplace/src/app/layout.tsx index 4298e1443..d00716cd4 100644 --- a/apps/marketplace/src/app/layout.tsx +++ b/apps/marketplace/src/app/layout.tsx @@ -8,6 +8,7 @@ import { StyledComponentsRegistry } from "@newm-web/components"; import { Provider } from "react-redux"; import { Footer, Header } from "../components"; import store from "../store"; +import Toast from "../components/Toast"; interface RootLayoutProps { readonly children: ReactNode; @@ -44,6 +45,8 @@ const RootLayout: FunctionComponent = ({ children }) => { + +
diff --git a/apps/marketplace/src/app/page.tsx b/apps/marketplace/src/app/page.tsx index e00baebac..8f50fd7e9 100644 --- a/apps/marketplace/src/app/page.tsx +++ b/apps/marketplace/src/app/page.tsx @@ -1,14 +1,14 @@ "use client"; import { FunctionComponent } from "react"; import { Box, Container } from "@mui/material"; -import { ArtistSpotlight, Songs } from "../components"; -import { mockSongs } from "../temp/data"; +import { ArtistSpotlight, Sales } from "../components"; +import { mockSales } from "../temp/data"; const Home: FunctionComponent = () => { return ( - + diff --git a/apps/marketplace/src/app/item/[songId]/layout.tsx b/apps/marketplace/src/app/sale/[saleId]/layout.tsx similarity index 50% rename from apps/marketplace/src/app/item/[songId]/layout.tsx rename to apps/marketplace/src/app/sale/[saleId]/layout.tsx index d124c5157..36e203cde 100644 --- a/apps/marketplace/src/app/item/[songId]/layout.tsx +++ b/apps/marketplace/src/app/sale/[saleId]/layout.tsx @@ -1,12 +1,16 @@ import { FunctionComponent, ReactNode } from "react"; -import { mockSongs } from "../../../temp/data"; +import { GetSalesResponse } from "../../../modules/sale"; +import { baseUrls } from "../../../buildParams"; interface SongLayoutProps { readonly children: ReactNode; } export const generateStaticParams = async () => { - return mockSongs.map(({ id }) => ({ songId: id })); + const resp = await fetch(`${baseUrls.newm}/v1/marketplace/sales`); + const data: GetSalesResponse = await resp.json(); + + return data.map(({ id }) => ({ saleId: id })); }; const Layout: FunctionComponent = ({ children }) => { diff --git a/apps/marketplace/src/app/item/[songId]/page.tsx b/apps/marketplace/src/app/sale/[saleId]/page.tsx similarity index 56% rename from apps/marketplace/src/app/item/[songId]/page.tsx rename to apps/marketplace/src/app/sale/[saleId]/page.tsx index 36af37b1c..59c96cf02 100644 --- a/apps/marketplace/src/app/item/[songId]/page.tsx +++ b/apps/marketplace/src/app/sale/[saleId]/page.tsx @@ -8,9 +8,10 @@ import { useTheme, } from "@mui/material"; import HelpIcon from "@mui/icons-material/Help"; +import currency from "currency.js"; import { SongCard } from "@newm-web/components"; -import { FunctionComponent, useEffect, useState } from "react"; -import { resizeCloudinaryImage } from "@newm-web/utils"; +import * as Yup from "yup"; +import { FunctionComponent } from "react"; import { Button, ProfileImage, @@ -18,27 +19,75 @@ import { Tooltip, } from "@newm-web/elements"; import { Form, Formik } from "formik"; +import { useRouter } from "next/navigation"; +import { formatNewmAmount, usePlayAudioUrl } from "@newm-web/utils"; +import { useGetSaleQuery } from "../../../modules/sale"; import MoreSongs from "../../../components/MoreSongs"; -import { mockSongs } from "../../../temp/data"; import { ItemSkeleton, SimilarSongs } from "../../../components"; interface SingleSongProps { readonly params: { - readonly songId: string; + readonly saleId: string; }; } const SingleSong: FunctionComponent = ({ params }) => { const theme = useTheme(); - const [isLoading, setIsLoading] = useState(true); - const songData = mockSongs.find((song) => song.id === params.songId); + const router = useRouter(); - // TEMP: simulate data loading - useEffect(() => { - setTimeout(() => { - setIsLoading(false); - }, 1000); - }, []); + const { isAudioPlaying, playPauseAudio } = usePlayAudioUrl(); + const { isLoading, data: sale } = useGetSaleQuery(params.saleId); + + const initialFormValues = { + streamTokens: 1000, + }; + + const formValidationSchema = Yup.object({ + streamTokens: Yup.number() + .required("This field is required") + .integer() + .min(1) + .max(sale?.availableBundleQuantity || 0), + }); + + /** + * @returns what percentage of the total tokens + * the current purchase amount is. + */ + const getPercentageOfTotalStreamTokens = (purchaseAmount: number) => { + if (!sale) return; + + const percentage = (purchaseAmount / sale.totalBundleQuantity) * 100; + return parseFloat(percentage.toFixed(6)); + }; + + /** + * @returns total cost of purchase in NEWM and USD. + */ + const getTotalPurchaseCost = (purchaseAmount: number) => { + if (!sale) { + throw new Error("no sale present"); + } + + const newmAmount = purchaseAmount * sale.costAmount; + const usdAmount = purchaseAmount * sale.costAmountUsd; + + return { + newmAmount: formatNewmAmount(newmAmount), + usdAmount: currency(usdAmount).format(), + }; + }; + + /** + * Navigates to the artist page when clicked. + */ + const handleArtistClick = () => { + if (!sale) { + throw new Error("no sale present"); + } + + router.push(`/artist/${sale.song.artistId}`); + }; if (isLoading) { return ; @@ -53,51 +102,56 @@ const SingleSong: FunctionComponent = ({ params }) => { > {} } - // eslint-disable-next-line @typescript-eslint/no-empty-function - onSubtitleClick={ () => {} } + isLoading={ isLoading } + isPlayable={ !!sale?.song.clipUrl } + isPlaying={ isAudioPlaying } + priceInNewm={ sale?.costAmount } + priceInUsd={ sale?.costAmountUsd } + onPlayPauseClick={ () => playPauseAudio(sale?.song.clipUrl) } /> - { songData?.title } + { sale?.song.title } - { songData?.isExplicit ? "Explicit" : null } + { sale?.song.isExplicit ? "Explicit" : null } - { songData?.description } + + { sale?.song.description } + - - { songData?.artist.firstName } { songData?.artist.lastName } - + { sale?.song.artistName } { return; } } > - { () => { + { ({ values, isValid }) => { + const totalCost = getTotalPurchaseCost(values.streamTokens); + return (
@@ -109,7 +163,7 @@ const SingleSong: FunctionComponent = ({ params }) => { "with the percentage of Streaming royalties you " + "can acquire and the total price of the bundle. " + "For example 1 token is worth = 0.0000001% of " + - "total royalties, and costs ‘Ɲ3.0‘." + "total royalties, and costs '3.0 Ɲ'." } > @@ -133,7 +187,10 @@ const SingleSong: FunctionComponent = ({ params }) => { - + = ({ params }) => { pl={ 1.5 } variant="subtitle2" > - = 0.0000001% of total royalties + ={ " " } + { getPercentageOfTotalStreamTokens( + values.streamTokens + ) } + % of total royalties = ({ params }) => { pt={ 0.5 } variant="subtitle2" > - Maximum stream tokens = 80000 + Maximum stream tokens ={ " " } + { sale?.availableBundleQuantity.toLocaleString() } @@ -163,12 +225,14 @@ const SingleSong: FunctionComponent = ({ params }) => { backgroundColor: theme.colors.grey600, } } > - diff --git a/apps/marketplace/src/app/search/page.tsx b/apps/marketplace/src/app/search/page.tsx index 0e1ba44ea..4e96a75f8 100644 --- a/apps/marketplace/src/app/search/page.tsx +++ b/apps/marketplace/src/app/search/page.tsx @@ -36,7 +36,7 @@ const Search = () => { "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png" } isPlayable={ true } - priceInNEWM="3.0" + priceInNewm={ 3 } subtitle="Luis Viton" title="The Forest Fall" // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/apps/marketplace/src/buildParams.ts b/apps/marketplace/src/buildParams.ts index b86aa6ede..5c80212de 100644 --- a/apps/marketplace/src/buildParams.ts +++ b/apps/marketplace/src/buildParams.ts @@ -4,3 +4,7 @@ import { isProd } from "@newm-web/env"; const isReduxLoggingEnabledInStaging = false; export const isReduxLoggingEnabled = !isProd && isReduxLoggingEnabledInStaging; + +export const baseUrls: Record = { + newm: isProd ? "https://studio.newm.io/" : "https://garage.newm.io/", +}; diff --git a/apps/marketplace/src/components/ArtistSongs.tsx b/apps/marketplace/src/components/ArtistSongs.tsx new file mode 100644 index 000000000..bef216d27 --- /dev/null +++ b/apps/marketplace/src/components/ArtistSongs.tsx @@ -0,0 +1,17 @@ +import { Box } from "@mui/material"; +import { FunctionComponent } from "react"; +import Sales from "./Sales"; +import { useGetSalesQuery } from "../modules/sale/api"; + +const ArtistSongs: FunctionComponent = () => { + // TODO: limit results by artist ID once artist page references API data + const { isLoading, data = [] } = useGetSalesQuery(); + + return ( + + + + ); +}; + +export default ArtistSongs; diff --git a/apps/marketplace/src/components/MoreSongs.tsx b/apps/marketplace/src/components/MoreSongs.tsx index ee1611f16..0e420e0ff 100644 --- a/apps/marketplace/src/components/MoreSongs.tsx +++ b/apps/marketplace/src/components/MoreSongs.tsx @@ -1,13 +1,12 @@ import { FunctionComponent } from "react"; import { Box, useTheme } from "@mui/material"; -import Songs from "./Songs"; -import { mockArtist, mockSongs } from "../temp/data"; +import Sales from "./Sales"; +import { mockArtist, mockSales } from "../temp/data"; const MoreSongs: FunctionComponent = () => { const theme = useTheme(); const artist = mockArtist; - const songs = mockSongs; const artistFullName = `${artist.firstName} ${artist.lastName}`; const title = ( @@ -21,7 +20,7 @@ const MoreSongs: FunctionComponent = () => { return ( - + ); }; diff --git a/apps/marketplace/src/components/Sales.tsx b/apps/marketplace/src/components/Sales.tsx new file mode 100644 index 000000000..8a947fbb9 --- /dev/null +++ b/apps/marketplace/src/components/Sales.tsx @@ -0,0 +1,94 @@ +import { FunctionComponent, ReactNode } from "react"; +import { Box, Grid, Stack, Typography } from "@mui/material"; +import { SongCard, SongCardSkeleton } from "@newm-web/components"; +import { useRouter } from "next/navigation"; +import { usePlayAudioUrl } from "@newm-web/utils"; +import { Sale } from "../modules/sale/types"; + +interface SalesProps { + readonly isLoading?: boolean; + readonly numSkeletons?: number; + readonly sales: ReadonlyArray; + readonly title?: string | ReactNode; +} + +const Sales: FunctionComponent = ({ + title, + sales = [], + isLoading = false, + numSkeletons = 8, +}) => { + const router = useRouter(); + const { audioUrl, isAudioPlaying, playPauseAudio } = usePlayAudioUrl(); + + const handleCardClick = (id: string) => { + router.push(`/sale/${id}`); + }; + + const handleSubtitleClick = (id: string) => { + router.push(`artist/${id}`); + }; + + if (!isLoading && !sales.length) { + return ( + + No songs to display at this time. + + ); + } + + return ( + + { !!title && ( + + + { title } + + + ) } + + + { isLoading + ? new Array(numSkeletons).fill(null).map((_, idx) => { + return ( + + + + ); + }) + : sales.map(({ costAmount, costAmountUsd, id, song }) => { + const genresString = song.genres.join(", "); + + return ( + + handleCardClick(id) } + onPlayPauseClick={ () => playPauseAudio(song.clipUrl) } + onSubtitleClick={ () => handleSubtitleClick(id) } + /> + + ); + }) } + + + ); +}; + +export default Sales; diff --git a/apps/marketplace/src/components/SimilarSongs.tsx b/apps/marketplace/src/components/SimilarSongs.tsx index 83ae36180..2e464de30 100644 --- a/apps/marketplace/src/components/SimilarSongs.tsx +++ b/apps/marketplace/src/components/SimilarSongs.tsx @@ -1,14 +1,12 @@ import { FunctionComponent } from "react"; import { Box } from "@mui/material"; -import Songs from "./Songs"; -import { mockSongs } from "../temp/data"; +import Sales from "./Sales"; +import { mockSales } from "../temp/data"; const MoreSongs: FunctionComponent = () => { - const songs = mockSongs; - return ( - + ); }; diff --git a/apps/marketplace/src/components/Songs.tsx b/apps/marketplace/src/components/Songs.tsx deleted file mode 100644 index 5d6638c52..000000000 --- a/apps/marketplace/src/components/Songs.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { FunctionComponent, ReactNode, useEffect, useState } from "react"; -import { Box, Grid, Stack, Typography } from "@mui/material"; -import { SongCard } from "@newm-web/components"; -import { useRouter } from "next/navigation"; -import { mockSongs } from "../temp/data"; - -interface SongsProps { - readonly songs: typeof mockSongs; - readonly title?: string | ReactNode; -} - -/** - * TODO: Implement useGetSongsQuery and playback functionality, - * see studio/src/pages/home/owners/Songs.tsx - */ -const Songs: FunctionComponent = ({ title, songs }) => { - const router = useRouter(); - - const [isLoading, setIsLoading] = useState(true); - - const handleTitleClick = (id: string) => { - router.push(`/item/${id}`); - }; - - /** - * TEMP: simulate loading - */ - useEffect(() => { - setTimeout(() => { - setIsLoading(false); - }, 1000); - }, []); - - return ( - - { songs.length ? ( - <> - { !!title && ( - - - { title } - - - ) } - - - { mockSongs.map((song) => { - const genresString = song.genres.join(", "); - - return ( - - handleTitleClick(song.id) } - // eslint-disable-next-line @typescript-eslint/no-empty-function - onSubtitleClick={ () => {} } - /> - - ); - }) } - - - ) : ( - - No songs to display at this time. - - ) } - - ); -}; - -export default Songs; diff --git a/apps/marketplace/src/components/Toast.tsx b/apps/marketplace/src/components/Toast.tsx new file mode 100644 index 000000000..d5d278a3f --- /dev/null +++ b/apps/marketplace/src/components/Toast.tsx @@ -0,0 +1,25 @@ +import { Alert } from "@newm-web/components"; +import { clearToastMessage, selectUi } from "../modules/ui"; +import { useAppDispatch, useAppSelector } from "../common"; + +const Toast = () => { + const dispatch = useAppDispatch(); + const { + toast: { heading, message, severity }, + } = useAppSelector(selectUi); + + const handleClose = () => { + dispatch(clearToastMessage()); + }; + + return ( + + ); +}; + +export default Toast; diff --git a/apps/marketplace/src/components/index.ts b/apps/marketplace/src/components/index.ts index 900ec0ed8..3535e401a 100644 --- a/apps/marketplace/src/components/index.ts +++ b/apps/marketplace/src/components/index.ts @@ -1,6 +1,7 @@ export * from "./Artist"; export * from "./skeletons/index"; export { default as Artist } from "./Artist"; +export { default as ArtistSongs } from "./ArtistSongs"; export { default as ArtistSpotlight } from "./ArtistSpotlight"; export { default as Footer } from "./footer/Footer"; export { default as Header } from "./header/Header"; @@ -8,4 +9,4 @@ export { default as ItemSkeleton } from "./skeletons/ItemSkeleton"; export { default as MoreSongs } from "./MoreSongs"; export { default as SimilarArtists } from "./SimilarArtists"; export { default as SimilarSongs } from "./SimilarSongs"; -export { default as Songs } from "./Songs"; +export { default as Sales } from "./Sales"; diff --git a/apps/marketplace/src/components/skeletons/ItemSkeleton.tsx b/apps/marketplace/src/components/skeletons/ItemSkeleton.tsx index ac33e4e3d..65fdfea66 100644 --- a/apps/marketplace/src/components/skeletons/ItemSkeleton.tsx +++ b/apps/marketplace/src/components/skeletons/ItemSkeleton.tsx @@ -7,15 +7,17 @@ const ItemSkeleton: FunctionComponent = () => ( alignItems={ ["center", "center", "start"] } direction={ ["column", "column", "row"] } > - - + + ({ + getSale: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while fetching available songs", + severity: "error", + }) + ); + } + }, + + providesTags: [Tags.Sale], + + query: (saleId) => ({ + method: "GET", + url: `/v1/marketplace/sales/${saleId}`, + }), + + transformResponse: (apiSale: ApiSale) => { + return transformApiSale(apiSale); + }, + }), + getSales: build.query({ + async onQueryStarted(body, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } catch (error) { + dispatch( + setToastMessage({ + message: "An error occurred while fetching available songs", + severity: "error", + }) + ); + } + }, + + providesTags: [Tags.Sale], + + query: ({ + ids, + artistIds, + genres, + moods, + songIds, + statuses, + ...rest + } = {}) => ({ + method: "GET", + params: { + ...(ids ? { ids: ids.join(",") } : {}), + ...(artistIds ? { artistIds: artistIds.join(",") } : {}), + ...(genres ? { genres: genres.join(",") } : {}), + ...(moods ? { moods: moods.join(",") } : {}), + ...(songIds ? { songIds: songIds.join(",") } : {}), + ...(statuses ? { statuses: statuses.join(",") } : {}), + ...rest, + }, + url: "/v1/marketplace/sales", + }), + + transformResponse: (apiSales: ReadonlyArray) => { + return apiSales.map(transformApiSale); + }, + }), + }), +}); + +export const { useGetSaleQuery, useGetSalesQuery } = extendedApi; + +export default extendedApi; diff --git a/apps/marketplace/src/modules/sale/index.ts b/apps/marketplace/src/modules/sale/index.ts new file mode 100644 index 000000000..01c6c9d82 --- /dev/null +++ b/apps/marketplace/src/modules/sale/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./types"; +export * from "./utils"; diff --git a/apps/marketplace/src/modules/sale/types.ts b/apps/marketplace/src/modules/sale/types.ts new file mode 100644 index 000000000..c54e1711b --- /dev/null +++ b/apps/marketplace/src/modules/sale/types.ts @@ -0,0 +1,117 @@ +export enum SaleStatus { + Ended = "Ended", + SoldOut = "SoldOut", + Started = "Started", +} + +export interface SaleState { + readonly sales: ReadonlyArray; +} + +export interface Sale { + // Available quantity of bundles for sale + readonly availableBundleQuantity: number; + // 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; + // Cost of one unit of tokens in NEWM + readonly costAmount: number; + // Cost of one unit of tokens in USD + readonly costAmountUsd: number; + // Asset Name (hex-encoded) of the cost token + readonly costAssetName: string; + // Policy ID of the cost token + readonly costPolicyId: string; + // Date and time when the Song was created (ISO-8601 format) + readonly createdAt: string; + // UUID of the sale + readonly id: string; + // Maximum bundle size allowed + readonly maxBundleSize: number; + // The song associated with the sale + readonly song: Song; + // 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 { + // UUID of the song artist + readonly artistId: string; + // Stage name of the song artist + readonly artistName: string; + // url for the song artist's profile image + readonly artistPictureUrl: string; + // Valid URL of song audio clip file + readonly clipUrl: string; + // Song collaborator objects (see details below). + readonly collaborators: ReadonlyArray; + // Valid URL of cover art picture file + readonly coverArtUrl: string; + // Song description + readonly description: string; + //Song genres + readonly genres: ReadonlyArray; + // UUID of the Song + readonly id: string; + // Whether the song contains explicit lyrics + readonly isExplicit: boolean; + // Song moods + readonly moods: ReadonlyArray; + // Song title + readonly title: string; + // Valid URL of song token agreement document + readonly tokenAgreementUrl: string; +} + +export interface Collaborator { + // UUID of the song collaborator + readonly id: string; + // Stage name of the song collaborator + readonly name: string; + // Role of the song collaborator + readonly role: string; +} + +export type GetSaleResponse = Sale; + +export type GetSalesResponse = ReadonlyArray; + +export interface GetSalesParams { + // List of song artist UUID's to filter results + readonly artistIds?: ReadonlyArray; + // List of song genres to filter results + readonly genres?: ReadonlyArray; + // List of sale UUID's to filter results + readonly ids?: ReadonlyArray; + // Maximum number of paginated results to retrieve. Default is 25 + readonly limit?: number; + // List of song moods to filter results + readonly moods?: ReadonlyArray; + // ISO-8601 formated newest (minimum) timestamp to filter results + readonly newerThan?: string; + // Start offset of paginated results to retrieve. Default is 0 + readonly offset?: number; + // ISO-8601 formated oldest (maximum) timestamp to filter results + readonly olderThan?: string; + // Case-insensitive phrase to filter by song title and artist name + readonly phrase?: string; + // List of song UUID's to filter results + readonly songIds?: ReadonlyArray; + // Sort order of the results based on createdAt field. Default is asc + readonly sortOrder?: "asc" | "desc"; + // List of sale statuses to filter results + readonly statuses?: ReadonlyArray; +} + +export interface ApiSale extends Omit { + readonly song: ApiSong; +} + +export interface ApiSong extends Omit { + readonly parentalAdvisory: "Explicit" | "Non-Explicit"; +} diff --git a/apps/marketplace/src/modules/sale/utils.ts b/apps/marketplace/src/modules/sale/utils.ts new file mode 100644 index 000000000..4716c8eac --- /dev/null +++ b/apps/marketplace/src/modules/sale/utils.ts @@ -0,0 +1,21 @@ +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, + song: { + ...song, + isExplicit: parentalAdvisory === "Explicit", + }, + }; +}; diff --git a/apps/marketplace/src/modules/ui/index.ts b/apps/marketplace/src/modules/ui/index.ts new file mode 100644 index 000000000..231f26a85 --- /dev/null +++ b/apps/marketplace/src/modules/ui/index.ts @@ -0,0 +1,4 @@ +export * from "./selectors"; +export * from "./slice"; +export * from "./types"; +export { default as uiReducer } from "./slice"; diff --git a/apps/marketplace/src/modules/ui/selectors.ts b/apps/marketplace/src/modules/ui/selectors.ts new file mode 100644 index 000000000..c4a1876c1 --- /dev/null +++ b/apps/marketplace/src/modules/ui/selectors.ts @@ -0,0 +1,3 @@ +import { RootState } from "../../store"; + +export const selectUi = (state: RootState): RootState["ui"] => state.ui; diff --git a/apps/marketplace/src/modules/ui/slice.ts b/apps/marketplace/src/modules/ui/slice.ts new file mode 100644 index 000000000..f372a81ff --- /dev/null +++ b/apps/marketplace/src/modules/ui/slice.ts @@ -0,0 +1,29 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { UIState } from "./types"; + +const initialState: UIState = { + toast: { + heading: "", + message: "", + severity: "error", + }, +}; + +const uiSlice = createSlice({ + initialState, + name: "ui", + reducers: { + clearToastMessage: (state) => { + state.toast.heading = ""; + state.toast.message = ""; + state.toast.severity = "error"; + }, + setToastMessage: (state, { payload }: PayloadAction) => { + state.toast = payload; + }, + }, +}); + +export const { clearToastMessage, setToastMessage } = uiSlice.actions; + +export default uiSlice.reducer; diff --git a/apps/marketplace/src/modules/ui/types.ts b/apps/marketplace/src/modules/ui/types.ts new file mode 100644 index 000000000..2e1627f98 --- /dev/null +++ b/apps/marketplace/src/modules/ui/types.ts @@ -0,0 +1,7 @@ +export interface UIState { + toast: { + heading?: string; + message: string; + severity?: "error" | "success"; + }; +} diff --git a/apps/marketplace/src/store.ts b/apps/marketplace/src/store.ts index d56890a13..70a92f6fd 100644 --- a/apps/marketplace/src/store.ts +++ b/apps/marketplace/src/store.ts @@ -2,13 +2,18 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import logger from "redux-logger"; import { isProd } from "@newm-web/env"; import { isReduxLoggingEnabled } from "./buildParams"; +import { newmApi } from "./api"; +import { uiReducer } from "./modules/ui"; -export const reducer = combineReducers({}); +export const reducer = combineReducers({ + ui: uiReducer, + [newmApi.reducerPath]: newmApi.reducer, +}); const store = configureStore({ devTools: !isProd, middleware: (getDefaultMiddleware) => { - const baseMiddleware = getDefaultMiddleware(); + const baseMiddleware = getDefaultMiddleware().prepend(newmApi.middleware); return isReduxLoggingEnabled ? baseMiddleware.prepend(logger) diff --git a/apps/marketplace/src/temp/data.ts b/apps/marketplace/src/temp/data.ts index fc6c926a8..76dbb7dc6 100644 --- a/apps/marketplace/src/temp/data.ts +++ b/apps/marketplace/src/temp/data.ts @@ -1,3 +1,5 @@ +import { Sale, SaleStatus } from "../modules/sale/types"; + export const mockArtist = { coverImageUrl: "https://res.cloudinary.com/newm/image/upload/v1680991027/cvjbuze1tqft5srafmzg.jpg", @@ -35,159 +37,314 @@ export const mockArtists = Array(10) : "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1695587661/mprskynp42oijtpaypeq.jpg", })); -export const mockSongs = [ +const mockArtistFullName = `${mockArtist.firstName} ${mockArtist.lastName}`; +const mockArtistName = mockArtist.stageName || mockArtistFullName; + +export const mockSales: ReadonlyArray = [ { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", - description: ` - Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - Aenean commodo ligula eget dolor. Aenean massa. Cum sociis - natoque penatibus et magnis dis parturient montes, nascetur - ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, - pretium.`, - genres: ["Punk"], + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", - isExplicit: true, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", + description: ` + Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + Aenean commodo ligula eget dolor. Aenean massa. Cum sociis + natoque penatibus et magnis dis parturient montes, nascetur + ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, + pretium.`, + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + isExplicit: true, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", - description: ` - Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - Aenean commodo ligula eget dolor. Aenean massa. Cum sociis - natoque penatibus et magnis dis parturient montes, nascetur - ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, - pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df581", - isExplicit: false, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", + description: ` + Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + Aenean commodo ligula eget dolor. Aenean massa. Cum sociis + natoque penatibus et magnis dis parturient montes, nascetur + ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, + pretium.`, + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df581", + isExplicit: false, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df582", - isExplicit: true, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df582", + isExplicit: true, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1699544008/xrcmyar9m09mk3l9mo1o.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1699544008/xrcmyar9m09mk3l9mo1o.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df583", - isExplicit: false, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df583", + isExplicit: false, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1699580048/aw7w0kielduse0z4vavi.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1699580048/aw7w0kielduse0z4vavi.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df584", - isExplicit: true, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df584", + isExplicit: true, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1701892098/rka1mlzzad6ohrcfqef3.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1701892098/rka1mlzzad6ohrcfqef3.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df585", - isExplicit: false, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df585", + isExplicit: false, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1702264297/ql6f3j5tettsbc3moea3.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1702264297/ql6f3j5tettsbc3moea3.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df586", - isExplicit: true, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df586", + isExplicit: true, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, { - artist: mockArtist, - coverArtUrl: - "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1702264297/ql6f3j5tettsbc3moea3.png", - description: ` + availableBundleQuantity: 100000000, + bundleAmount: 1, + bundleAssetName: "ABCD1234", + bundlePolicyId: "1234ABCD", + costAmount: 3, + costAmountUsd: 0.015, + costAssetName: "XYZ123", + costPolicyId: "123XYZ", + createdAt: new Date("April 10th, 2024").toDateString(), + id: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + maxBundleSize: 1, + song: { + artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58", + artistName: mockArtistName, + artistPictureUrl: + "https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg", + clipUrl: + "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", + collaborators: [], + coverArtUrl: + "https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1702264297/ql6f3j5tettsbc3moea3.png", + description: ` Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium.`, - genres: ["Punk"], - id: "3cfb2d02-a320-4385-96d1-1498d8a1df587", - isExplicit: false, - priceInNEWM: "3.0", - priceInUSD: "4.21", - streamUrl: - "https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8", - title: "Vibrate Punk", + genres: ["Punk"], + id: "3cfb2d02-a320-4385-96d1-1498d8a1df587", + isExplicit: false, + moods: [], + title: "Vibrate Punk", + tokenAgreementUrl: "http://example.com", + }, + status: SaleStatus.Started, + totalBundleQuantity: 100000000, }, ]; -export const mockSong = mockSongs[0]; +export const mockSale = mockSales[0]; diff --git a/apps/mobile-wallet-connector/src/api/newm/api.ts b/apps/mobile-wallet-connector/src/api/newm/api.ts index 37abbc7f2..93121f95a 100644 --- a/apps/mobile-wallet-connector/src/api/newm/api.ts +++ b/apps/mobile-wallet-connector/src/api/newm/api.ts @@ -1,6 +1,7 @@ import { createApi } from "@reduxjs/toolkit/query/react"; +import { axiosBaseQuery } from "@newm-web/utils"; import { baseUrls } from "../../buildParams"; -import { axiosBaseQuery, prepareHeaders } from "../utils"; +import { prepareHeaders } from "../utils"; export const baseQuery = axiosBaseQuery({ baseUrl: baseUrls.newm, diff --git a/apps/mobile-wallet-connector/src/api/utils.ts b/apps/mobile-wallet-connector/src/api/utils.ts index f0009a9e0..7d0d8a8f9 100644 --- a/apps/mobile-wallet-connector/src/api/utils.ts +++ b/apps/mobile-wallet-connector/src/api/utils.ts @@ -1,7 +1,6 @@ import { BaseQueryApi } from "@reduxjs/toolkit/dist/query/baseQueryTypes"; -import axios, { AxiosError, AxiosRequestConfig } from "axios"; +import { AxiosRequestConfig } from "axios"; import { executeRecaptcha } from "@newm-web/utils"; -import { AxiosBaseQueryParams, BaseQuery } from "@newm-web/types"; import { recaptchaEndpointActionMap } from "./constants"; /** @@ -31,59 +30,3 @@ export const prepareHeaders = async ( ...headers, }; }; - -/** - * Sets up base query using axios request library (allows for tracking - * upload progress, which the native fetch library does not). - */ -export const axiosBaseQuery = ( - { baseUrl, prepareHeaders }: AxiosBaseQueryParams = { baseUrl: "" } -): BaseQuery => { - return async ( - { url, method, body, params, headers = {}, onUploadProgress }, - api - ) => { - try { - const axiosInstance = axios.create({ - headers: prepareHeaders ? await prepareHeaders(api, headers) : headers, - - // convert array params to comma separated strings - paramsSerializer: (params) => { - const searchParams = new URLSearchParams(); - for (const key of Object.keys(params)) { - const param = params[key]; - if (Array.isArray(param)) { - for (const p of param) { - searchParams.append(key, p); - } - } else { - searchParams.append(key, param); - } - } - - return searchParams.toString(); - }, - }); - - const result = await axiosInstance({ - data: body, - headers, - method, - onUploadProgress, - params, - url: baseUrl + url, - }); - - return { data: result.data }; - } catch (axiosError) { - const err = axiosError as AxiosError; - - return { - error: { - data: err.response?.data || err.message, - status: err.response?.status, - }, - }; - } - }; -}; diff --git a/apps/studio/src/api/cloudinary/api.ts b/apps/studio/src/api/cloudinary/api.ts index 8cb60d802..bcb1d7b33 100644 --- a/apps/studio/src/api/cloudinary/api.ts +++ b/apps/studio/src/api/cloudinary/api.ts @@ -1,7 +1,7 @@ import { createApi } from "@reduxjs/toolkit/query/react"; +import { axiosBaseQuery } from "@newm-web/utils"; import { CloudinaryUploadParams, CloudinaryUploadResponse } from "./types"; import { baseUrls } from "../../buildParams"; -import { axiosBaseQuery } from "../../api/utils"; const api = createApi({ baseQuery: axiosBaseQuery({ diff --git a/apps/studio/src/api/lambda/api.ts b/apps/studio/src/api/lambda/api.ts index d7009f713..0ea6e81ce 100644 --- a/apps/studio/src/api/lambda/api.ts +++ b/apps/studio/src/api/lambda/api.ts @@ -1,4 +1,5 @@ import { createApi } from "@reduxjs/toolkit/query/react"; +import { axiosBaseQuery } from "@newm-web/utils"; import { GenerateArtistAgreementBody, GenerateArtistAgreementResponse, @@ -6,11 +7,7 @@ import { import { baseUrls } from "../../buildParams"; import { setToastMessage } from "../../modules/ui"; import { baseQuery as newmBaseQuery } from "../newm/api"; -import { - axiosBaseQuery, - fetchBaseQueryWithReauth, - prepareHeaders, -} from "../utils"; +import { fetchBaseQueryWithReauth, prepareHeaders } from "../utils"; const baseQuery = axiosBaseQuery({ baseUrl: baseUrls.lambda, diff --git a/apps/studio/src/api/newm/api.ts b/apps/studio/src/api/newm/api.ts index 8dc06df87..3a924da53 100644 --- a/apps/studio/src/api/newm/api.ts +++ b/apps/studio/src/api/newm/api.ts @@ -1,11 +1,8 @@ import { createApi } from "@reduxjs/toolkit/query/react"; +import { axiosBaseQuery } from "@newm-web/utils"; import { Tags } from "./types"; import { baseUrls } from "../../buildParams"; -import { - axiosBaseQuery, - fetchBaseQueryWithReauth, - prepareHeaders, -} from "../utils"; +import { fetchBaseQueryWithReauth, prepareHeaders } from "../utils"; export const baseQuery = axiosBaseQuery({ baseUrl: baseUrls.newm, diff --git a/apps/studio/src/api/utils.ts b/apps/studio/src/api/utils.ts index 3e9641f92..d18885c0d 100644 --- a/apps/studio/src/api/utils.ts +++ b/apps/studio/src/api/utils.ts @@ -82,62 +82,6 @@ export const fetchBaseQueryWithReauth = ( }; }; -/** - * Sets up base query using axios request library (allows for tracking - * upload progress, which the native fetch library does not). - */ -export const axiosBaseQuery = ( - { baseUrl, prepareHeaders }: AxiosBaseQueryParams = { baseUrl: "" } -): BaseQuery => { - return async ( - { url, method, body, params, headers = {}, onUploadProgress }, - api - ) => { - try { - const axiosInstance = axios.create({ - headers: prepareHeaders ? await prepareHeaders(api, headers) : headers, - - // convert array params to comma separated strings - paramsSerializer: (params) => { - const searchParams = new URLSearchParams(); - for (const key of Object.keys(params)) { - const param = params[key]; - if (Array.isArray(param)) { - for (const p of param) { - searchParams.append(key, p); - } - } else { - searchParams.append(key, param); - } - } - - return searchParams.toString(); - }, - }); - - const result = await axiosInstance({ - data: body, - headers, - method, - onUploadProgress, - params, - url: baseUrl + url, - }); - - return { data: result.data }; - } catch (axiosError) { - const err = axiosError as AxiosError; - - return { - error: { - data: err.response?.data || err.message, - status: err.response?.status, - }, - }; - } - }; -}; - /** * Gets NEWM service auth header. Can be overwritten by an auth * header present for a specific request. diff --git a/apps/studio/src/components/dspPricing/PricingPlansDialog.tsx b/apps/studio/src/components/dspPricing/PricingPlansDialog.tsx index d99db8ac0..64793d4f2 100644 --- a/apps/studio/src/components/dspPricing/PricingPlansDialog.tsx +++ b/apps/studio/src/components/dspPricing/PricingPlansDialog.tsx @@ -36,16 +36,16 @@ const PricingPlansDialog = ({ onClose, open }: PricingPlansDialogProps) => { : "N/A"; const dspFormattedPricingAda = dspPriceAda - ? `(~${formatPriceToDecimal(dspPriceAda)}₳/RELEASE)` + ? `(≈${formatPriceToDecimal(dspPriceAda)}₳/RELEASE)` : undefined; const collabFormattedPricing = collabPerArtistPriceAda - ? ` (~${formatPriceToDecimal(collabPerArtistPriceAda, 1)}₳/each)` + ? ` (≈${formatPriceToDecimal(collabPerArtistPriceAda, 1)}₳/each)` : ""; const totalMintFormattedPricing = mintPriceAda && collabPerArtistPriceAda - ? ` (~${formatPriceToDecimal( + ? ` (≈${formatPriceToDecimal( String( parseFloat(mintPriceAda) + parseFloat(collabPerArtistPriceAda) ), diff --git a/apps/studio/src/components/minting/SelectCoCreators.tsx b/apps/studio/src/components/minting/SelectCoCreators.tsx index 17a69eb49..c5ea35dc5 100644 --- a/apps/studio/src/components/minting/SelectCoCreators.tsx +++ b/apps/studio/src/components/minting/SelectCoCreators.tsx @@ -175,7 +175,7 @@ const FormContent: FunctionComponent = ({ { `For every additional artist who will receive royalties, the - fee to complete the minting process will increase by ~₳${ + fee to complete the minting process will increase by ≈₳${ formatPriceToDecimal(songEstimate?.collabPriceAda) || "N/A" }.` } void; readonly onPlayPauseClick?: () => void; readonly onSubtitleClick?: () => void; - readonly priceInNEWM?: string; - readonly priceInUSD?: string; + readonly priceInNewm?: number; + readonly priceInUsd?: number; readonly subtitle?: string; readonly title?: string; } -export const SongCard = ({ +const SongCard = ({ imageDimensions = 400, coverArtUrl, title, @@ -33,8 +35,8 @@ export const SongCard = ({ onCardClick, onPlayPauseClick, onSubtitleClick, - priceInNEWM, - priceInUSD, + priceInNewm, + priceInUsd, subtitle, isLoading = false, }: SongCardProps) => { @@ -71,6 +73,7 @@ export const SongCard = ({ if (isLoading) { return ( @@ -141,52 +144,68 @@ export const SongCard = ({ ) } - - + + { !!title && ( { title } ) } - { !!subtitle && ( - - { subtitle } - - ) } - - { priceInNEWM || priceInUSD ? ( - - { !!priceInNEWM && ( - - { priceInNEWM } Ɲ + + + { !!priceInNewm && ( + + { formatNewmAmount(priceInNewm) } ) } - { !!priceInUSD && ( - - { `(≈ $${priceInUSD})` } + { !!priceInUsd && ( + +  (≈ { currency(priceInUsd).format() }) ) } - ) : null } + + + { !!subtitle && ( + + { subtitle } + + ) } ); }; + +export default SongCard; diff --git a/packages/elements/src/lib/skeleton/SongCardSkeleton.tsx b/packages/components/src/lib/skeletons/SongCardSkeleton.tsx similarity index 76% rename from packages/elements/src/lib/skeleton/SongCardSkeleton.tsx rename to packages/components/src/lib/skeletons/SongCardSkeleton.tsx index 2e10ad47e..d653239fe 100644 --- a/packages/elements/src/lib/skeleton/SongCardSkeleton.tsx +++ b/packages/components/src/lib/skeletons/SongCardSkeleton.tsx @@ -2,6 +2,7 @@ import { Box, Skeleton, Stack } from "@mui/material"; import { FunctionComponent } from "react"; interface SongCardSkeletonProps { + readonly isPriceVisible?: boolean; readonly isSubtitleVisible?: boolean; readonly isTitleVisible?: boolean; } @@ -9,6 +10,7 @@ interface SongCardSkeletonProps { const SongCardSkeleton: FunctionComponent = ({ isTitleVisible = true, isSubtitleVisible = true, + isPriceVisible = false, }) => ( = ({ top={ 0 } > - { isTitleVisible && } + + { isTitleVisible && } + { isPriceVisible && } + { isSubtitleVisible && } diff --git a/packages/elements/src/lib/ResponsiveImage.tsx b/packages/elements/src/lib/ResponsiveImage.tsx index fb4585b8f..ac5ef1f97 100644 --- a/packages/elements/src/lib/ResponsiveImage.tsx +++ b/packages/elements/src/lib/ResponsiveImage.tsx @@ -1,5 +1,11 @@ import { Box, BoxProps } from "@mui/material"; -import { FunctionComponent, HTMLProps, useState } from "react"; +import { + FunctionComponent, + HTMLProps, + useEffect, + useRef, + useState, +} from "react"; type ResponsiveImagePropsBase = BoxProps & HTMLProps; type ResponsiveImageProps = Omit; @@ -15,8 +21,19 @@ const ResponsiveImage: FunctionComponent = ({ sx, ...rest }) => { + const imgRef = useRef(null); const [isImageLoaded, setIsImageLoaded] = useState(false); + /** + * Occasionally image onLoad will not trigger on initial page load, + * this ensures the non-placeholder image is still rendered. + */ + useEffect(() => { + if (imgRef.current?.complete) { + setIsImageLoaded(true); + } + }, []); + return ( <> { !isImageLoaded && ( @@ -31,6 +48,7 @@ const ResponsiveImage: FunctionComponent = ({ setIsImageLoaded(true) } diff --git a/packages/elements/src/lib/skeleton/index.ts b/packages/elements/src/lib/skeleton/index.ts index 264412935..6ed10a2fa 100644 --- a/packages/elements/src/lib/skeleton/index.ts +++ b/packages/elements/src/lib/skeleton/index.ts @@ -1,2 +1 @@ -export { default as SongCardSkeleton } from "./SongCardSkeleton"; export { default as TableSkeleton } from "./TableSkeleton"; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a8c18dd8f..16a369806 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,9 +1,12 @@ +export * from "./lib/api"; +export * from "./lib/audio"; export * from "./lib/url"; export * from "./lib/array"; export * from "./lib/async"; export * from "./lib/error"; export * from "./lib/file"; export * from "./lib/types"; +export * from "./lib/crypto"; export * from "./lib/hooks"; export * from "./lib/time"; export * from "./lib/redux"; diff --git a/packages/utils/src/lib/api.ts b/packages/utils/src/lib/api.ts new file mode 100644 index 000000000..cf957c229 --- /dev/null +++ b/packages/utils/src/lib/api.ts @@ -0,0 +1,58 @@ +import { AxiosBaseQueryParams, BaseQuery } from "@newm-web/types"; +import axios, { AxiosError } from "axios"; + +/** + * Sets up RTKQuery base query using axios request library (allows for + * tracking upload progress, which the native fetch library does not). + */ +export const axiosBaseQuery = ( + { baseUrl, prepareHeaders }: AxiosBaseQueryParams = { baseUrl: "" } +): BaseQuery => { + return async ( + { url, method, body, params, headers = {}, onUploadProgress }, + api + ) => { + try { + const axiosInstance = axios.create({ + headers: prepareHeaders ? await prepareHeaders(api, headers) : headers, + + // convert array params to comma separated strings + paramsSerializer: (params) => { + const searchParams = new URLSearchParams(); + for (const key of Object.keys(params)) { + const param = params[key]; + if (Array.isArray(param)) { + for (const p of param) { + searchParams.append(key, p); + } + } else { + searchParams.append(key, param); + } + } + + return searchParams.toString(); + }, + }); + + const result = await axiosInstance({ + data: body, + headers, + method, + onUploadProgress, + params, + url: baseUrl + url, + }); + + return { data: result.data }; + } catch (axiosError) { + const err = axiosError as AxiosError; + + return { + error: { + data: err.response?.data || err.message, + status: err.response?.status, + }, + }; + } + }; +}; diff --git a/packages/utils/src/lib/audio.ts b/packages/utils/src/lib/audio.ts new file mode 100644 index 000000000..453c788e5 --- /dev/null +++ b/packages/utils/src/lib/audio.ts @@ -0,0 +1,77 @@ +import { Howl } from "howler"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +/** + * Hook used to play an audio file using the Howl library. + */ +export const usePlayAudioUrl = () => { + const [audio, setAudio] = useState(); + const [audioUrl, setAudioUrl] = useState(); + const [isAudioPlaying, setIsAudioPlaying] = useState(false); + + useEffect(() => { + return () => { + if (audio?.playing()) { + audio.stop(); + } + }; + }, [audio]); + + const playPauseAudio = useCallback( + (src?: string) => { + if (!src) return; + + const isCurrentSong = src === audioUrl; + + // if currently selected song, stop or play and return + if (isCurrentSong) { + if (audio?.playing()) { + audio?.stop(); + } else { + audio?.play(); + } + + return; + } + + // if not currently selected song, stop playing + if (audio?.playing()) { + audio.stop(); + } + + // play new song + const newAudio = new Howl({ + html5: true, + onend: () => { + setIsAudioPlaying(false); + }, + onpause: () => { + setIsAudioPlaying(false); + }, + onplay: (id) => { + setAudioUrl(src); + setIsAudioPlaying(true); + }, + onstop: () => { + setIsAudioPlaying(false); + }, + src, + }); + + newAudio.play(); + setAudio(newAudio); + }, + [audio, audioUrl] + ); + + const result = useMemo( + () => ({ + audioUrl, + isAudioPlaying, + playPauseAudio, + }), + [audioUrl, isAudioPlaying, playPauseAudio] + ); + + return result; +}; diff --git a/packages/utils/src/lib/crypto.ts b/packages/utils/src/lib/crypto.ts new file mode 100644 index 000000000..e93b63b02 --- /dev/null +++ b/packages/utils/src/lib/crypto.ts @@ -0,0 +1,15 @@ +import currency from "currency.js"; + +/** + * Formats a numerical NEWM amount with the correct decimal + * places and symbol. + */ +export const formatNewmAmount = (amount?: number, includeSymbol = true) => { + if (!amount) return ""; + + return currency(amount, { + pattern: "# !", + precision: 1, + symbol: includeSymbol ? "Ɲ" : "", + }).format(); +};