From c4475b35264e57d6c2b3acdb9a1549f58909174e Mon Sep 17 00:00:00 2001 From: David Kirshon <86050631+dmkirshon@users.noreply.github.com> Date: Thu, 8 Aug 2024 16:48:06 +0200 Subject: [PATCH] Wallet Enhancements and Earnings Claiming Frontend Implementation (#536) * Implement Portfolio and Transactions tab structure (#528) * feat: Create tabs for portfolio and transactions * feat: STUD-94 Add empty portfolio and transaction ui state (#534) * feat: STUD-94 Add empty portfolio and transaction ui state * feat: STUD-94 Add isSuccess and isError to custom thunk to mimic query functionality * feat: STUD-94 Fix logic to display/render song royalties to include only when there are songs * feat: STUD-96 Add UI for no pending earnings in wallet (#535) * feat: STUD-96 Add UI for no pending earnings in wallet * feat: STUD-96 Update logic to display/hide earning state * feat: Update copy for unclaimed royalties (#542) * feat: Update empty state copy for Wallet tab views (#556) * STUD-145: Update Portfolio table and time period filters for viewing accumulated royalties (#559) * feat: Update Portfolio Royalty Earnings formatting * feat: Add Royalty Earning desc table sorting * feat: Implement temp Royalty Song pagination * feat: Improve rendering of data with hooks * feat: Add filtering of single royalty earning date * feat: Improve test data structure to mimic query * feat: Modify Query data for consistent results * refactor: Update Dropdown event trigger name * fix: Pagination when data or rows change * STUD-145: Add Portfolio end of table copy (#573) * STUD-150: Implement no connected wallet ui (#576) * STUD-150: Modify copy for when no wallet is connected (#583) * fix: Update thunkHook from merge conflict issue * STUD-302-Integrate-feature-flag-into-wallet-enhancement-feature-branch (#706) * feat: Implement feature flag for wallet enhance * feat: Add current state of wallet for false flag --------- Co-authored-by: escobarjonatan --- .../src/components/TableDropdownSelect.tsx | 65 ++--- apps/studio/src/components/index.ts | 1 + apps/studio/src/modules/ui/slice.ts | 10 + apps/studio/src/modules/ui/types.ts | 1 + .../src/pages/home/wallet/EmptyPortfolio.tsx | 31 +++ .../pages/home/wallet/EmptyTransactions.tsx | 15 ++ .../pages/home/wallet/NoConnectedWallet.tsx | 38 +++ .../pages/home/wallet/NoPendingEarnings.tsx | 27 ++ .../src/pages/home/wallet/Portfolio.tsx | 120 ++++++--- .../pages/home/wallet/SongRoyaltiesList.tsx | 65 +++-- .../src/pages/home/wallet/Transactions.tsx | 20 ++ .../pages/home/wallet/UnclaimedRoyalties.tsx | 34 ++- apps/studio/src/pages/home/wallet/Wallet.tsx | 240 +++++++++++++++--- .../wallet/legacyWallet/LegacyAllCaughtUp.tsx | 17 ++ .../wallet/legacyWallet/LegacyPortfolio.tsx | 80 ++++++ .../legacyWallet/LegacySongRoyaltiesList.tsx | 150 +++++++++++ .../LegacyTableDropdownSelect.tsx | 62 +++++ .../legacyWallet/LegacyUnclaimedRoyalties.tsx | 73 ++++++ .../pages/home/wallet/legacyWallet/index.tsx | 2 + .../pages/home/wallet/songRoyaltiesUtils.ts | 77 ++++++ packages/assets/src/index.ts | 4 + packages/assets/src/lib/icons/Document.tsx | 37 +++ packages/assets/src/lib/icons/Suitcase.tsx | 37 +++ packages/utils/src/lib/redux.ts | 32 ++- packages/utils/src/lib/types.ts | 2 + 25 files changed, 1103 insertions(+), 137 deletions(-) create mode 100644 apps/studio/src/pages/home/wallet/EmptyPortfolio.tsx create mode 100644 apps/studio/src/pages/home/wallet/EmptyTransactions.tsx create mode 100644 apps/studio/src/pages/home/wallet/NoConnectedWallet.tsx create mode 100644 apps/studio/src/pages/home/wallet/NoPendingEarnings.tsx create mode 100644 apps/studio/src/pages/home/wallet/Transactions.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/LegacyAllCaughtUp.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/LegacyPortfolio.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/LegacySongRoyaltiesList.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/LegacyTableDropdownSelect.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/LegacyUnclaimedRoyalties.tsx create mode 100644 apps/studio/src/pages/home/wallet/legacyWallet/index.tsx create mode 100644 apps/studio/src/pages/home/wallet/songRoyaltiesUtils.ts create mode 100644 packages/assets/src/lib/icons/Document.tsx create mode 100644 packages/assets/src/lib/icons/Suitcase.tsx diff --git a/apps/studio/src/components/TableDropdownSelect.tsx b/apps/studio/src/components/TableDropdownSelect.tsx index 7f78822a8..1a8f34cf3 100644 --- a/apps/studio/src/components/TableDropdownSelect.tsx +++ b/apps/studio/src/components/TableDropdownSelect.tsx @@ -5,55 +5,58 @@ import { SelectChangeEvent, styled, } from "@mui/material"; -import { useState } from "react"; +import { FunctionComponent } from "react"; -const TableDropdownSelect = () => { +export interface TableDropdownMenuParameters { + readonly label: string; + readonly value: string; +} + +interface TableDropdownSelectProps { + readonly menuItems: ReadonlyArray; + readonly onDropdownChange?: (value: string) => void; + readonly selectedValue: string; +} + +const TableDropdownSelect: FunctionComponent = ({ + selectedValue, + menuItems, + onDropdownChange, +}) => { const StyledSelect = styled(Select)(({ theme }) => ({ - "& .MuiSelect-iconOpen": { + "& .MuiSelect-icon": { color: theme.colors.white, - transform: "rotate(180deg)", }, - "& .c.css-zsouyz-MuiSvgIcon-root-MuiSelect-icon": { - color: theme.colors.white, - paddingBottom: "5px", - transform: "scale(1.2)", + fontSize: "12px", + })); + + const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ + "&.Mui-selected": { + backgroundColor: theme.colors.activeBackground, }, - "& .css-x2bp66-MuiSvgIcon-root-MuiSelect-icon": { - color: theme.colors.white, - paddingBottom: 4, - transform: "scale(1.2)", + "&.MuiMenuItem-root:hover": { + backgroundColor: theme.colors.activeBackground, }, + fontSize: "12px", })); - const StyledMenuItem = styled(MenuItem)({ - fontSize: 12, - }); - const [dropdownValue, setDropdownValue] = useState("ROYALTIES PER WEEK"); const handleDropdownChange = (event: SelectChangeEvent) => { - setDropdownValue(event.target.value as string); + onDropdownChange?.(event.target.value as string); }; return ( - - ROYALTIES PER DAY - - - ROYALTIES PER WEEK - - - ROYALTIES PER MONTH - - - ROYALTIES PER YEAR - + { menuItems.map((menuItem) => ( + + { menuItem.label.toUpperCase() } + + )) } ); diff --git a/apps/studio/src/components/index.ts b/apps/studio/src/components/index.ts index 19b08d42b..fc164f210 100644 --- a/apps/studio/src/components/index.ts +++ b/apps/studio/src/components/index.ts @@ -32,6 +32,7 @@ export { default as SquareGridCard } from "./SquareGridCard"; export { default as Toast } from "./Toast"; export { default as UpdateWalletAddressModal } from "./UpdateWalletAddressModal"; export { default as TableDropdownSelect } from "./TableDropdownSelect"; +export * from "./TableDropdownSelect"; export { default as ViewPDF } from "./ViewPDF"; export { SearchBox } from "./SearchBox"; export { default as IconStatus } from "./library/IconStatus"; diff --git a/apps/studio/src/modules/ui/slice.ts b/apps/studio/src/modules/ui/slice.ts index 04ac4f3e1..40de21d56 100644 --- a/apps/studio/src/modules/ui/slice.ts +++ b/apps/studio/src/modules/ui/slice.ts @@ -22,6 +22,7 @@ const initialState: UIState = { isConfirmationRequired: false, message: "", }, + walletPortfolioTableFilter: "All", }; const uiSlice = createSlice({ @@ -41,6 +42,10 @@ const uiSlice = createSlice({ state.toast.message = ""; state.toast.severity = "error"; }, + resetWalletPortfolioTableFilter: (state) => { + state.walletPortfolioTableFilter = + initialState.walletPortfolioTableFilter; + }, setIsConnectWalletModalOpen: ( state, { payload }: PayloadAction @@ -77,6 +82,9 @@ const uiSlice = createSlice({ ) => { state.updateWalletAddressModal = payload; }, + setWalletPortfolioTableFilter: (state, action: PayloadAction) => { + state.walletPortfolioTableFilter = action.payload; + }, }, }); @@ -91,6 +99,8 @@ export const { setIsConnectWalletModalOpen, setIsInvitesModalOpen, setIsWalletEnvMismatchModalOpen, + setWalletPortfolioTableFilter, + resetWalletPortfolioTableFilter, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/apps/studio/src/modules/ui/types.ts b/apps/studio/src/modules/ui/types.ts index c1fc572af..5012a1d9b 100644 --- a/apps/studio/src/modules/ui/types.ts +++ b/apps/studio/src/modules/ui/types.ts @@ -21,6 +21,7 @@ export interface UIState { isConfirmationRequired: boolean; message: string; }; + walletPortfolioTableFilter: string; } export interface UploadProgressParams { diff --git a/apps/studio/src/pages/home/wallet/EmptyPortfolio.tsx b/apps/studio/src/pages/home/wallet/EmptyPortfolio.tsx new file mode 100644 index 000000000..c0e996153 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/EmptyPortfolio.tsx @@ -0,0 +1,31 @@ +import { FunctionComponent } from "react"; +import { useNavigate } from "react-router-dom"; +import { Stack, Typography } from "@mui/material"; +import { Suitcase } from "@newm-web/assets"; +import { Button } from "@newm-web/elements"; + +export const EmptyPortfolio: FunctionComponent = () => { + const navigate = useNavigate(); + + return ( + + + + Your portfolio is empty + + + Only songs with stream tokens held in your connected wallet will be + shown here + + + + ); +}; diff --git a/apps/studio/src/pages/home/wallet/EmptyTransactions.tsx b/apps/studio/src/pages/home/wallet/EmptyTransactions.tsx new file mode 100644 index 000000000..9005ee1d4 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/EmptyTransactions.tsx @@ -0,0 +1,15 @@ +import { FunctionComponent } from "react"; +import { Stack, Typography } from "@mui/material"; +import { Document } from "@newm-web/assets"; + +export const EmptyTransactions: FunctionComponent = () => ( + + + + No transactions found + + + All future transactions made with the connected wallet will be listed here + + +); diff --git a/apps/studio/src/pages/home/wallet/NoConnectedWallet.tsx b/apps/studio/src/pages/home/wallet/NoConnectedWallet.tsx new file mode 100644 index 000000000..32fd741f8 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/NoConnectedWallet.tsx @@ -0,0 +1,38 @@ +import { Stack, Typography } from "@mui/material"; +import { Button } from "@newm-web/elements"; +import { NEWMLogo } from "@newm-web/assets"; +import { setIsConnectWalletModalOpen } from "../../../modules/ui"; +import { useAppDispatch } from "../../../common"; + +export const NoConnectedWallet = () => { + const dispatch = useAppDispatch(); + + return ( + + + + Connect your wallet + + + Connecting your wallet will enable you to claim your accrued royalties, + stream token sale earnings, and view your transaction history. + + + + ); +}; diff --git a/apps/studio/src/pages/home/wallet/NoPendingEarnings.tsx b/apps/studio/src/pages/home/wallet/NoPendingEarnings.tsx new file mode 100644 index 000000000..0952482c3 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/NoPendingEarnings.tsx @@ -0,0 +1,27 @@ +import { FunctionComponent } from "react"; +import { Stack, Typography } from "@mui/material"; +import { CheckCircle } from "@newm-web/assets"; +import theme from "@newm-web/theme"; + +export const NoPendingEarnings: FunctionComponent = () => ( + + + + + No pending earnings to claim + + + Total earnings accrued so far: ##.##Ɲ (~$#.##) + + + +); diff --git a/apps/studio/src/pages/home/wallet/Portfolio.tsx b/apps/studio/src/pages/home/wallet/Portfolio.tsx index 34bee85ee..9d6932ed0 100644 --- a/apps/studio/src/pages/home/wallet/Portfolio.tsx +++ b/apps/studio/src/pages/home/wallet/Portfolio.tsx @@ -1,11 +1,18 @@ -import { FunctionComponent, useEffect, useRef, useState } from "react"; +import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; import { useWindowDimensions } from "@newm-web/utils"; import { Box } from "@mui/material"; import { TableSkeleton } from "@newm-web/elements"; import theme from "@newm-web/theme"; -import { SortOrder } from "@newm-web/types"; -import SongRoyaltiesList from "./SongRoyaltiesList"; +import { useConnectWallet } from "@newm.io/cardano-dapp-wallet-connector"; +import SongRoyaltiesList, { TotalSongRoyalty } from "./SongRoyaltiesList"; +import { EmptyPortfolio } from "./EmptyPortfolio"; +import { + createTempSongEarningsQueryData, + isWithinFilterPeriod, +} from "./songRoyaltiesUtils"; import { useGetUserWalletSongsThunk } from "../../../modules/song"; +import { useAppSelector } from "../../../common"; +import { selectUi } from "../../../modules/ui"; const Portfolio: FunctionComponent = () => { const windowHeight = useWindowDimensions()?.height; @@ -22,19 +29,27 @@ const Portfolio: FunctionComponent = () => { const pageIdx = page - 1; const lastRowOnPage = pageIdx * rowsPerPage + rowsPerPage; - const [getUserWalletSongs, { data: walletSongsResponse, isLoading }] = - useGetUserWalletSongsThunk(); + const { wallet } = useConnectWallet(); + const [ + getUserWalletSongs, + { data: walletSongsResponse, isLoading, isSuccess }, + ] = useGetUserWalletSongsThunk(); - const songs = - walletSongsResponse?.data?.songs?.map((entry) => entry.song) || []; + const songs = useMemo( + () => walletSongsResponse?.data?.songs?.map((entry) => entry.song) || [], + [walletSongsResponse?.data?.songs] + ); + + const [walletSongsRoyaltyCombined, setWalletSongsRoyaltyCombined] = useState< + TotalSongRoyalty[] + >([]); + + const { walletPortfolioTableFilter } = useAppSelector(selectUi); useEffect(() => { - getUserWalletSongs({ - limit: skeletonRows, - offset: pageIdx * skeletonRows, - sortOrder: SortOrder.Desc, - }); - }, [getUserWalletSongs, pageIdx, skeletonRows]); + // Pagination was removed as Song creation date is not used as sorting criteria + getUserWalletSongs({}); + }, [getUserWalletSongs, wallet]); useEffect(() => { const skeletonYPos = skeletonRef.current?.offsetTop || 0; @@ -50,29 +65,66 @@ const Portfolio: FunctionComponent = () => { setSkeletonRows(rowsToRender); setRowsPerPage(rowsToRender); - }, [windowHeight]); + + // TODO: Creates temporary earnings in place of backend table query + const walletSongsEarnings = songs.map((song) => + createTempSongEarningsQueryData(song) + ); + + const combinedEarnings = walletSongsEarnings + .map((songEarning) => { + const totalRoyaltyAmount = songEarning.earningsData + // filter out earnings that are not within the filter period + .filter((songEarning) => + isWithinFilterPeriod( + songEarning.createdAt, + walletPortfolioTableFilter + ) + ) + // combine earnings within the filter period + .reduce((acc, curr) => acc + curr.amount, 0); + return { + song: songEarning.song, + totalRoyaltyAmount, + }; + }) + // Sort filtered songs by descending totalRoyaltyAmount + .sort((a, b) => b?.totalRoyaltyAmount - a?.totalRoyaltyAmount) + // handle pagination for song earnings + .slice(pageIdx * skeletonRows, pageIdx * skeletonRows + rowsToRender); + + setWalletSongsRoyaltyCombined(combinedEarnings); + }, [pageIdx, skeletonRows, songs, walletPortfolioTableFilter, windowHeight]); + + useEffect(() => { + setPage(1); + }, [walletPortfolioTableFilter, windowHeight]); + + if (isLoading) { + return ( + theme.breakpoints.values.sm ? 3 : 2 } + maxWidth={ maxListWidth } + rows={ skeletonRows } + /> + ); + } + + if (isSuccess && songs?.length === 0) { + return ; + } return ( - - { isLoading ? ( - theme.breakpoints.values.sm ? 3 : 2 - } - maxWidth={ maxListWidth } - rows={ skeletonRows } - /> - ) : ( - - ) } + + ); }; diff --git a/apps/studio/src/pages/home/wallet/SongRoyaltiesList.tsx b/apps/studio/src/pages/home/wallet/SongRoyaltiesList.tsx index 86ea908a8..4ec43c78d 100644 --- a/apps/studio/src/pages/home/wallet/SongRoyaltiesList.tsx +++ b/apps/studio/src/pages/home/wallet/SongRoyaltiesList.tsx @@ -16,8 +16,19 @@ import { resizeCloudinaryImage } from "@newm-web/utils"; import { Song } from "@newm-web/types"; import { Dispatch, SetStateAction } from "react"; import { TablePagination } from "@newm-web/elements"; +import currency from "currency.js"; import AllCaughtUp from "./AllCaughtUp"; -import { TableDropdownSelect } from "../../../components"; +import { selectUi, setWalletPortfolioTableFilter } from "../../../modules/ui"; +import { + TableDropdownMenuParameters, + TableDropdownSelect, +} from "../../../components"; +import { useAppDispatch, useAppSelector } from "../../../common"; + +export interface TotalSongRoyalty { + song: Song; + totalRoyaltyAmount: number; +} interface SongRoyaltiesListProps { lastRowOnPage: number; @@ -25,10 +36,17 @@ interface SongRoyaltiesListProps { rows: number; rowsPerPage: number; setPage: Dispatch>; - songRoyalties: ReadonlyArray; + songRoyalties: ReadonlyArray; totalCountOfSongs: number; } +const royaltyPeriodFilters: ReadonlyArray = [ + { label: "Royalty Earnings: All Time", value: "All" }, + { label: "Royalty Earnings: Past Week", value: "Week" }, + { label: "Royalty Earnings: Past Month", value: "Month" }, + { label: "Royalty Earnings: Past Year", value: "Year" }, +]; + const StyledTableRow = styled(TableRow)(({ theme }) => ({ // hide last border "&:last-child td, &:last-child th": { @@ -36,7 +54,7 @@ const StyledTableRow = styled(TableRow)(({ theme }) => ({ }, "&:nth-of-type(odd)": { - backgroundColor: theme.palette.action.hover, + backgroundColor: theme.colors.grey600, }, })); @@ -54,7 +72,11 @@ export default function SongRoyaltiesList({ setPage, totalCountOfSongs, }: SongRoyaltiesListProps) { + const { walletPortfolioTableFilter } = useAppSelector(selectUi); + const dispatch = useAppDispatch(); + const TABLE_WIDTH = 700; + const isLastRowOnPageVisible = lastRowOnPage >= totalCountOfSongs; const handlePageChange = ( event: React.ChangeEvent, @@ -63,7 +85,11 @@ export default function SongRoyaltiesList({ setPage(page); }; - if (songRoyalties) { + const handleRoyaltyPeriodChange = (tableFilter: string) => { + dispatch(setWalletPortfolioTableFilter(tableFilter)); + }; + + if (songRoyalties.length) { return ( - + - { songRoyalties.map((row, index) => ( - + { songRoyalties.map(({ song, totalRoyaltyAmount }) => ( + Album cover - { row.title } + { song.title } - --.-- + { currency(totalRoyaltyAmount, { + pattern: "#!", + symbol: "Ɲ", + }).format() } )) } - { totalCountOfSongs > rows && ( - { songRoyalties.length === 0 ? ( - + { isLastRowOnPageVisible && ( + - ) : null } + ) } ); } else { diff --git a/apps/studio/src/pages/home/wallet/Transactions.tsx b/apps/studio/src/pages/home/wallet/Transactions.tsx new file mode 100644 index 000000000..6081f790c --- /dev/null +++ b/apps/studio/src/pages/home/wallet/Transactions.tsx @@ -0,0 +1,20 @@ +import { FunctionComponent } from "react"; +import { Box } from "@mui/material"; +import { EmptyTransactions } from "./EmptyTransactions"; +import AllCaughtUp from "./AllCaughtUp"; + +const Transactions: FunctionComponent = () => { + const transactions = []; + const isLoading = false; + + if (!isLoading && !transactions.length) { + return ; + } + + return ( + + + + ); +}; +export default Transactions; diff --git a/apps/studio/src/pages/home/wallet/UnclaimedRoyalties.tsx b/apps/studio/src/pages/home/wallet/UnclaimedRoyalties.tsx index f9e669087..5badd95c5 100644 --- a/apps/studio/src/pages/home/wallet/UnclaimedRoyalties.tsx +++ b/apps/studio/src/pages/home/wallet/UnclaimedRoyalties.tsx @@ -31,16 +31,22 @@ export const UnclaimedRoyalties = ({ paddingRight: [1, "unset"], } } > - - - ROYALTIES ACCRUED SO FAR + + + YOU HAVE UNCLAIMED EARNINGS @@ -49,15 +55,25 @@ export const UnclaimedRoyalties = ({ - - { currency(unclaimedRoyalties).format() } + + { currency(unclaimedRoyalties, { + pattern: "#!", + symbol: "Ɲ", + }).format() } + + + ~$X.XX + ) } + + + + + + + + + + ); + } else { + // New Wallet Royalties and Enhancement Features + if (!wallet) { + return ; + } + + return ( + + + + + WALLET + + + + { songs.length === 0 && !isLoading ? null : unclaimedRoyalties ? ( + ) : ( - + ) } - - + + + + + + + - - + + + + + + + - - - ); + + ); + } }; export default Wallet; diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/LegacyAllCaughtUp.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyAllCaughtUp.tsx new file mode 100644 index 000000000..0e4ec1a65 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyAllCaughtUp.tsx @@ -0,0 +1,17 @@ +import { Box, Typography } from "@mui/material"; +import { FunctionComponent } from "react"; + +const LegacyAllCaughtUp: FunctionComponent = () => { + return ( + + + You're all caught up{ " " } + + 🎉 + + + + ); +}; + +export default LegacyAllCaughtUp; diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/LegacyPortfolio.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyPortfolio.tsx new file mode 100644 index 000000000..0ed67d02e --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyPortfolio.tsx @@ -0,0 +1,80 @@ +import { FunctionComponent, useEffect, useRef, useState } from "react"; +import { useWindowDimensions } from "@newm-web/utils"; +import { Box } from "@mui/material"; +import { TableSkeleton } from "@newm-web/elements"; +import theme from "@newm-web/theme"; +import { SortOrder } from "@newm-web/types"; +import SongRoyaltiesList from "./LegacySongRoyaltiesList"; +import { useGetUserWalletSongsThunk } from "../../../../modules/song"; + +const LegacyPortfolio: FunctionComponent = () => { + const windowHeight = useWindowDimensions()?.height; + const windowWidth = useWindowDimensions()?.width; + const maxListWidth = 700; + const SKELETON_PADDING = 200; + const ROW_HEIGHT = 50; + const DEFAULT_ROWS = 10; + const MIN_ROWS = 1; + const skeletonRef = useRef(); + const [skeletonRows, setSkeletonRows] = useState(10); + const [rowsPerPage, setRowsPerPage] = useState(1); + const [page, setPage] = useState(1); + const pageIdx = page - 1; + const lastRowOnPage = pageIdx * rowsPerPage + rowsPerPage; + + const [getUserWalletSongs, { data: walletSongsResponse, isLoading }] = + useGetUserWalletSongsThunk(); + + const songs = + walletSongsResponse?.data?.songs?.map((entry) => entry.song) || []; + + useEffect(() => { + getUserWalletSongs({ + limit: skeletonRows, + offset: pageIdx * skeletonRows, + sortOrder: SortOrder.Desc, + }); + }, [getUserWalletSongs, pageIdx, skeletonRows]); + + useEffect(() => { + const skeletonYPos = skeletonRef.current?.offsetTop || 0; + + const rowsToRender = windowHeight + ? Math.max( + Math.floor( + (windowHeight - skeletonYPos - SKELETON_PADDING) / ROW_HEIGHT + ), + MIN_ROWS + ) + : DEFAULT_ROWS; + + setSkeletonRows(rowsToRender); + setRowsPerPage(rowsToRender); + }, [windowHeight]); + + return ( + + { isLoading ? ( + theme.breakpoints.values.sm ? 3 : 2 + } + maxWidth={ maxListWidth } + rows={ skeletonRows } + /> + ) : ( + + ) } + + ); +}; + +export default LegacyPortfolio; diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/LegacySongRoyaltiesList.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/LegacySongRoyaltiesList.tsx new file mode 100644 index 000000000..6180f1e1c --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/LegacySongRoyaltiesList.tsx @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from "react"; +import { styled } from "@mui/material/styles"; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import theme from "@newm-web/theme"; +import { resizeCloudinaryImage } from "@newm-web/utils"; +import { Song } from "@newm-web/types"; +import { Dispatch, SetStateAction } from "react"; +import { TablePagination } from "@newm-web/elements"; +import LegacyAllCaughtUp from "./LegacyAllCaughtUp"; +import LegacyTableDropdownSelect from "./LegacyTableDropdownSelect"; + +interface LegacySongRoyaltiesListProps { + lastRowOnPage: number; + page: number; + rows: number; + rowsPerPage: number; + setPage: Dispatch>; + songRoyalties: ReadonlyArray; + totalCountOfSongs: number; +} + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + // hide last border + "&:last-child td, &:last-child th": { + border: 0, + }, + + "&:nth-of-type(odd)": { + backgroundColor: theme.palette.action.hover, + }, +})); + +const StyledTableCell = styled(TableCell)({ + borderColor: theme.colors.grey700, + height: "48px", +}); + +export default function LegacySongRoyaltiesList({ + songRoyalties, + rows, + page, + rowsPerPage, + lastRowOnPage, + setPage, + totalCountOfSongs, +}: LegacySongRoyaltiesListProps) { + const TABLE_WIDTH = 700; + + const handlePageChange = ( + event: React.ChangeEvent, + page: number + ) => { + setPage(page); + }; + + if (songRoyalties) { + return ( + + + + + + + + SONG + + + + + + + + + + { songRoyalties.map((row, index) => ( + + + + Album cover + + { row.title } + + + + + + --.-- + + + + )) } + + + { totalCountOfSongs > rows && ( + + ) } +
+
+ + { songRoyalties.length === 0 ? ( + + + + ) : null } +
+ ); + } else { + return
; + } +} diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/LegacyTableDropdownSelect.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyTableDropdownSelect.tsx new file mode 100644 index 000000000..230725324 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyTableDropdownSelect.tsx @@ -0,0 +1,62 @@ +import { + Box, + MenuItem, + Select, + SelectChangeEvent, + styled, +} from "@mui/material"; +import { useState } from "react"; + +const LegacyTableDropdownSelect = () => { + const StyledSelect = styled(Select)(({ theme }) => ({ + "& .MuiSelect-iconOpen": { + color: theme.colors.white, + transform: "rotate(180deg)", + }, + "& .c.css-zsouyz-MuiSvgIcon-root-MuiSelect-icon": { + color: theme.colors.white, + paddingBottom: "5px", + transform: "scale(1.2)", + }, + "& .css-x2bp66-MuiSvgIcon-root-MuiSelect-icon": { + color: theme.colors.white, + paddingBottom: 4, + transform: "scale(1.2)", + }, + })); + const StyledMenuItem = styled(MenuItem)({ + fontSize: 12, + }); + const [dropdownValue, setDropdownValue] = useState("ROYALTIES PER WEEK"); + + const handleDropdownChange = (event: SelectChangeEvent) => { + setDropdownValue(event.target.value as string); + }; + return ( + + + + ROYALTIES PER DAY + + + ROYALTIES PER WEEK + + + ROYALTIES PER MONTH + + + ROYALTIES PER YEAR + + + + ); +}; + +export default LegacyTableDropdownSelect; diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/LegacyUnclaimedRoyalties.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyUnclaimedRoyalties.tsx new file mode 100644 index 000000000..e94dea9be --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/LegacyUnclaimedRoyalties.tsx @@ -0,0 +1,73 @@ +import { Box, IconButton, Stack, Typography } from "@mui/material"; +import { Button, Tooltip } from "@newm-web/elements"; +import currency from "currency.js"; +import theme from "@newm-web/theme"; +import HelpIcon from "@mui/icons-material/Help"; + +interface LegacyUnclaimedRoyaltiesProps { + unclaimedRoyalties: number; +} + +export const LegacyUnclaimedRoyalties = ({ + unclaimedRoyalties, +}: LegacyUnclaimedRoyaltiesProps) => { + return ( + + + + + ROYALTIES ACCRUED SO FAR + + + + + + + + + + + { currency(unclaimedRoyalties).format() } + + + + + + + + + ); +}; + +export default LegacyUnclaimedRoyalties; diff --git a/apps/studio/src/pages/home/wallet/legacyWallet/index.tsx b/apps/studio/src/pages/home/wallet/legacyWallet/index.tsx new file mode 100644 index 000000000..e4ce1b9df --- /dev/null +++ b/apps/studio/src/pages/home/wallet/legacyWallet/index.tsx @@ -0,0 +1,2 @@ +export { default as LegacyPortfolio } from "./LegacyPortfolio"; +export { default as LegacyUnclaimedRoyalties } from "./LegacyUnclaimedRoyalties"; diff --git a/apps/studio/src/pages/home/wallet/songRoyaltiesUtils.ts b/apps/studio/src/pages/home/wallet/songRoyaltiesUtils.ts new file mode 100644 index 000000000..386495ad0 --- /dev/null +++ b/apps/studio/src/pages/home/wallet/songRoyaltiesUtils.ts @@ -0,0 +1,77 @@ +import { Song } from "@newm-web/types"; + +interface EarningsData { + amount: number; + createdAt: number; +} + +interface WalletSongsEarningsData { + earningsData: ReadonlyArray; + song: Song; +} + +// create record with the number of days for royalty period filter option +const royaltyPeriodFilterDays: Record = { + All: 0, + Week: 604800000, + // eslint-disable-next-line sort-keys-fix/sort-keys-fix + Month: 2592000000, + Year: 31536000000, +}; + +export const isWithinFilterPeriod = ( + royaltiesCreatedDate: number | undefined, + walletPortfolioTableFilter: string +) => { + const filterPeriod = royaltyPeriodFilterDays[walletPortfolioTableFilter]; + if (walletPortfolioTableFilter === "All") { + return true; + } else if (royaltiesCreatedDate) { + return royaltiesCreatedDate >= Date.now() - filterPeriod; + } else { + return false; + } +}; + +/** TODO: This is a temporary function to generate test (claimed and unclaimed) + * royalty earnings for the given wallet songs. Song title length is used as a + * temp unique differentiator to generate royalties. The song title length + * conditionals are in place of the backend earnings table query. The current + * assumption is that the query will be for one song ID and return each earning + * event in individual objects within an array. + */ +export const createTempSongEarningsQueryData = ( + song: Song +): WalletSongsEarningsData => { + const tempRoyaltyAmount = 0.01; + // use song title length as a unique differentiator to generate royalties + if (song.title.length % 2 === 0) { + return { + earningsData: [ + { + amount: tempRoyaltyAmount + song.title.length, + createdAt: Date.now() - royaltyPeriodFilterDays["Week"], + }, + { + amount: tempRoyaltyAmount + song.title.length, + createdAt: Date.now() - royaltyPeriodFilterDays["Month"], + }, + { + amount: tempRoyaltyAmount + song.title.length, + createdAt: Date.now() - royaltyPeriodFilterDays["Year"], + }, + { + amount: tempRoyaltyAmount + song.title.length, + createdAt: Date.now() - royaltyPeriodFilterDays["Year"] - 60000, + }, + ], + song, + }; + } else { + // Return empty array for songs with no Royalties + return { + earningsData: [], + song, + }; + } +}; diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts index 5001ddc14..6c3dfd71a 100644 --- a/packages/assets/src/index.ts +++ b/packages/assets/src/index.ts @@ -9,6 +9,7 @@ import CheckIcon from "./lib/icons/CheckIcon"; import CloseCircleFill from "./lib/icons/CloseCircleFill"; import CloseIcon from "./lib/icons/CloseIcon"; import DiscordLogo from "./lib/icons/DiscordLogo"; +import Document from "./lib/icons/Document"; import DropdownAdornment from "./lib/icons/DropdownAdornment"; import FoldersIcon from "./lib/icons/FoldersIcon"; import GlobalFill from "./lib/icons/GlobalFill"; @@ -23,6 +24,7 @@ import PeopleIcon from "./lib/icons/PeopleIcon"; import PlayButton from "./lib/icons/PlayButton"; import SelectedCheckboxIcon from "./lib/icons/SelectedCheckboxIcon"; import SoundWave from "./lib/icons/SoundWave"; +import Suitcase from "./lib/icons/Suitcase"; import Search from "./lib/icons/Search"; import SoundCloudLogo from "./lib/icons/SoundCloudLogo"; import SpotifyLogo from "./lib/icons/SpotifyLogo"; @@ -45,6 +47,7 @@ export { CloseCircleFill, CloseIcon, DiscordLogo, + Document, DropdownAdornment, FoldersIcon, GlobalFill, @@ -62,6 +65,7 @@ export { SoundCloudLogo, SoundWave, SpotifyLogo, + Suitcase, TimeCircleLine, UnselectedCheckboxIcon, UploadIcon, diff --git a/packages/assets/src/lib/icons/Document.tsx b/packages/assets/src/lib/icons/Document.tsx new file mode 100644 index 000000000..28f5b23a1 --- /dev/null +++ b/packages/assets/src/lib/icons/Document.tsx @@ -0,0 +1,37 @@ +import { createSvgIcon } from "@mui/material"; + +const Document = createSvgIcon( + + + + + + + + + + + + + + , + "Document" +); + +export default Document; diff --git a/packages/assets/src/lib/icons/Suitcase.tsx b/packages/assets/src/lib/icons/Suitcase.tsx new file mode 100644 index 000000000..b012f30f7 --- /dev/null +++ b/packages/assets/src/lib/icons/Suitcase.tsx @@ -0,0 +1,37 @@ +import { createSvgIcon } from "@mui/material"; + +const Suitcase = createSvgIcon( + + + + + + + + + + + + + + , + "Suitcase" +); + +export default Suitcase; diff --git a/packages/utils/src/lib/redux.ts b/packages/utils/src/lib/redux.ts index cd6ea3e51..43f1132e5 100644 --- a/packages/utils/src/lib/redux.ts +++ b/packages/utils/src/lib/redux.ts @@ -5,11 +5,11 @@ import { UseWrappedThunkResponse } from "./types"; /** * Wraps a thunk so that it can be used as a hook that returns - * a function to call the thunk as well as the loading status - * and the thunk return value. + * a function to call the thunk as well as the loading status, + * the thunk return value, success and error status. * - * @param thunk thunk that should be wrapped with the hook - * @returns a function that returns the touple: [wrapped thunk, { loading, data }] + * @param thunk Thunk that should be wrapped with the hook. + * @returns A function that returns the tuple: [wrapped thunk, { data, isError, isLoading, isSuccess }] */ export const asThunkHook = ( thunk: AsyncThunk> @@ -19,6 +19,8 @@ export const asThunkHook = ( UseWrappedThunkResponse ] => { const [data, setData] = useState(); + const [isSuccess, setIsSuccess] = useState(false); + const [isError, setIsError] = useState(false); const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); @@ -26,16 +28,28 @@ export const asThunkHook = ( const callHook = useCallback( async (arg: Arg) => { setIsLoading(true); - const action = thunk(arg) as any; // eslint-disable-line - const result = (await dispatch(action)) as PayloadAction; + try { + const action = thunk(arg) as any; // eslint-disable-line + const result = (await dispatch(action)) as PayloadAction; - setData(result.payload); - setIsLoading(false); + // Check if the thunk execution was successful + if (thunk.fulfilled.match(result)) { + setData(result.payload); + setIsSuccess(true); + } else { + setIsSuccess(false); + } + } catch (error) { + setIsError(true); + setIsSuccess(false); + } finally { + setIsLoading(false); + } }, [dispatch] ); - return [callHook, { data, isLoading }]; + return [callHook, { data, isError, isLoading, isSuccess }]; }; return useWrappedThunk; diff --git a/packages/utils/src/lib/types.ts b/packages/utils/src/lib/types.ts index 722f848dd..50241346d 100644 --- a/packages/utils/src/lib/types.ts +++ b/packages/utils/src/lib/types.ts @@ -14,7 +14,9 @@ export interface WindowDimensions { export interface UseWrappedThunkResponse { readonly data?: Returned; + readonly isError: boolean; readonly isLoading: boolean; + readonly isSuccess: boolean; } export type CustomError = {