Skip to content

Commit

Permalink
MRKT-24: sale data (#608)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
scandycuz authored May 6, 2024
1 parent b9a93ac commit dfae82f
Show file tree
Hide file tree
Showing 47 changed files with 1,132 additions and 435 deletions.
8 changes: 8 additions & 0 deletions apps/marketplace/src/api/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Maps RTKQuery API endpoint names with name that
* back-end expects for recaptcha action argument.
*/
export const recaptchaEndpointActionMap: Record<string, string> = {
getSale: "get_sale",
getSales: "get_sales",
};
1 change: 1 addition & 0 deletions apps/marketplace/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as newmApi } from "./newm/api";
19 changes: 19 additions & 0 deletions apps/marketplace/src/api/newm/api.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions apps/marketplace/src/api/newm/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum Tags {
Sale = "Sale",
}
32 changes: 32 additions & 0 deletions apps/marketplace/src/api/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
8 changes: 3 additions & 5 deletions apps/marketplace/src/app/artist/[artistId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -59,9 +59,7 @@ const Artist: FunctionComponent<ArtistProps> = ({ params }) => {
onClickAbout={ () => setIsAboutModalOpen(true) }
/>

<Box mt={ 7 }>
<Songs songs={ mockSongs } />
</Box>
<ArtistSongs />

<SimilarArtists />
</Container>
Expand Down
3 changes: 3 additions & 0 deletions apps/marketplace/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +45,8 @@ const RootLayout: FunctionComponent<RootLayoutProps> = ({ children }) => {
<AppRouterCacheProvider options={ { enableCssLayer: true } }>
<Provider store={ store }>
<ThemeProvider theme={ theme }>
<Toast />

<Stack flexGrow={ 1 } justifyContent="space-between">
<Stack justifyContent="flex-start">
<Header />
Expand Down
6 changes: 3 additions & 3 deletions apps/marketplace/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container sx={ { flexGrow: 1 } }>
<Box mt={ [7.5, 5.5, 10] }>
<Songs songs={ mockSongs } title="JUST RELEASED" />
<Sales sales={ mockSales } title="JUST RELEASED" />
</Box>

<ArtistSpotlight />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SongLayoutProps> = ({ children }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,86 @@ 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,
TextInputField,
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<SingleSongProps> = ({ 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 <ItemSkeleton />;
Expand All @@ -53,51 +102,56 @@ const SingleSong: FunctionComponent<SingleSongProps> = ({ params }) => {
>
<Box mb={ [2, 2, 0] } mr={ [0, 0, 5] } width={ [240, 240, 400] }>
<SongCard
coverArtUrl={ songData?.coverArtUrl }
coverArtUrl={ sale?.song.coverArtUrl }
imageDimensions={ 480 }
isPlayable={ true }
priceInNEWM={ mockSongs[0].priceInNEWM }
// eslint-disable-next-line @typescript-eslint/no-empty-function
onCardClick={ () => {} }
// 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) }
/>
</Box>
<Stack gap={ [4, 4, 2.5] } pt={ [0, 0, 1.5] } width={ ["100%", 440, 440] }>
<Stack gap={ 0.5 } textAlign={ ["center", "center", "left"] }>
<Typography variant="h3">{ songData?.title }</Typography>
<Typography variant="h3">{ sale?.song.title }</Typography>
<Typography color={ theme.colors.grey300 } variant="subtitle2">
{ songData?.isExplicit ? "Explicit" : null }
{ sale?.song.isExplicit ? "Explicit" : null }
</Typography>
</Stack>

<Typography variant="subtitle1">{ songData?.description }</Typography>
<Typography variant="subtitle1">
{ sale?.song.description }
</Typography>
<Stack
alignItems="center"
direction="row"
gap={ 1.5 }
justifyContent={ ["center", "center", "start"] }
role="button"
sx={ { cursor: "pointer" } }
tabIndex={ 0 }
onClick={ handleArtistClick }
onKeyDown={ handleArtistClick }
>
<ProfileImage
height={ 40 }
src={ resizeCloudinaryImage(songData?.artist.profileImageUrl, {
height: 56,
width: 56,
}) }
src={ sale?.song.artistPictureUrl }
width={ 40 }
/>
<Typography variant="h4">
{ songData?.artist.firstName } { songData?.artist.lastName }
</Typography>
<Typography variant="h4">{ sale?.song.artistName }</Typography>
</Stack>

<Formik
initialValues={ { streamTokens: 1 } }
initialValues={ initialFormValues }
validationSchema={ formValidationSchema }
onSubmit={ () => {
return;
} }
>
{ () => {
{ ({ values, isValid }) => {
const totalCost = getTotalPurchaseCost(values.streamTokens);

return (
<Form>
<Stack gap={ 2.5 } mb={ 4 } mt={ 0.5 }>
Expand All @@ -109,7 +163,7 @@ const SingleSong: FunctionComponent<SingleSongProps> = ({ 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 Ɲ'."
}
>
<IconButton sx={ { padding: 0 } }>
Expand All @@ -133,23 +187,31 @@ const SingleSong: FunctionComponent<SingleSongProps> = ({ params }) => {
</Typography>
<Stack direction={ "row" }>
<Box maxWidth={ "150px" }>
<TextInputField name="streamTokens"></TextInputField>
<TextInputField
name="streamTokens"
type="number"
/>
</Box>
<Typography
alignSelf="center"
flexShrink={ "0" }
pl={ 1.5 }
variant="subtitle2"
>
= 0.0000001% of total royalties
={ " " }
{ getPercentageOfTotalStreamTokens(
values.streamTokens
) }
% of total royalties
</Typography>
</Stack>
<Typography
color={ theme.colors.grey300 }
pt={ 0.5 }
variant="subtitle2"
>
Maximum stream tokens = 80000
Maximum stream tokens ={ " " }
{ sale?.availableBundleQuantity.toLocaleString() }
</Typography>
</Stack>
</Box>
Expand All @@ -163,12 +225,14 @@ const SingleSong: FunctionComponent<SingleSongProps> = ({ params }) => {
backgroundColor: theme.colors.grey600,
} }
>
<Button type="submit" width="full">
<Button disabled={ !isValid } type="submit" width="full">
<Typography
fontWeight={ theme.typography.fontWeightBold }
variant="body1"
>
Buy 1 Stream Token • { "4.73N (~ $7.30)" }
Buy { values.streamTokens.toLocaleString() } Stream
Tokens •{ " " }
{ `${totalCost?.newmAmount} (≈ ${totalCost?.usdAmount})` }
</Typography>
</Button>
</Box>
Expand Down
2 changes: 1 addition & 1 deletion apps/marketplace/src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/marketplace/src/buildParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ import { isProd } from "@newm-web/env";
const isReduxLoggingEnabledInStaging = false;

export const isReduxLoggingEnabled = !isProd && isReduxLoggingEnabledInStaging;

export const baseUrls: Record<string, string> = {
newm: isProd ? "https://studio.newm.io/" : "https://garage.newm.io/",
};
Loading

0 comments on commit dfae82f

Please sign in to comment.