From 20fea34ceb615d3436bde91f2ba82cd4f27cf0a0 Mon Sep 17 00:00:00 2001 From: Andrew Mendelsohn Date: Wed, 27 Mar 2024 12:01:03 -0700 Subject: [PATCH] [PAY-2554, PAY-2555] Purchase album via purchase modal (#7941) --- packages/commands/src/create-playlist.mjs | 5 - .../common/src/hooks/purchaseContent/types.ts | 9 +- .../usePurchaseContentFormConfiguration.ts | 19 +- .../common/src/hooks/purchaseContent/utils.ts | 28 ++- .../common/src/hooks/useSelectTierInfo.ts | 4 +- packages/common/src/models/Collection.ts | 11 +- packages/common/src/models/PurchaseContent.ts | 2 + packages/common/src/models/Track.ts | 2 - .../audius-api-client/ResponseAdapter.ts | 8 + .../src/services/audius-api-client/types.ts | 2 + .../src/services/audius-backend/solana.ts | 5 +- .../common/src/store/gated-content/sagas.ts | 174 +++++++++------ .../src/store/gated-content/selectors.ts | 2 +- .../common/src/store/gated-content/slice.ts | 28 +-- .../src/store/purchase-content/sagas.ts | 210 +++++++++++------- .../src/store/purchase-content/slice.ts | 13 +- .../src/store/purchase-content/types.ts | 5 +- .../premium-content-purchase-modal/index.ts | 10 +- packages/common/src/store/wallet/utils.ts | 21 +- .../src/api/v1/models/playlists.py | 2 + .../gated_content/content_access_checker.py | 14 +- .../src/queries/query_helpers.py | 25 ++- .../details-tile/DetailsTileNoAccess.tsx | 9 +- .../lineup-tile/LineupTileAccessStatus.tsx | 9 +- .../LockedContentDrawer.tsx | 4 +- .../now-playing-drawer/ActionsBar.tsx | 8 +- .../PremiumTrackPurchaseDrawer.tsx | 39 +++- .../PurchaseSuccess.tsx | 64 +++--- .../Notification/utils.ts | 2 +- .../screens/track-screen/DownloadSection.tsx | 3 +- packages/mobile/src/utils/routes.tsx | 7 +- .../common/store/pages/saved/lineups/sagas.ts | 4 +- .../collection/desktop/CollectionHeader.tsx | 47 +++- .../LockedContentModal.module.css | 6 +- .../LockedContentModal.tsx | 23 +- .../src/components/now-playing/NowPlaying.tsx | 10 +- .../desktop/components/SocialActions.tsx | 9 +- .../PremiumContentPurchaseModal.tsx | 92 +++++--- .../components/PurchaseContentFormFields.tsx | 24 +- .../components/PurchaseContentFormFooter.tsx | 77 ++++--- .../components/PurchaseSummaryTable.tsx | 24 +- .../src/components/track/DownloadSection.tsx | 5 +- ...ackSection.tsx => GatedContentSection.tsx} | 135 +++++------ .../track/GiantTrackTile.module.css | 2 +- .../src/components/track/GiantTrackTile.tsx | 24 +- ...ss => LockedContentDetailsTile.module.css} | 14 +- ...sTile.tsx => LockedContentDetailsTile.tsx} | 72 +++--- .../components/track/desktop/BottomRow.tsx | 4 +- .../components/track/desktop/TrackTile.tsx | 5 +- .../components/track/mobile/BottomButtons.tsx | 4 +- .../src/components/track/mobile/TrackTile.tsx | 9 +- .../components/desktop/CollectionPage.tsx | 11 + .../settings-page/SettingsPageProvider.tsx | 6 +- .../components/mobile/TrackHeader.module.css | 6 +- .../components/mobile/TrackHeader.tsx | 28 ++- 55 files changed, 853 insertions(+), 532 deletions(-) rename packages/web/src/components/track/{GatedTrackSection.tsx => GatedContentSection.tsx} (84%) rename packages/web/src/components/track/{LockedTrackDetailsTile.module.css => LockedContentDetailsTile.module.css} (90%) rename packages/web/src/components/track/{LockedTrackDetailsTile.tsx => LockedContentDetailsTile.tsx} (58%) diff --git a/packages/commands/src/create-playlist.mjs b/packages/commands/src/create-playlist.mjs index ea32e095648..bcf86629427 100644 --- a/packages/commands/src/create-playlist.mjs +++ b/packages/commands/src/create-playlist.mjs @@ -48,7 +48,6 @@ program })) } } - console.log(chalk.yellow('Playlist Metadata: '), metadata) if (price) { if (!album) { program.error(chalk.red('Price can only be set for albums')) @@ -58,10 +57,6 @@ program price, audiusLibs }) - console.log( - chalk.yellow('Stream Conditions: '), - metadata.stream_conditions - ) } const response = await audiusLibs.EntityManager.createPlaylist(metadata) diff --git a/packages/common/src/hooks/purchaseContent/types.ts b/packages/common/src/hooks/purchaseContent/types.ts index af49799042d..0c01f7c02fe 100644 --- a/packages/common/src/hooks/purchaseContent/types.ts +++ b/packages/common/src/hooks/purchaseContent/types.ts @@ -1,3 +1,4 @@ +import { ComputedCollectionProperties, UserCollectionMetadata } from '~/models' import { USDCPurchaseConditions, UserTrackMetadata, @@ -19,6 +20,11 @@ export enum PayExtraPreset { NONE = 'none' } +export type PurchaseableAlbumStreamMetadata = UserCollectionMetadata & + ComputedCollectionProperties & { + stream_conditions: USDCPurchaseConditions + } + export type PurchaseableTrackStreamMetadata = UserTrackMetadata & ComputedTrackProperties & { stream_conditions: USDCPurchaseConditions @@ -29,9 +35,10 @@ export type PurchaseableTrackDownloadMetadata = UserTrackMetadata & download_conditions: USDCPurchaseConditions } -export type PurchaseableTrackMetadata = +export type PurchaseableContentMetadata = | PurchaseableTrackStreamMetadata | PurchaseableTrackDownloadMetadata + | PurchaseableAlbumStreamMetadata export type USDCPurchaseConfig = { minContentPriceCents: number diff --git a/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts b/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts index 6dcca2c6de7..f1865fcd385 100644 --- a/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts +++ b/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts @@ -4,10 +4,11 @@ import { USDC } from '@audius/fixed-decimal' import BN from 'bn.js' import { useDispatch, useSelector } from 'react-redux' +import { UserCollectionMetadata } from '~/models' import { PurchaseMethod, PurchaseVendor } from '~/models/PurchaseContent' import { UserTrackMetadata } from '~/models/Track' import { - ContentType, + PurchaseableContentType, PurchaseContentPage, isContentPurchaseInProgress, purchaseContentActions, @@ -36,17 +37,18 @@ const { } = purchaseContentSelectors export const usePurchaseContentFormConfiguration = ({ - track, + metadata, price, presetValues, purchaseVendor }: { - track?: Nullable + metadata?: Nullable price: number presetValues: PayExtraAmountPresetValues purchaseVendor?: PurchaseVendor }) => { const dispatch = useDispatch() + const isAlbum = metadata && 'playlist_id' in metadata const stage = useSelector(getPurchaseContentFlowStage) const error = useSelector(getPurchaseContentError) const page = useSelector(getPurchaseContentPage) @@ -71,7 +73,8 @@ export const usePurchaseContentFormConfiguration = ({ purchaseMethod, purchaseVendor }: PurchaseContentValues) => { - if (isUnlocking || !track?.track_id) return + const contentId = isAlbum ? metadata?.playlist_id : metadata?.track_id + if (isUnlocking || !contentId) return if ( purchaseMethod === PurchaseMethod.CRYPTO && @@ -90,13 +93,15 @@ export const usePurchaseContentFormConfiguration = ({ purchaseVendor, extraAmount, extraAmountPreset: amountPreset, - contentId: track.track_id, - contentType: ContentType.TRACK + contentId, + contentType: isAlbum + ? PurchaseableContentType.ALBUM + : PurchaseableContentType.TRACK }) ) } }, - [isUnlocking, track, page, presetValues, dispatch] + [isAlbum, isUnlocking, metadata, page, presetValues, dispatch] ) return { diff --git a/packages/common/src/hooks/purchaseContent/utils.ts b/packages/common/src/hooks/purchaseContent/utils.ts index 34bba363701..e244966f900 100644 --- a/packages/common/src/hooks/purchaseContent/utils.ts +++ b/packages/common/src/hooks/purchaseContent/utils.ts @@ -1,8 +1,10 @@ -import { UserTrackMetadata, isContentUSDCPurchaseGated } from '~/models/Track' +import { isContentUSDCPurchaseGated } from '~/models/Track' import { PayExtraAmountPresetValues, PayExtraPreset, + PurchaseableAlbumStreamMetadata, + PurchaseableContentMetadata, PurchaseableTrackDownloadMetadata, PurchaseableTrackStreamMetadata } from './types' @@ -34,12 +36,22 @@ export const getExtraAmount = ({ return extraAmount } -export const isTrackStreamPurchaseable = ( - track: UserTrackMetadata -): track is PurchaseableTrackStreamMetadata => - isContentUSDCPurchaseGated(track.stream_conditions) +export const isPurchaseableAlbum = ( + metadata?: PurchaseableContentMetadata +): metadata is PurchaseableAlbumStreamMetadata => + !!metadata && + 'is_album' in metadata && + isContentUSDCPurchaseGated(metadata.stream_conditions) + +export const isStreamPurchaseable = ( + metadata: PurchaseableContentMetadata +): metadata is + | PurchaseableTrackStreamMetadata + | PurchaseableAlbumStreamMetadata => + isContentUSDCPurchaseGated(metadata.stream_conditions) export const isTrackDownloadPurchaseable = ( - track: UserTrackMetadata -): track is PurchaseableTrackDownloadMetadata => - isContentUSDCPurchaseGated(track.download_conditions) + metadata: PurchaseableContentMetadata +): metadata is PurchaseableTrackDownloadMetadata => + 'download_conditions' in metadata && + isContentUSDCPurchaseGated(metadata.download_conditions) diff --git a/packages/common/src/hooks/useSelectTierInfo.ts b/packages/common/src/hooks/useSelectTierInfo.ts index afd0542fcd8..64348fd422f 100644 --- a/packages/common/src/hooks/useSelectTierInfo.ts +++ b/packages/common/src/hooks/useSelectTierInfo.ts @@ -1,10 +1,8 @@ import { ID } from '../models' -import { makeGetTierAndVerifiedForUser } from '../store' +import { getTierAndVerifiedForUser } from '../store' import { useProxySelector } from './useProxySelector' -const getTierAndVerifiedForUser = makeGetTierAndVerifiedForUser() - /** * Wraps our reselect tier selector in useMemo and useSelector * to be safe for use in multiple components diff --git a/packages/common/src/models/Collection.ts b/packages/common/src/models/Collection.ts index e2e07807c1a..ae186a4251e 100644 --- a/packages/common/src/models/Collection.ts +++ b/packages/common/src/models/Collection.ts @@ -4,7 +4,13 @@ import { Repost } from '../models/Repost' import { Nullable } from '../utils/typeUtils' import { Favorite } from './Favorite' -import { UserTrackMetadata, ResourceContributor, Copyright } from './Track' +import { + AccessConditions, + UserTrackMetadata, + ResourceContributor, + Copyright, + AccessPermissions +} from './Track' import { User, UserMetadata } from './User' export enum Variant { @@ -59,6 +65,9 @@ export type CollectionMetadata = { local?: boolean release_date?: string ddex_app?: string | null + is_stream_gated: boolean + stream_conditions: Nullable + access: AccessPermissions ddex_release_ids?: any | null artists?: [ResourceContributor] | null copyright_line?: Copyright | null diff --git a/packages/common/src/models/PurchaseContent.ts b/packages/common/src/models/PurchaseContent.ts index 3a1dd33d003..886e334d3fd 100644 --- a/packages/common/src/models/PurchaseContent.ts +++ b/packages/common/src/models/PurchaseContent.ts @@ -13,3 +13,5 @@ export enum PurchaseAccess { STREAM = 'stream', DOWNLOAD = 'download' } + +export type GatedContentStatus = null | 'UNLOCKING' | 'UNLOCKED' | 'LOCKED' diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index e40bf24d980..ea9bd552c0f 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -162,8 +162,6 @@ export type SolCollectionMap = { } } -export type GatedTrackStatus = null | 'UNLOCKING' | 'UNLOCKED' | 'LOCKED' - export type ResourceContributor = { name: string roles: [string] diff --git a/packages/common/src/services/audius-api-client/ResponseAdapter.ts b/packages/common/src/services/audius-api-client/ResponseAdapter.ts index 58377c19c0a..2bd3f740278 100644 --- a/packages/common/src/services/audius-api-client/ResponseAdapter.ts +++ b/packages/common/src/services/audius-api-client/ResponseAdapter.ts @@ -333,6 +333,11 @@ export const makePlaylist = ( const total_play_count = 'total_play_count' in playlist ? playlist.total_play_count : 0 const track_count = 'track_count' in playlist ? playlist.track_count : 0 + const is_stream_gated = + 'is_stream_gated' in playlist ? playlist.is_stream_gated : false + const stream_conditions = + 'stream_conditions' in playlist ? playlist.stream_conditions : null + const access = 'access' in playlist ? playlist.access : null const playlistContents = { track_ids: playlist.added_timestamps @@ -373,6 +378,9 @@ export const makePlaylist = ( track_count, total_play_count, playlist_contents: playlistContents, + is_stream_gated, + stream_conditions, + access, // Fields to prune id: undefined, diff --git a/packages/common/src/services/audius-api-client/types.ts b/packages/common/src/services/audius-api-client/types.ts index 3ee27ff5b06..e972d620a3a 100644 --- a/packages/common/src/services/audius-api-client/types.ts +++ b/packages/common/src/services/audius-api-client/types.ts @@ -206,6 +206,8 @@ export type APIPlaylist = { cover_art: Nullable cover_art_sizes: Nullable cover_art_cids: Nullable + is_stream_gated: boolean + stream_conditions: Nullable } export type APISearchPlaylist = Omit< diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 28bfd51865b..64d21eb24e0 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -18,6 +18,7 @@ import { } from '@solana/web3.js' import BN from 'bn.js' +import { PurchaseableContentType } from '~/store' import { BN_USDC_CENT_WEI } from '~/utils/wallet' import { @@ -381,7 +382,7 @@ export type PurchaseContentArgs = { id: ID blocknumber: number extraAmount?: number | BN - type: 'track' + type: PurchaseableContentType splits: Record purchaserUserId: ID purchaseAccess: PurchaseAccess @@ -397,7 +398,7 @@ export const purchaseContent = async ( export type PurchaseContentWithPaymentRouterArgs = { id: number - type: 'track' + type: PurchaseableContentType splits: Record extraAmount?: number blocknumber: number diff --git a/packages/common/src/store/gated-content/sagas.ts b/packages/common/src/store/gated-content/sagas.ts index c79f12fc6b4..1a412aba41a 100644 --- a/packages/common/src/store/gated-content/sagas.ts +++ b/packages/common/src/store/gated-content/sagas.ts @@ -14,13 +14,13 @@ import { ID, Kind, Name, - GatedTrackStatus, Track, isContentCollectibleGated, isContentFollowGated, isContentTipGated, isContentUSDCPurchaseGated, - NFTAccessSignature + NFTAccessSignature, + GatedContentStatus } from '~/models' import { User } from '~/models/User' import { IntKeys } from '~/services/remote-config' @@ -33,6 +33,10 @@ import { usersSocialActions } from '~/store/social' import { tippingActions } from '~/store/tipping' import { Nullable } from '~/utils/typeUtils' +import { getCollection } from '../cache/collections/selectors' +import { getTrack } from '../cache/tracks/selectors' +import { PurchaseableContentType } from '../purchase-content' + import * as gatedContentSelectors from './selectors' import { actions as gatedContentActions } from './slice' @@ -41,8 +45,8 @@ const DEFAULT_GATED_TRACK_POLL_INTERVAL_MS = 1000 const { updateNftAccessSignatures, revokeAccess, - updateGatedTrackStatus, - updateGatedTrackStatuses, + updateGatedContentStatus, + updateGatedContentStatuses, addFolloweeId, removeFolloweeId, addTippedUserId, @@ -185,7 +189,7 @@ function* handleSpecialAccessTrackSubscriptions(tracks: Track[]) { const followeeIds = yield* select(getFolloweeIds) const tippedUserIds = yield* select(getTippedUserIds) - const statusMap: { [id: ID]: GatedTrackStatus } = {} + const statusMap: { [id: ID]: GatedContentStatus } = {} const tracksThatNeedSignature = Object.values(tracks).filter((track) => { const { @@ -229,13 +233,14 @@ function* handleSpecialAccessTrackSubscriptions(tracks: Track[]) { return false }) - yield* put(updateGatedTrackStatuses(statusMap)) + yield* put(updateGatedContentStatuses(statusMap)) yield* all( tracksThatNeedSignature.map((track) => { const trackId = track.track_id - return call(pollGatedTrack, { - trackId, + return call(pollGatedContent, { + contentId: trackId, + contentType: PurchaseableContentType.TRACK, currentUserId, isSourceTrack: false }) @@ -318,7 +323,7 @@ function* updateCollectibleGatedTracks(trackMap: { [id: ID]: string[] }) { * make a request to DN which confirms that user owns the corresponding nft collections by * returning corresponding stream and download signatures. */ -function* updateGatedTrackAccess( +function* updateGatedContentAccess( action: | ReturnType | ReturnType @@ -407,14 +412,16 @@ function* updateGatedTrackAccess( yield* call(updateCollectibleGatedTracks, trackMap) } -export function* pollGatedTrack({ - trackId, +export function* pollGatedContent({ + contentId, + contentType, currentUserId, isSourceTrack }: { - trackId: ID + contentId: ID + contentType: PurchaseableContentType currentUserId: number - isSourceTrack: boolean + isSourceTrack?: boolean }) { const analytics = yield* getContext('analytics') const apiClient = yield* getContext('apiClient') @@ -425,94 +432,112 @@ export function* pollGatedTrack({ DEFAULT_GATED_TRACK_POLL_INTERVAL_MS // get initial track metadata to determine whether we are polling for stream or download access - const cachedTracks = yield* select(getTracks, { - ids: [trackId] - }) - const initialTrack = cachedTracks[trackId] - const initiallyHadNoStreamAccess = !initialTrack?.access.stream - const initiallyHadNoDownloadAccess = !initialTrack?.access.download + const isAlbum = contentType === PurchaseableContentType.ALBUM + const cachedEntity = isAlbum + ? yield* select(getCollection, { id: contentId }) + : yield* select(getTrack, { + id: contentId + }) + const initiallyHadNoStreamAccess = !cachedEntity?.access.stream + const initiallyHadNoDownloadAccess = !cachedEntity?.access.download // poll for access until it is granted while (true) { - const track = yield* call([apiClient, 'getTrack'], { - id: trackId, - currentUserId - }) - const currentlyHasStreamAccess = !!track?.access?.stream - const currentlyHasDownloadAccess = !!track?.access?.download + const apiEntity = isAlbum + ? (yield* call([apiClient, 'getPlaylist'], { + playlistId: contentId, + currentUserId + }))[0] + : yield* call([apiClient, 'getTrack'], { + id: contentId, + currentUserId + }) - if (initiallyHadNoStreamAccess && currentlyHasStreamAccess) { - yield* put( - cacheActions.update(Kind.TRACKS, [ - { - id: trackId, - metadata: { - access: track.access - } - } - ]) + if (!apiEntity?.access) { + throw new Error( + `Could not retrieve entity with access for ${contentType} ${contentId}` ) - yield* put(updateGatedTrackStatus({ trackId, status: 'UNLOCKED' })) - // note: if necessary, update some ui status to show that the track download is unlocked - yield* put(removeFolloweeId({ id: track.owner_id })) - yield* put(removeTippedUserId({ id: track.owner_id })) + } - // Show confetti if track is unlocked from the how to unlock section on track page or modal + const ownerId = + 'playlist_owner_id' in apiEntity // isAlbum + ? apiEntity.playlist_owner_id + : apiEntity.owner_id + + const currentlyHasStreamAccess = !!apiEntity.access.stream + const currentlyHasDownloadAccess = !!apiEntity.access.download + + yield* put( + cacheActions.update(isAlbum ? Kind.COLLECTIONS : Kind.TRACKS, [ + { + id: contentId, + metadata: { + access: apiEntity.access + } + } + ]) + ) + if (initiallyHadNoStreamAccess && currentlyHasStreamAccess) { + yield* put(updateGatedContentStatus({ contentId, status: 'UNLOCKED' })) + // note: if necessary, update some ui status to show that the download is unlocked + yield* put(removeFolloweeId({ id: ownerId })) + yield* put(removeTippedUserId({ id: ownerId })) + + // Show confetti if track is unlocked from the how to unlock section on track/collection page or modal if (isSourceTrack) { yield* put(showConfetti()) } - if (!track.stream_conditions) { + if (!apiEntity.stream_conditions) { return } - const eventName = isContentUSDCPurchaseGated(track.stream_conditions) - ? Name.USDC_PURCHASE_GATED_TRACK_UNLOCKED - : isContentFollowGated(track.stream_conditions) - ? Name.FOLLOW_GATED_TRACK_UNLOCKED - : isContentTipGated(track.stream_conditions) - ? Name.TIP_GATED_TRACK_UNLOCKED - : null + + // TODO: Add separate analytics names for gated albums + const eventName = + !isAlbum && + (isContentUSDCPurchaseGated(apiEntity.stream_conditions) + ? Name.USDC_PURCHASE_GATED_TRACK_UNLOCKED + : isContentFollowGated(apiEntity.stream_conditions) + ? Name.FOLLOW_GATED_TRACK_UNLOCKED + : isContentTipGated(apiEntity.stream_conditions) + ? Name.TIP_GATED_TRACK_UNLOCKED + : null) if (eventName) { analytics.track({ eventName, properties: { - trackId + contentId } }) } break } else if (initiallyHadNoDownloadAccess && currentlyHasDownloadAccess) { - yield* put( - cacheActions.update(Kind.TRACKS, [ - { - id: trackId, - metadata: { - access: track.access - } - } - ]) - ) // note: if necessary, update some ui status to show that the track download is unlocked - yield* put(removeFolloweeId({ id: track.owner_id })) + yield* put(removeFolloweeId({ id: ownerId })) // Show confetti if track is unlocked from the how to unlock section on track page or modal if (isSourceTrack) { yield* put(showConfetti()) } - if (!track.download_conditions) { + if ( + !('download_conditions' in apiEntity) || + !apiEntity.download_conditions + ) { return } - const eventName = isContentUSDCPurchaseGated(track.download_conditions) - ? Name.USDC_PURCHASE_GATED_DOWNLOAD_TRACK_UNLOCKED - : isContentFollowGated(track.download_conditions) - ? Name.FOLLOW_GATED_DOWNLOAD_TRACK_UNLOCKED - : null + const eventName = + !isAlbum && + (isContentUSDCPurchaseGated(apiEntity.download_conditions) + ? Name.USDC_PURCHASE_GATED_DOWNLOAD_TRACK_UNLOCKED + : isContentFollowGated(apiEntity.download_conditions) + ? Name.FOLLOW_GATED_DOWNLOAD_TRACK_UNLOCKED + : null) if (eventName) { analytics.track({ eventName, properties: { - trackId + contentId } }) } @@ -544,7 +569,7 @@ function* updateSpecialAccessTracks( yield* put(addTippedUserId({ id: trackOwnerId })) } - const statusMap: { [id: ID]: GatedTrackStatus } = {} + const statusMap: { [id: ID]: GatedContentStatus } = {} const tracksToPoll: Set = new Set() const cachedTracks = yield* select(getTracks, {}) @@ -573,12 +598,13 @@ function* updateSpecialAccessTracks( } }) - yield* put(updateGatedTrackStatuses(statusMap)) + yield* put(updateGatedContentStatuses(statusMap)) yield* all( Array.from(tracksToPoll).map((trackId) => { - return call(pollGatedTrack, { - trackId, + return call(pollGatedContent, { + contentId: trackId, + contentType: PurchaseableContentType.TRACK, currentUserId, isSourceTrack: sourceTrackId === trackId }) @@ -598,7 +624,7 @@ function* handleUnfollowUser( // polling their newly loaded follow gated track signatures. yield* put(removeFolloweeId({ id: action.userId })) - const statusMap: { [id: ID]: GatedTrackStatus } = {} + const statusMap: { [id: ID]: GatedContentStatus } = {} const revokeAccessMap: { [id: ID]: 'stream' | 'download' } = {} const cachedTracks = yield* select(getTracks, {}) @@ -621,7 +647,7 @@ function* handleUnfollowUser( } }) - yield* put(updateGatedTrackStatuses(statusMap)) + yield* put(updateGatedContentStatuses(statusMap)) yield* put(revokeAccess({ revokeAccessMap })) } @@ -675,7 +701,7 @@ function* watchGatedTracks() { updateUserEthCollectibles.type, updateUserSolCollectibles.type ], - updateGatedTrackAccess + updateGatedContentAccess ) } diff --git a/packages/common/src/store/gated-content/selectors.ts b/packages/common/src/store/gated-content/selectors.ts index fbdc1a71943..c883ab9bae8 100644 --- a/packages/common/src/store/gated-content/selectors.ts +++ b/packages/common/src/store/gated-content/selectors.ts @@ -3,7 +3,7 @@ import { CommonState } from '../commonStore' export const getNftAccessSignatureMap = (state: CommonState) => state.gatedContent.nftAccessSignatureMap -export const getGatedTrackStatusMap = (state: CommonState) => +export const getGatedContentStatusMap = (state: CommonState) => state.gatedContent.statusMap export const getLockedContentId = (state: CommonState) => diff --git a/packages/common/src/store/gated-content/slice.ts b/packages/common/src/store/gated-content/slice.ts index a8b0d855bbe..2eaa90dc2db 100644 --- a/packages/common/src/store/gated-content/slice.ts +++ b/packages/common/src/store/gated-content/slice.ts @@ -1,11 +1,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { ID, GatedTrackStatus, NFTAccessSignature } from '~/models' +import { ID, GatedContentStatus, NFTAccessSignature } from '~/models' import { Nullable } from '~/utils' type GatedContentState = { nftAccessSignatureMap: { [id: ID]: Nullable } - statusMap: { [id: ID]: GatedTrackStatus } + statusMap: { [id: ID]: GatedContentStatus } lockedContentId: Nullable followeeIds: ID[] tippedUserIds: ID[] @@ -27,13 +27,13 @@ type RevokeAccessPayload = { revokeAccessMap: { [id: ID]: 'stream' | 'download' } } -type UpdateGatedTrackStatusPayload = { - trackId: ID - status: GatedTrackStatus +type UpdateGatedContentStatusPayload = { + contentId: ID + status: GatedContentStatus } -type UpdateGatedTrackStatusesPayload = { - [id: ID]: GatedTrackStatus +type UpdateGatedContentStatusesPayload = { + [id: ID]: GatedContentStatus } type IdPayload = { @@ -56,15 +56,15 @@ const slice = createSlice({ revokeAccess: (_state, __action: PayloadAction) => { // triggers saga }, - updateGatedTrackStatus: ( + updateGatedContentStatus: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.statusMap[action.payload.trackId] = action.payload.status + state.statusMap[action.payload.contentId] = action.payload.status }, - updateGatedTrackStatuses: ( + updateGatedContentStatuses: ( state, - action: PayloadAction + action: PayloadAction ) => { state.statusMap = { ...state.statusMap, @@ -99,8 +99,8 @@ const slice = createSlice({ export const { updateNftAccessSignatures, revokeAccess, - updateGatedTrackStatus, - updateGatedTrackStatuses, + updateGatedContentStatus, + updateGatedContentStatuses, setLockedContentId, resetLockedContentId, addFolloweeId, diff --git a/packages/common/src/store/purchase-content/sagas.ts b/packages/common/src/store/purchase-content/sagas.ts index 4507dc1faad..47909b58cc1 100644 --- a/packages/common/src/store/purchase-content/sagas.ts +++ b/packages/common/src/store/purchase-content/sagas.ts @@ -1,7 +1,9 @@ import BN from 'bn.js' +import { sumBy } from 'lodash' import { takeLatest } from 'redux-saga/effects' import { call, put, race, select, take } from 'typed-redux-saga' +import { PurchaseableContentMetadata, isPurchaseableAlbum } from '~/hooks' import { FavoriteSource, Name } from '~/models/Analytics' import { ErrorLevel } from '~/models/ErrorReporting' import { ID } from '~/models/Identifiers' @@ -10,7 +12,7 @@ import { PurchaseVendor, PurchaseAccess } from '~/models/PurchaseContent' -import { Track, isContentUSDCPurchaseGated } from '~/models/Track' +import { isContentUSDCPurchaseGated } from '~/models/Track' import { User } from '~/models/User' import { BNUSDC } from '~/models/Wallet' import { @@ -37,6 +39,7 @@ import { } from '~/store/buy-usdc/slice' import { BuyUSDCError } from '~/store/buy-usdc/types' import { getBuyUSDCRemoteConfig, getUSDCUserBank } from '~/store/buy-usdc/utils' +import { getCollection } from '~/store/cache/collections/selectors' import { getTrack } from '~/store/cache/tracks/selectors' import { getUser } from '~/store/cache/users/selectors' import { getContext } from '~/store/effects' @@ -56,8 +59,9 @@ import { } from '~/store/ui/modals/coinflow-onramp-modal' import { BN_USDC_CENT_WEI } from '~/utils/wallet' -import { pollGatedTrack } from '../gated-content/sagas' -import { updateGatedTrackStatus } from '../gated-content/slice' +import { pollGatedContent } from '../gated-content/sagas' +import { updateGatedContentStatus } from '../gated-content/slice' +import { saveCollection } from '../social/collections/actions' import { buyUSDC, @@ -68,7 +72,11 @@ import { purchaseContentFlowFailed, startPurchaseContentFlow } from './slice' -import { ContentType, PurchaseContentError, PurchaseErrorCode } from './types' +import { + PurchaseableContentType, + PurchaseContentError, + PurchaseErrorCode +} from './types' import { getBalanceNeeded } from './utils' const { getUserId, getAccountUser } = accountSelectors @@ -87,35 +95,46 @@ type RaceStatusResult = { type GetPurchaseConfigArgs = { contentId: ID - contentType: ContentType + contentType: PurchaseableContentType } function* getContentInfo({ contentId, contentType }: GetPurchaseConfigArgs) { - if (contentType !== ContentType.TRACK) { - throw new Error('Only tracks are supported') - } - - const trackInfo = yield* select(getTrack, { id: contentId }) + const metadata = + contentType === PurchaseableContentType.ALBUM + ? yield* select(getCollection, { id: contentId }) + : yield* select(getTrack, { id: contentId }) const purchaseConditions = - trackInfo?.stream_conditions ?? trackInfo?.download_conditions + metadata?.stream_conditions ?? + (metadata && 'download_conditions' in metadata + ? metadata?.download_conditions + : null) // Stream access is a superset of download access - purchasing a stream-gated // track also gets you download access, but purchasing a download-gated track // only gets you download access (because the track was already free to stream). - const purchaseAccess = trackInfo?.is_stream_gated + const purchaseAccess = metadata?.is_stream_gated ? PurchaseAccess.STREAM : PurchaseAccess.DOWNLOAD - if (!trackInfo || !isContentUSDCPurchaseGated(purchaseConditions)) { + if (!metadata || !isContentUSDCPurchaseGated(purchaseConditions)) { throw new Error('Content is missing purchase conditions') } - const artistInfo = yield* select(getUser, { id: trackInfo.owner_id }) + const isAlbum = 'playlist_id' in metadata + const artistId = isAlbum ? metadata.playlist_owner_id : metadata.owner_id + const artistInfo = yield* select(getUser, { id: artistId }) if (!artistInfo) { throw new Error('Failed to retrieve content owner') } - const title = trackInfo.title + const title = isAlbum ? metadata.playlist_name : metadata.title const price = purchaseConditions.usdc_purchase.price - return { price, title, artistInfo, purchaseAccess, trackInfo } + return { + price, + title, + artistInfo, + purchaseAccess, + purchaseConditions, + metadata + } } const getUserPurchaseMetadata = ({ @@ -142,39 +161,66 @@ const getUserPurchaseMetadata = ({ location }) -const getTrackPurchaseMetadata = ({ - created_at, - description, - duration, - genre, - is_delete, - isrc, - iswc, - license, - owner_id, - permalink, - release_date, - tags, - title, - track_id, - updated_at -}: Track) => ({ - created_at, - description, - duration, - genre, - is_delete, - isrc, - iswc, - license, - owner_id, - permalink, - release_date, - tags, - title, - track_id, - updated_at -}) +const getPurchaseMetadata = (metadata: PurchaseableContentMetadata) => { + const { + created_at, + description, + is_delete, + permalink, + release_date, + updated_at + } = metadata + + const commonFields = { + created_at, + description, + is_delete, + permalink, + release_date, + updated_at + } + + const isAlbum = isPurchaseableAlbum(metadata) + if (isAlbum) { + return { + ...commonFields, + duration: sumBy(metadata.tracks, (track) => track.duration), + owner_id: metadata.playlist_owner_id, + title: metadata.playlist_name, + content_id: metadata.playlist_id + } + } + + const { + duration, + genre, + isrc, + iswc, + license, + owner_id, + tags, + title, + track_id + } = metadata + + return { + created_at, + description, + duration, + genre, + is_delete, + isrc, + iswc, + license, + owner_id, + permalink, + release_date, + tags, + title, + content_id: track_id, + updated_at + } +} function* getCoinflowPurchaseMetadata({ contentId, @@ -182,7 +228,7 @@ function* getCoinflowPurchaseMetadata({ extraAmount, splits }: PurchaseWithCoinflowArgs) { - const { trackInfo, artistInfo, title, price } = yield* call(getContentInfo, { + const { metadata, artistInfo, title, price } = yield* call(getContentInfo, { contentId, contentType }) @@ -198,34 +244,33 @@ function* getCoinflowPurchaseMetadata({ usdcRecipientSplits: splits, artistInfo: getUserPurchaseMetadata(artistInfo), purchaserInfo: currentUser ? getUserPurchaseMetadata(currentUser) : null, - contentInfo: getTrackPurchaseMetadata(trackInfo) + contentInfo: getPurchaseMetadata(metadata as PurchaseableContentMetadata) } } return data } function* getPurchaseConfig({ contentId, contentType }: GetPurchaseConfigArgs) { - if (contentType !== ContentType.TRACK) { - throw new Error('Only tracks are supported') - } - - const trackInfo = yield* select(getTrack, { id: contentId }) - const purchaseConditions = - trackInfo?.stream_conditions ?? trackInfo?.download_conditions - if (!trackInfo || !isContentUSDCPurchaseGated(purchaseConditions)) { + const { metadata, artistInfo, purchaseConditions } = yield* call( + getContentInfo, + { + contentId, + contentType + } + ) + if (!metadata || !isContentUSDCPurchaseGated(purchaseConditions)) { throw new Error('Content is missing purchase conditions') } - const user = yield* select(getUser, { id: trackInfo.owner_id }) - if (!user) { + if (!artistInfo) { throw new Error('Failed to retrieve content owner') } - const recipientERCWallet = user.erc_wallet ?? user.wallet + const recipientERCWallet = artistInfo.erc_wallet ?? artistInfo.wallet if (!recipientERCWallet) { throw new Error('Unable to resolve destination wallet') } - const { blocknumber } = trackInfo + const { blocknumber } = metadata const splits = purchaseConditions.usdc_purchase.splits return { @@ -239,24 +284,19 @@ function* pollForPurchaseConfirmation({ contentType }: { contentId: ID - contentType: ContentType + contentType: PurchaseableContentType }) { - if (contentType !== ContentType.TRACK) { - throw new Error('Only tracks are supported') - } - const currentUserId = yield* select(getUserId) if (!currentUserId) { throw new Error( 'Failed to fetch current user id while polling for purchase confirmation' ) } - yield* put( - updateGatedTrackStatus({ trackId: contentId, status: 'UNLOCKING' }) - ) + yield* put(updateGatedContentStatus({ contentId, status: 'UNLOCKING' })) - yield* pollGatedTrack({ - trackId: contentId, + yield* pollGatedContent({ + contentId, + contentType, currentUserId, isSourceTrack: true }) @@ -267,7 +307,7 @@ type PurchaseWithCoinflowArgs = { extraAmount?: number splits: Record contentId: ID - contentType: ContentType + contentType: PurchaseableContentType purchaserUserId: ID /** USDC in dollars */ price: number @@ -280,6 +320,7 @@ function* purchaseWithCoinflow(args: PurchaseWithCoinflowArgs) { extraAmount, splits, contentId, + contentType, purchaserUserId, price, purchaseAccess @@ -298,7 +339,7 @@ function* purchaseWithCoinflow(args: PurchaseWithCoinflowArgs) { audiusBackendInstance, { id: contentId, - type: 'track', + type: contentType, splits, extraAmount, blocknumber, @@ -404,7 +445,7 @@ function* doStartPurchaseContentFlow({ purchaseMethod, purchaseVendor, contentId, - contentType = ContentType.TRACK + contentType = PurchaseableContentType.TRACK } }: ReturnType) { const audiusBackendInstance = yield* getContext('audiusBackendInstance') @@ -495,7 +536,7 @@ function* doStartPurchaseContentFlow({ blocknumber, extraAmount: extraAmountBN, splits, - type: 'track', + type: contentType, purchaserUserId, purchaseAccess }) @@ -525,7 +566,7 @@ function* doStartPurchaseContentFlow({ blocknumber, extraAmount: extraAmountBN, splits, - type: 'track', + type: contentType, purchaserUserId, purchaseAccess }) @@ -542,14 +583,21 @@ function* doStartPurchaseContentFlow({ yield* pollForPurchaseConfirmation({ contentId, contentType }) // Auto-favorite the purchased item - if (contentType === ContentType.TRACK) { + if (contentType === PurchaseableContentType.TRACK) { yield* put(saveTrack(contentId, FavoriteSource.IMPLICIT)) } + if (contentType === PurchaseableContentType.ALBUM) { + yield* put(saveCollection(contentId, FavoriteSource.IMPLICIT)) + } // Check if playing the purchased track's preview and if so, stop it const isPreviewing = yield* select(getPreviewing) - const trackId = yield* select(getTrackId) - if (contentId === trackId && isPreviewing) { + const nowPlayingTrackId = yield* select(getTrackId) + if ( + contentType === PurchaseableContentType.TRACK && + contentId === nowPlayingTrackId && + isPreviewing + ) { yield* put(stop({})) } diff --git a/packages/common/src/store/purchase-content/slice.ts b/packages/common/src/store/purchase-content/slice.ts index c217c820102..7f0c997e58a 100644 --- a/packages/common/src/store/purchase-content/slice.ts +++ b/packages/common/src/store/purchase-content/slice.ts @@ -4,7 +4,7 @@ import { ID } from '~/models/Identifiers' import { PurchaseMethod, PurchaseVendor } from '~/models/PurchaseContent' import { - ContentType, + PurchaseableContentType, PurchaseContentError, PurchaseContentPage, PurchaseContentStage @@ -18,7 +18,7 @@ type OnSuccess = { type PurchaseContentState = { page: PurchaseContentPage stage: PurchaseContentStage - contentType: ContentType + contentType: PurchaseableContentType contentId: ID /** Pay extra amount in cents */ extraAmount?: number @@ -32,7 +32,7 @@ type PurchaseContentState = { const initialState: PurchaseContentState = { page: PurchaseContentPage.PURCHASE, - contentType: ContentType.TRACK, + contentType: PurchaseableContentType.TRACK, contentId: -1, extraAmount: undefined, extraAmountPreset: undefined, @@ -54,7 +54,7 @@ const slice = createSlice({ purchaseMethod: PurchaseMethod purchaseVendor?: PurchaseVendor contentId: ID - contentType?: ContentType + contentType?: PurchaseableContentType onSuccess?: OnSuccess }> ) => { @@ -64,7 +64,8 @@ const slice = createSlice({ state.extraAmount = action.payload.extraAmount state.extraAmountPreset = action.payload.extraAmountPreset state.contentId = action.payload.contentId - state.contentType = action.payload.contentType ?? ContentType.TRACK + state.contentType = + action.payload.contentType ?? PurchaseableContentType.TRACK state.onSuccess = action.payload.onSuccess state.purchaseMethod = action.payload.purchaseMethod state.purchaseVendor = action.payload.purchaseVendor @@ -87,7 +88,7 @@ const slice = createSlice({ purchaseConfirmed: ( state, _action: PayloadAction<{ - contentType: ContentType + contentType: PurchaseableContentType contentId: ID }> ) => { diff --git a/packages/common/src/store/purchase-content/types.ts b/packages/common/src/store/purchase-content/types.ts index 5acf76766c0..fb542953668 100644 --- a/packages/common/src/store/purchase-content/types.ts +++ b/packages/common/src/store/purchase-content/types.ts @@ -2,8 +2,9 @@ import { BuyCryptoErrorCode } from '~/store/buy-crypto/types' import { BuyUSDCErrorCode } from '../buy-usdc' -export enum ContentType { - TRACK = 'track' +export enum PurchaseableContentType { + TRACK = 'track', + ALBUM = 'album' } export enum PurchaseContentStage { diff --git a/packages/common/src/store/ui/modals/premium-content-purchase-modal/index.ts b/packages/common/src/store/ui/modals/premium-content-purchase-modal/index.ts index fec9b153c83..de01232332e 100644 --- a/packages/common/src/store/ui/modals/premium-content-purchase-modal/index.ts +++ b/packages/common/src/store/ui/modals/premium-content-purchase-modal/index.ts @@ -1,9 +1,11 @@ import { ID } from '~/models/Identifiers' +import { PurchaseableContentType } from '~/store/purchase-content' import { createModal } from '../createModal' export type PremiumContentPurchaseModalState = { contentId: ID + contentType: PurchaseableContentType } const premiumContentPurchaseModal = @@ -11,11 +13,15 @@ const premiumContentPurchaseModal = reducerPath: 'PremiumContentPurchaseModal', initialState: { isOpen: false, - contentId: -1 + contentId: -1, + contentType: PurchaseableContentType.TRACK }, sliceSelector: (state) => state.ui.modals, enableTracking: true, - getTrackingData: ({ contentId }) => ({ contentId }) + getTrackingData: ({ contentId, contentType }) => ({ + contentId, + contentType + }) }) export const { diff --git a/packages/common/src/store/wallet/utils.ts b/packages/common/src/store/wallet/utils.ts index a3f0b6402c4..c1558ae2076 100644 --- a/packages/common/src/store/wallet/utils.ts +++ b/packages/common/src/store/wallet/utils.ts @@ -68,17 +68,16 @@ export const getWeiBalanceForUser = ( return getUserBalance(user) } -export const makeGetTierAndVerifiedForUser = () => - createSelector( - [getWeiBalanceForUser, getVerifiedForUser], - ( - wei, - isVerified - ): { tier: BadgeTier; isVerified: boolean; tierNumber: number } => { - const { tier, tierNumber } = getTierAndNumberForBalance(wei) - return { tier, isVerified, tierNumber } - } - ) +export const getTierAndVerifiedForUser = createSelector( + [getWeiBalanceForUser, getVerifiedForUser], + ( + wei, + isVerified + ): { tier: BadgeTier; isVerified: boolean; tierNumber: number } => { + const { tier, tierNumber } = getTierAndNumberForBalance(wei) + return { tier, isVerified, tierNumber } + } +) // Helpers diff --git a/packages/discovery-provider/src/api/v1/models/playlists.py b/packages/discovery-provider/src/api/v1/models/playlists.py index 1ba19e07c50..233df1b26b5 100644 --- a/packages/discovery-provider/src/api/v1/models/playlists.py +++ b/packages/discovery-provider/src/api/v1/models/playlists.py @@ -72,6 +72,8 @@ "cover_art_sizes": fields.String, "cover_art_cids": fields.Nested(playlist_artwork, allow_null=True), "track_count": fields.Integer(required=True), + "is_stream_gated": fields.Boolean(required=True), + "stream_conditions": fields.Raw(allow_null=True), }, ) diff --git a/packages/discovery-provider/src/gated_content/content_access_checker.py b/packages/discovery-provider/src/gated_content/content_access_checker.py index 7443aeb45e2..1bb7565efe7 100644 --- a/packages/discovery-provider/src/gated_content/content_access_checker.py +++ b/packages/discovery-provider/src/gated_content/content_access_checker.py @@ -37,8 +37,8 @@ class ContentAccessResponse(TypedDict): class ContentAccessBatchResponse(TypedDict): # track : user id -> track id -> access track: GatedContentAccessResult - # playlist : user id -> playlist id -> access - playlist: GatedContentAccessResult + # album : user id -> playlist id -> access + album: GatedContentAccessResult class GatedContentAccessHandler(Protocol): @@ -150,7 +150,7 @@ def check_access_for_batch( args: List[ContentAccessBatchArgs], ) -> ContentAccessBatchResponse: if not args: - return {"track": {}, "playlist": {}} + return {"track": {}, "album": {}} track_ids = [ arg["content_id"] for arg in args if arg["content_type"] == "track" @@ -161,11 +161,11 @@ def check_access_for_batch( ] gated_album_data = self._get_gated_album_data_for_batch(session, album_ids) - batch_access_result: ContentAccessBatchResponse = {"track": {}, "playlist": {}} + batch_access_result: ContentAccessBatchResponse = {"track": {}, "album": {}} for arg in args: content_type = arg["content_type"] - key_type = "track" if content_type == "track" else "playlist" + key_type = "track" if content_type == "track" else "album" content_id = arg["content_id"] user_id = arg["user_id"] entity = ( @@ -196,7 +196,7 @@ def check_access_for_batch( # note that stem tracks do not have stream/download conditions. # also note that albums only support stream_conditions. stream_conditions = entity["stream_conditions"] - download_conditions = entity["download_conditions"] + download_conditions = entity.get("download_conditions") if not stream_conditions and not download_conditions: access = ( self._check_stem_access( @@ -287,8 +287,6 @@ def _get_gated_album_data_for_batch( "content_type": "album", "is_stream_gated": album["is_stream_gated"], # type: ignore "stream_conditions": album["stream_conditions"], # type: ignore - "is_download_gated": album["is_download_gated"], # type: ignore - "download_conditions": album["download_conditions"], # type: ignore "content_owner_id": album["playlist_owner_id"], # type: ignore } for album in albums diff --git a/packages/discovery-provider/src/queries/query_helpers.py b/packages/discovery-provider/src/queries/query_helpers.py index 7530d0e059b..d01afd9765a 100644 --- a/packages/discovery-provider/src/queries/query_helpers.py +++ b/packages/discovery-provider/src/queries/query_helpers.py @@ -603,8 +603,7 @@ def getContentId(metadata): else metadata.get("playlist_id") ) - # gated track access - gated_track_access = { + gated_content_access_results = { getContentId(metadata): defaultdict() for metadata in entities } gated_entities = list( @@ -632,6 +631,7 @@ def getContentId(metadata): for entity in gated_entities: content_id = getContentId(entity) gated_access = gated_content_access[content_type] + has_stream_access = ( current_user_id in gated_access and content_id in gated_access[current_user_id] @@ -642,8 +642,12 @@ def getContentId(metadata): and content_id in gated_access[current_user_id] and gated_access[current_user_id][content_id]["has_download_access"] ) - gated_track_access[content_id]["has_stream_access"] = has_stream_access - gated_track_access[content_id]["has_download_access"] = has_download_access + gated_content_access_results[content_id][ + "has_stream_access" + ] = has_stream_access + gated_content_access_results[content_id][ + "has_download_access" + ] = has_download_access for entity in entities: content_id = getContentId(entity) @@ -653,10 +657,10 @@ def getContentId(metadata): "download": True, } else: - has_stream_access = gated_track_access[content_id].get( + has_stream_access = gated_content_access_results[content_id].get( "has_stream_access", True ) - has_download_access = gated_track_access[content_id].get( + has_download_access = gated_content_access_results[content_id].get( "has_download_access", True ) entity[response_name_constants.access] = { @@ -918,6 +922,13 @@ def populate_playlist_metadata( playlist_save ) + # has current user unlocked gated tracks? + # if so, also populate corresponding signatures. + # if no current user (guest), populate access based on track stream/download conditions + _populate_gated_content_metadata( + session, playlists, current_user_id, content_type="album" + ) + track_ids = [] for playlist in playlists: for track in playlist["playlist_contents"]["track_ids"]: @@ -961,8 +972,6 @@ def populate_playlist_metadata( user_saved_playlist_dict.get(playlist_id, False) ) - playlist[response_name_constants.access] = {} - return playlists diff --git a/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx b/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx index 4557453583e..3e0467503cd 100644 --- a/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx +++ b/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx @@ -16,7 +16,8 @@ import { usersSocialActions, tippingActions, usePremiumContentPurchaseModal, - gatedContentSelectors + gatedContentSelectors, + PurchaseableContentType } from '@audius/common/store' import { formatPrice } from '@audius/common/utils' import type { ViewStyle } from 'react-native' @@ -38,7 +39,7 @@ import { useNavigation } from 'app/hooks/useNavigation' import { flexRowCentered, makeStyles } from 'app/styles' import { spacing } from 'app/styles/spacing' -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors const { followUser } = usersSocialActions const { beginTip } = tippingActions @@ -199,7 +200,7 @@ export const DetailsTileNoAccess = ({ const followSource = isModalOpen ? FollowSource.HOW_TO_UNLOCK_MODAL : FollowSource.HOW_TO_UNLOCK_TRACK_PAGE - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const gatedTrackStatus = gatedTrackStatusMap[trackId] ?? null const { nftCollection, collectionLink, followee, tippedUser } = useStreamConditionsEntity(streamConditions) @@ -219,7 +220,7 @@ export const DetailsTileNoAccess = ({ const handlePurchasePress = useCallback(() => { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackDetails } ) }, [trackId, openPremiumContentPurchaseModal]) diff --git a/packages/mobile/src/components/lineup-tile/LineupTileAccessStatus.tsx b/packages/mobile/src/components/lineup-tile/LineupTileAccessStatus.tsx index 5a01e858c53..96f986aeecb 100644 --- a/packages/mobile/src/components/lineup-tile/LineupTileAccessStatus.tsx +++ b/packages/mobile/src/components/lineup-tile/LineupTileAccessStatus.tsx @@ -5,7 +5,8 @@ import { ModalSource, isContentUSDCPurchaseGated } from '@audius/common/models' import { usePremiumContentPurchaseModal, gatedContentActions, - gatedContentSelectors + gatedContentSelectors, + PurchaseableContentType } from '@audius/common/store' import { formatPrice } from '@audius/common/utils' import { TouchableOpacity, View } from 'react-native' @@ -20,7 +21,7 @@ import { flexRowCentered, makeStyles } from 'app/styles' import { spacing } from 'app/styles/spacing' import { useColor } from 'app/utils/theme' -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors const { setLockedContentId } = gatedContentActions const messages = { @@ -64,7 +65,7 @@ export const LineupTileAccessStatus = ({ const isUSDCEnabled = useIsUSDCEnabled() const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const gatedTrackStatus = gatedTrackStatusMap[trackId] const staticWhite = useColor('staticWhite') const isUSDCPurchase = @@ -73,7 +74,7 @@ export const LineupTileAccessStatus = ({ const handlePress = useCallback(() => { if (isUSDCPurchase) { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackTile } ) } else if (trackId) { diff --git a/packages/mobile/src/components/locked-content-drawer/LockedContentDrawer.tsx b/packages/mobile/src/components/locked-content-drawer/LockedContentDrawer.tsx index b6431145868..56b485acd4b 100644 --- a/packages/mobile/src/components/locked-content-drawer/LockedContentDrawer.tsx +++ b/packages/mobile/src/components/locked-content-drawer/LockedContentDrawer.tsx @@ -39,7 +39,7 @@ const useStyles = makeStyles(({ spacing, palette }) => ({ borderBottomWidth: 1, width: '100%' }, - gatedTrackSection: { + gatedContentSection: { padding: 0, borderWidth: 0, backgroundColor: 'transparent' @@ -76,7 +76,7 @@ export const LockedContentDrawer = () => { { const handlePurchasePress = useCallback(() => { if (track?.track_id) { openPremiumContentPurchaseModal( - { contentId: track.track_id }, + { + contentId: track.track_id, + contentType: PurchaseableContentType.TRACK + }, { source: ModalSource.NowPlaying } ) } diff --git a/packages/mobile/src/components/premium-track-purchase-drawer/PremiumTrackPurchaseDrawer.tsx b/packages/mobile/src/components/premium-track-purchase-drawer/PremiumTrackPurchaseDrawer.tsx index 3ce6b94070d..e900430404e 100644 --- a/packages/mobile/src/components/premium-track-purchase-drawer/PremiumTrackPurchaseDrawer.tsx +++ b/packages/mobile/src/components/premium-track-purchase-drawer/PremiumTrackPurchaseDrawer.tsx @@ -1,6 +1,11 @@ import { useCallback, type ReactNode, useEffect } from 'react' import { useGetTrackById } from '@audius/common/api' +import type { + PurchaseableTrackStreamMetadata, + PurchaseableTrackDownloadMetadata, + PurchaseableContentMetadata +} from '@audius/common/hooks' import { useRemoteVar, useUSDCBalance, @@ -10,10 +15,9 @@ import { PURCHASE_METHOD, PURCHASE_VENDOR, usePurchaseMethod, - isTrackStreamPurchaseable, + isStreamPurchaseable, isTrackDownloadPurchaseable } from '@audius/common/hooks' -import type { PurchaseableTrackMetadata } from '@audius/common/hooks' import type { USDCPurchaseConditions } from '@audius/common/models' import { Name, @@ -233,7 +237,7 @@ const RenderForm = ({ purchaseConditions }: { onClose: () => void - track: PurchaseableTrackMetadata + track: PurchaseableTrackStreamMetadata | PurchaseableTrackDownloadMetadata purchaseConditions: USDCPurchaseConditions }) => { const navigation = useNavigation() @@ -361,7 +365,7 @@ const RenderForm = ({ {isIOSDisabled ? ( ) : isPurchaseSuccessful ? ( - + ) : isUnlocking ? null : ( @@ -429,6 +433,7 @@ export const PremiumTrackPurchaseDrawer = () => { const isUSDCEnabled = useIsUSDCEnabled() const presetValues = usePayExtraPresets() const { + // TODO: album support data: { contentId: trackId }, isOpen, onClose, @@ -439,26 +444,34 @@ export const PremiumTrackPurchaseDrawer = () => { { id: trackId }, { disabled: !trackId } ) + const metadata = track as PurchaseableContentMetadata const stage = useSelector(getPurchaseContentFlowStage) const error = useSelector(getPurchaseContentError) const isUnlocking = !error && isContentPurchaseInProgress(stage) const isLoading = statusIsNotFinalized(trackStatus) - const isValidStreamGatedTrack = !!track && isTrackStreamPurchaseable(track) + const isValidStreamGatedTrack = !!metadata && isStreamPurchaseable(metadata) const isValidDownloadGatedTrack = - !!track && isTrackDownloadPurchaseable(track) + !!metadata && isTrackDownloadPurchaseable(metadata) const purchaseConditions = isValidStreamGatedTrack - ? track.stream_conditions + ? metadata.stream_conditions : isValidDownloadGatedTrack - ? track.download_conditions + ? metadata.download_conditions : null - const price = purchaseConditions ? purchaseConditions?.usdc_purchase.price : 0 + const price = + purchaseConditions && 'usdc_purchase' in purchaseConditions + ? purchaseConditions?.usdc_purchase.price + : 0 const { initialValues, onSubmit, validationSchema } = - usePurchaseContentFormConfiguration({ track, presetValues, price }) + usePurchaseContentFormConfiguration({ + metadata, + presetValues, + price + }) const handleClosed = useCallback(() => { onClosed() @@ -498,7 +511,11 @@ export const PremiumTrackPurchaseDrawer = () => { > diff --git a/packages/mobile/src/components/premium-track-purchase-drawer/PurchaseSuccess.tsx b/packages/mobile/src/components/premium-track-purchase-drawer/PurchaseSuccess.tsx index b0ef82a4106..e5dc55f14cd 100644 --- a/packages/mobile/src/components/premium-track-purchase-drawer/PurchaseSuccess.tsx +++ b/packages/mobile/src/components/premium-track-purchase-drawer/PurchaseSuccess.tsx @@ -1,7 +1,13 @@ import { useCallback } from 'react' -import type { PurchaseableTrackMetadata } from '@audius/common/hooks' -import { tracksSocialActions } from '@audius/common/store' +import { + isPurchaseableAlbum, + type PurchaseableContentMetadata +} from '@audius/common/hooks' +import { + collectionsSocialActions, + tracksSocialActions +} from '@audius/common/store' import { useDispatch } from 'react-redux' import { @@ -15,16 +21,14 @@ import { } from '@audius/harmony-native' import { spacing } from 'app/styles/spacing' import { EventNames, RepostSource } from 'app/types/analytics' -import { getTrackRoute } from 'app/utils/routes' +import { getCollectionRoute, getTrackRoute } from 'app/utils/routes' import { TwitterButton } from '../twitter-button' const messages = { success: 'Your Purchase Was Successful!', - shareTwitterTextTrack: (trackTitle: string, handle: string) => - `I bought the track ${trackTitle} by ${handle} on @Audius! #AudiusPremium`, - shareTwitterTextStems: (trackTitle: string, handle: string) => - `I bought the stems for ${trackTitle} by ${handle} on @Audius! #AudiusPremium`, + shareTwitterText: (contentType: string, trackTitle: string, handle: string) => + `I bought the ${contentType} ${trackTitle} by ${handle} on @Audius! #AudiusPremium`, viewTrack: 'View Track', repost: 'Repost', reposted: 'Reposted' @@ -32,31 +36,34 @@ const messages = { export const PurchaseSuccess = ({ onPressViewTrack, - track + metadata }: { onPressViewTrack: () => void - track: PurchaseableTrackMetadata + metadata: PurchaseableContentMetadata }) => { - const { handle } = track.user - const { - title, - is_download_gated, - _stems, - has_current_user_reposted: isReposted, - track_id: trackId - } = track + const { handle } = metadata.user + const { has_current_user_reposted: isReposted } = metadata + const isAlbum = isPurchaseableAlbum(metadata) + const title = isAlbum ? metadata.playlist_name : metadata.title + const contentId = isAlbum ? metadata.playlist_id : metadata.track_id - const link = getTrackRoute(track, true) + const link = isAlbum + ? getCollectionRoute(metadata) + : getTrackRoute(metadata, true) const dispatch = useDispatch() const handleTwitterShare = useCallback( (handle: string) => { let shareText: string - if (is_download_gated && _stems?.length) { - shareText = messages.shareTwitterTextStems(title, handle) + if (!isAlbum && metadata.is_download_gated && metadata._stems?.length) { + shareText = messages.shareTwitterText('stems for', title, handle) } else { - shareText = messages.shareTwitterTextTrack(title, handle) + shareText = messages.shareTwitterText( + isAlbum ? 'album' : 'track', + title, + handle + ) } return { shareText, @@ -66,16 +73,23 @@ export const PurchaseSuccess = ({ } as const } }, - [title, is_download_gated, _stems] + [isAlbum, metadata, title] ) const onRepost = useCallback(() => { dispatch( isReposted - ? tracksSocialActions.undoRepostTrack(trackId, RepostSource.PURCHASE) - : tracksSocialActions.repostTrack(trackId, RepostSource.PURCHASE) + ? (isAlbum + ? collectionsSocialActions.undoRepostCollection + : tracksSocialActions.undoRepostTrack)( + contentId, + RepostSource.PURCHASE + ) + : (isAlbum + ? collectionsSocialActions.repostCollection + : tracksSocialActions.repostTrack)(contentId, RepostSource.PURCHASE) ) - }, [dispatch, isReposted, trackId]) + }, [contentId, dispatch, isAlbum, isReposted]) return ( diff --git a/packages/mobile/src/screens/notifications-screen/Notification/utils.ts b/packages/mobile/src/screens/notifications-screen/Notification/utils.ts index 0cf7c1c726a..a0a9181c884 100644 --- a/packages/mobile/src/screens/notifications-screen/Notification/utils.ts +++ b/packages/mobile/src/screens/notifications-screen/Notification/utils.ts @@ -5,7 +5,7 @@ import { getCollectionRoute, getTrackRoute } from 'app/utils/routes' export const getEntityRoute = (entity: EntityType, fullUrl = false) => { if ('track_id' in entity) { return getTrackRoute(entity, fullUrl) - } else { + } else if (entity.user) { return getCollectionRoute(entity, fullUrl) } } diff --git a/packages/mobile/src/screens/track-screen/DownloadSection.tsx b/packages/mobile/src/screens/track-screen/DownloadSection.tsx index 33dbbe8c988..9f5e7f9785b 100644 --- a/packages/mobile/src/screens/track-screen/DownloadSection.tsx +++ b/packages/mobile/src/screens/track-screen/DownloadSection.tsx @@ -9,6 +9,7 @@ import type { ID } from '@audius/common/models' import { DownloadQuality, ModalSource } from '@audius/common/models' import type { CommonState } from '@audius/common/store' import { + PurchaseableContentType, cacheTracksSelectors, usePremiumContentPurchaseModal, useWaitForDownloadModal @@ -96,7 +97,7 @@ export const DownloadSection = ({ trackId }: { trackId: ID }) => { const handlePurchasePress = useCallback(() => { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackDetails } ) }, [trackId, openPremiumContentPurchaseModal]) diff --git a/packages/mobile/src/utils/routes.tsx b/packages/mobile/src/utils/routes.tsx index 2c1eff9c8a8..3eefac9fda0 100644 --- a/packages/mobile/src/utils/routes.tsx +++ b/packages/mobile/src/utils/routes.tsx @@ -1,4 +1,4 @@ -import type { UserCollection, User } from '@audius/common/models' +import type { User, UserCollectionMetadata } from '@audius/common/models' import { encodeUrlName, getHash } from '@audius/common/utils' import { env } from 'app/env' @@ -21,12 +21,13 @@ export const getUserRoute = (user: UserHandle, fullUrl = false) => { } export const getCollectionRoute = ( - collection: Pick, + collection: Pick, fullUrl = false ) => { const { permalink } = collection + if (!permalink) return '' - return fullUrl ? `${AUDIUS_URL}${permalink}` : permalink || '' + return fullUrl ? `${AUDIUS_URL}${permalink}` : permalink } export const getSearchRoute = (query: string, fullUrl = false) => { diff --git a/packages/web/src/common/store/pages/saved/lineups/sagas.ts b/packages/web/src/common/store/pages/saved/lineups/sagas.ts index e66d70087fd..d39f495abcb 100644 --- a/packages/web/src/common/store/pages/saved/lineups/sagas.ts +++ b/packages/web/src/common/store/pages/saved/lineups/sagas.ts @@ -13,7 +13,7 @@ import { getContext, playerSelectors, purchaseContentActions, - ContentType, + PurchaseableContentType, SavedPageTrack } from '@audius/common/store' import { makeUid } from '@audius/common/utils' @@ -148,7 +148,7 @@ function* watchAddToLibrary() { if ( type === purchaseContentActions.purchaseConfirmed.type && 'payload' in action && - action.payload.contentType !== ContentType.TRACK + action.payload.contentType !== PurchaseableContentType.TRACK ) { return } diff --git a/packages/web/src/components/collection/desktop/CollectionHeader.tsx b/packages/web/src/components/collection/desktop/CollectionHeader.tsx index ba621eab80d..cb50877de7d 100644 --- a/packages/web/src/components/collection/desktop/CollectionHeader.tsx +++ b/packages/web/src/components/collection/desktop/CollectionHeader.tsx @@ -1,6 +1,12 @@ import { ChangeEvent, useCallback, useState } from 'react' -import { useEditPlaylistModal } from '@audius/common/store' +import { useGetCurrentUserId, useGetPurchases } from '@audius/common/api' +import { useAllPaginatedQuery } from '@audius/common/audius-query' +import { USDCContentPurchaseType } from '@audius/common/models' +import { + PurchaseableContentType, + useEditPlaylistModal +} from '@audius/common/store' import { formatSecondsAsText, formatDate } from '@audius/common/utils' import { Text, @@ -15,6 +21,7 @@ import { Input } from 'components/input' import { UserLink } from 'components/link' import RepostFavoritesStats from 'components/repost-favorites-stats/RepostFavoritesStats' import Skeleton from 'components/skeleton/Skeleton' +import { GatedContentSection } from 'components/track/GatedContentSection' import InfoLabel from 'components/track/InfoLabel' import { UserGeneratedText } from 'components/user-generated-text' @@ -32,6 +39,7 @@ type CollectionHeaderProps = any export const CollectionHeader = (props: CollectionHeaderProps) => { const { collectionId, + ownerId, type, title, coverArtSizes, @@ -56,12 +64,32 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { reposts, saves, onClickReposts, - onClickFavorites + onClickFavorites, + isStreamGated, + streamConditions } = props const [artworkLoading, setIsArtworkLoading] = useState(true) const [filterText, setFilterText] = useState('') + const { data: currentUserId } = useGetCurrentUserId({}) + + const { data: purchases, hasMore: hasMorePurchases } = useAllPaginatedQuery( + useGetPurchases, + { + userId: currentUserId + }, + { + pageSize: 100 + } + ) + + const isAlbumPurchased = purchases.some( + (purchaseDetails) => + purchaseDetails.contentType === USDCContentPurchaseType.ALBUM && + purchaseDetails.contentId === collectionId + ) + const handleFilterChange = useCallback( (e: ChangeEvent) => { const newFilterText = e.target.value @@ -105,7 +133,7 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { const TitleComponent = isOwner ? 'button' : 'span' return ( -
+
{
) : null}
- + {isStreamGated && streamConditions ? ( + + ) : null} +
) } diff --git a/packages/web/src/components/locked-content-modal/LockedContentModal.module.css b/packages/web/src/components/locked-content-modal/LockedContentModal.module.css index 9f2aaffc697..e5fba832f90 100644 --- a/packages/web/src/components/locked-content-modal/LockedContentModal.module.css +++ b/packages/web/src/components/locked-content-modal/LockedContentModal.module.css @@ -34,17 +34,17 @@ gap: var(--harmony-unit-6); } -.gatedTrackSectionWrapper { +.gatedContentSectionWrapper { margin: 0; padding: 0; border: none; } -.gatedTrackSection { +.gatedContentSection { flex-direction: column; gap: var(--harmony-unit-6); } -.gatedTrackSectionButton { +.gatedContentSectionButton { width: 100%; } diff --git a/packages/web/src/components/locked-content-modal/LockedContentModal.tsx b/packages/web/src/components/locked-content-modal/LockedContentModal.tsx index 5301afec04c..7571798c16a 100644 --- a/packages/web/src/components/locked-content-modal/LockedContentModal.tsx +++ b/packages/web/src/components/locked-content-modal/LockedContentModal.tsx @@ -1,7 +1,10 @@ import { useCallback } from 'react' import { useGatedContentAccess, useLockedContent } from '@audius/common/hooks' -import { gatedContentActions } from '@audius/common/store' +import { + PurchaseableContentType, + gatedContentActions +} from '@audius/common/store' import { ModalContent, ModalHeader, @@ -12,8 +15,8 @@ import cn from 'classnames' import { useDispatch } from 'react-redux' import { useModalState } from 'common/hooks/useModalState' -import { GatedTrackSection } from 'components/track/GatedTrackSection' -import { LockedTrackDetailsTile } from 'components/track/LockedTrackDetailsTile' +import { GatedContentSection } from 'components/track/GatedContentSection' +import { LockedContentDetailsTile } from 'components/track/LockedContentDetailsTile' import { useIsMobile } from 'hooks/useIsMobile' import ModalDrawer from 'pages/audio-rewards-page/components/modals/ModalDrawer' @@ -61,16 +64,18 @@ export const LockedContentModal = () => { {track && track.stream_conditions && owner && (
- - +
diff --git a/packages/web/src/components/now-playing/NowPlaying.tsx b/packages/web/src/components/now-playing/NowPlaying.tsx index 482d7aa55c0..bafcf735e34 100644 --- a/packages/web/src/components/now-playing/NowPlaying.tsx +++ b/packages/web/src/components/now-playing/NowPlaying.tsx @@ -29,7 +29,8 @@ import { playerSelectors, playbackRateValueMap, gatedContentSelectors, - OverflowActionCallbacks + OverflowActionCallbacks, + PurchaseableContentType } from '@audius/common/store' import { Genre } from '@audius/common/utils' import { IconCaretRight as IconCaret, Scrubber } from '@audius/harmony' @@ -79,7 +80,7 @@ const { saveTrack, unsaveTrack, repostTrack, undoRepostTrack } = const { next, pause, play, previous, repeat, shuffle } = queueActions const getDominantColorsByTrack = averageColorSelectors.getDominantColorsByTrack const getUserId = accountSelectors.getUserId -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors type OwnProps = { onClose: () => void @@ -404,7 +405,7 @@ const NowPlaying = g( const matrix = isMatrix() const darkMode = isDarkMode() - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const gatedTrackStatus = track_id && gatedTrackStatusMap[typeof track_id === 'number' ? track_id : -1] @@ -413,7 +414,8 @@ const NowPlaying = g( const onClickPill = useAuthenticatedClickCallback(() => { openPremiumContentPurchaseModal( { - contentId: typeof track_id === 'number' ? track_id : -1 + contentId: typeof track_id === 'number' ? track_id : -1, + contentType: PurchaseableContentType.TRACK }, { source: ModalSource.NowPlaying } ) diff --git a/packages/web/src/components/play-bar/desktop/components/SocialActions.tsx b/packages/web/src/components/play-bar/desktop/components/SocialActions.tsx index 25e9cf49472..220c1997453 100644 --- a/packages/web/src/components/play-bar/desktop/components/SocialActions.tsx +++ b/packages/web/src/components/play-bar/desktop/components/SocialActions.tsx @@ -5,7 +5,8 @@ import { themeSelectors, usePremiumContentPurchaseModal, gatedContentSelectors, - CommonState + CommonState, + PurchaseableContentType } from '@audius/common/store' import { useSelector } from 'react-redux' @@ -19,7 +20,7 @@ import { shouldShowDark } from 'utils/theme/theme' import styles from './SocialActions.module.css' const { getTheme } = themeSelectors -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors const { getTrack } = cacheTracksSelectors type SocialActionsProps = { @@ -53,13 +54,13 @@ export const SocialActions = ({ const favoriteText = favorited ? messages.unfavorite : messages.favorite const repostText = reposted ? messages.reposted : messages.repost - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const gatedTrackStatus = trackId && gatedTrackStatusMap[trackId] const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() const onClickPill = useAuthenticatedClickCallback(() => { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.PlayBar } ) }, [trackId, openPremiumContentPurchaseModal]) diff --git a/packages/web/src/components/premium-content-purchase-modal/PremiumContentPurchaseModal.tsx b/packages/web/src/components/premium-content-purchase-modal/PremiumContentPurchaseModal.tsx index 977b824088f..da79b9e2af3 100644 --- a/packages/web/src/components/premium-content-purchase-modal/PremiumContentPurchaseModal.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/PremiumContentPurchaseModal.tsx @@ -1,19 +1,24 @@ import { useCallback, useEffect } from 'react' -import { useGetTrackById } from '@audius/common/api' +import { + useGetCurrentUserId, + useGetPlaylistById, + useGetTrackById, + useGetUserById +} from '@audius/common/api' import { useFeatureFlag, usePurchaseContentFormConfiguration, usePayExtraPresets, - isTrackStreamPurchaseable, + isStreamPurchaseable, isTrackDownloadPurchaseable, - PurchaseableTrackMetadata, - PURCHASE_METHOD + PURCHASE_METHOD, + PurchaseableContentMetadata } from '@audius/common/hooks' import { + ID, PurchaseMethod, PurchaseVendor, - Track, USDCPurchaseConditions } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' @@ -24,7 +29,8 @@ import { purchaseContentSelectors, PurchaseContentStage, PurchaseContentPage, - isContentPurchaseInProgress + isContentPurchaseInProgress, + PurchaseableContentType } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { @@ -42,7 +48,7 @@ import { toFormikValidationSchema } from 'zod-formik-adapter' import { useHistoryContext } from 'app/HistoryProvider' import { ModalForm } from 'components/modal-form/ModalForm' -import { LockedTrackDetailsTile } from 'components/track/LockedTrackDetailsTile' +import { LockedContentDetailsTile } from 'components/track/LockedContentDetailsTile' import { USDCManualTransfer } from 'components/usdc-manual-transfer/USDCManualTransfer' import { useIsMobile } from 'hooks/useIsMobile' import { useIsUSDCEnabled } from 'hooks/useIsUSDCEnabled' @@ -80,16 +86,18 @@ const pageToPageIndex = (page: PurchaseContentPage) => { // of the `` component const RenderForm = ({ onClose, - track, - purchaseConditions + metadata, + purchaseConditions, + contentId }: { onClose: () => void - track: PurchaseableTrackMetadata + metadata: PurchaseableContentMetadata purchaseConditions: USDCPurchaseConditions + contentId: ID }) => { const dispatch = useDispatch() const isMobile = useIsMobile() - const { permalink } = track + const { permalink } = metadata const { usdc_purchase: { price } } = purchaseConditions @@ -102,7 +110,7 @@ const RenderForm = ({ const { history } = useHistoryContext() // Reset form on track change - useEffect(() => resetForm, [track.track_id, resetForm]) + useEffect(() => resetForm, [contentId, resetForm]) // Navigate to track on successful purchase behind the modal useEffect(() => { @@ -144,17 +152,17 @@ const RenderForm = ({ ) : null} - @@ -170,10 +178,10 @@ const RenderForm = ({ ) : null} @@ -188,7 +196,7 @@ export const PremiumContentPurchaseModal = () => { isOpen, onClose, onClosed, - data: { contentId: trackId } + data: { contentId, contentType } } = usePremiumContentPurchaseModal() const { isEnabled: isCoinflowEnabled, isLoaded: isCoinflowEnabledLoaded } = useFeatureFlag(FeatureFlags.BUY_WITH_COINFLOW) @@ -197,27 +205,46 @@ export const PremiumContentPurchaseModal = () => { const error = useSelector(getPurchaseContentError) const isUnlocking = !error && isContentPurchaseInProgress(stage) const presetValues = usePayExtraPresets() + const { data: currentUserId } = useGetCurrentUserId({}) + const isAlbum = contentType === PurchaseableContentType.ALBUM const { data: track } = useGetTrackById( - { id: trackId! }, - { disabled: !trackId } + { id: contentId! }, + { disabled: isAlbum || !contentId } + ) + + const { data: album } = useGetPlaylistById( + { playlistId: contentId!, currentUserId }, + { disabled: !isAlbum || !contentId } + ) + + const { data: user } = useGetUserById( + { + id: track?.owner_id ?? album?.playlist_owner_id, + currentUserId + }, + { disabled: !(track?.owner_id ?? album?.playlist_owner_id) } ) + const metadata = { + ...(isAlbum ? album : track), + user + } as PurchaseableContentMetadata - const isValidStreamGatedTrack = !!track && isTrackStreamPurchaseable(track) - const isValidDownloadGatedTrack = - !!track && isTrackDownloadPurchaseable(track) + const isValidStreamGated = !!metadata && isStreamPurchaseable(metadata) + const isValidDownloadGated = + !!metadata && isTrackDownloadPurchaseable(metadata) - const purchaseConditions = isValidStreamGatedTrack - ? track.stream_conditions - : isValidDownloadGatedTrack - ? track.download_conditions + const purchaseConditions = isValidStreamGated + ? metadata.stream_conditions + : isValidDownloadGated + ? metadata.download_conditions : null const price = purchaseConditions ? purchaseConditions?.usdc_purchase.price : 0 const { initialValues, validationSchema, onSubmit } = usePurchaseContentFormConfiguration({ - track, + metadata, price, presetValues, purchaseVendor: isCoinflowEnabled @@ -244,9 +271,9 @@ export const PremiumContentPurchaseModal = () => { }, [onClosed, dispatch]) if ( - !track || + !metadata || !purchaseConditions || - !(isValidDownloadGatedTrack || isValidStreamGatedTrack) + !(isValidDownloadGated || isValidStreamGated) ) { console.error('PremiumContentPurchaseModal: Track is not purchasable') return null @@ -271,7 +298,8 @@ export const PremiumContentPurchaseModal = () => { onSubmit={onSubmit} > diff --git a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx index 3193e053770..a6287522e38 100644 --- a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx @@ -8,7 +8,8 @@ import { PURCHASE_METHOD, PURCHASE_VENDOR, usePurchaseMethod, - PurchaseableTrackMetadata + PurchaseableContentMetadata, + isPurchaseableAlbum } from '@audius/common/hooks' import { PurchaseMethod, PurchaseVendor } from '@audius/common/models' import { IntKeys, FeatureFlags } from '@audius/common/services' @@ -34,7 +35,7 @@ type PurchaseContentFormFieldsProps = Pick< 'purchaseSummaryValues' | 'stage' | 'isUnlocking' > & { price: number - track: PurchaseableTrackMetadata + metadata: PurchaseableContentMetadata } export const PurchaseContentFormFields = ({ @@ -42,7 +43,7 @@ export const PurchaseContentFormFields = ({ purchaseSummaryValues, stage, isUnlocking, - track + metadata }: PurchaseContentFormFieldsProps) => { const payExtraAmountPresetValues = usePayExtraPresets() const coinflowMaximumCents = useRemoteVar(IntKeys.COINFLOW_MAXIMUM_CENTS) @@ -101,12 +102,18 @@ export const PurchaseContentFormFields = ({ ) } - const stemsPurchaseCount = track.is_download_gated - ? track._stems?.length ?? 0 - : 0 + const isAlbumPurchase = isPurchaseableAlbum(metadata) + const stemsPurchaseCount = + 'is_download_gated' in metadata && metadata.is_download_gated + ? metadata._stems?.length ?? 0 + : 0 const downloadPurchaseCount = - track.is_download_gated && track.is_downloadable ? 1 : 0 - const streamPurchaseCount = track.is_stream_gated ? 1 : 0 + 'is_download_gated' in metadata && + metadata.is_download_gated && + metadata.is_downloadable + ? 1 + : 0 + const streamPurchaseCount = metadata.is_stream_gated ? 1 : 0 return ( <> @@ -122,6 +129,7 @@ export const PurchaseContentFormFields = ({ downloadPurchaseCount={downloadPurchaseCount} streamPurchaseCount={streamPurchaseCount} totalPriceInCents={totalPriceInCents} + isAlbumPurchase={isAlbumPurchase} /> {isUnlocking || isPurchased ? null : ( + `View ${capitalize(contentType)}`, purchasing: 'Purchasing', shareButtonContent: 'I just purchased a track on Audius!', - shareTwitterText: (trackTitle: string, handle: string) => - `I bought the track ${trackTitle} by ${handle} on @Audius! #AudiusPremium`, + shareTwitterText: (contentType: string, title: string, handle: string) => + `I bought the ${contentType} ${title} by ${handle} on @Audius! #AudiusPremium`, reposted: 'Reposted', repost: 'Repost' } @@ -65,47 +69,60 @@ type PurchaseContentFormFooterProps = Pick< PurchaseContentFormState, 'error' | 'isUnlocking' | 'purchaseSummaryValues' | 'stage' > & { - track: PurchaseableTrackMetadata - onViewTrackClicked: () => void + metadata: PurchaseableContentMetadata + onViewContentClicked: () => void } export const PurchaseContentFormFooter = ({ error, - track, + metadata, isUnlocking, purchaseSummaryValues, stage, - onViewTrackClicked + onViewContentClicked: onViewTrackClicked }: PurchaseContentFormFooterProps) => { const { - title, permalink, user: { handle }, - has_current_user_reposted: isReposted, - track_id: trackId - } = track + has_current_user_reposted: isReposted + } = metadata + const contentId = + 'track_id' in metadata ? metadata.track_id : metadata.playlist_id + const title = 'title' in metadata ? metadata.title : metadata.playlist_name + const isAlbum = isPurchaseableAlbum(metadata) const dispatch = useDispatch() const isPurchased = stage === PurchaseContentStage.FINISH const { totalPrice } = purchaseSummaryValues const handleTwitterShare = useCallback( (handle: string) => { - const shareText = messages.shareTwitterText(title, handle) + const shareText = messages.shareTwitterText( + isAlbum ? 'album' : 'track', + title, + handle + ) const analytics = make(Name.PURCHASE_CONTENT_TWITTER_SHARE, { text: shareText }) return { shareText, analytics } }, - [title] + [title, isAlbum] ) const onRepost = useCallback(() => { dispatch( isReposted - ? tracksSocialActions.undoRepostTrack(trackId, RepostSource.PURCHASE) - : tracksSocialActions.repostTrack(trackId, RepostSource.PURCHASE) + ? (isAlbum + ? collectionsSocialActions.undoRepostCollection + : tracksSocialActions.undoRepostTrack)( + contentId, + RepostSource.PURCHASE + ) + : (isAlbum + ? collectionsSocialActions.repostCollection + : tracksSocialActions.repostTrack)(contentId, RepostSource.PURCHASE) ) - }, [trackId, dispatch, isReposted]) + }, [contentId, dispatch, isAlbum, isReposted]) if (isPurchased) { return ( @@ -121,13 +138,19 @@ export const PurchaseContentFormFooter = ({ > {isReposted ? messages.reposted : messages.repost} - + {permalink ? ( + + ) : null} - {messages.viewTrack} + {messages.viewContent(isAlbum ? 'album' : 'track')} ) diff --git a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseSummaryTable.tsx b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseSummaryTable.tsx index 4e4c50a184f..b32fd9d62a4 100644 --- a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseSummaryTable.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseSummaryTable.tsx @@ -5,6 +5,7 @@ import { SummaryTable, SummaryTableItem } from 'components/summary-table' const messages = { summary: 'Transaction Summary', + premiumAlbum: 'Premium Album', premiumTrack: 'Premium Track', downloadableFiles: 'Downloadable Files', payExtra: 'Pay Extra', @@ -27,6 +28,8 @@ type PurchaseSummaryTableProps = { downloadPurchaseCount?: number // How many stems are available for purchase stemsPurchaseCount?: number + // Whether this is an album purchase + isAlbumPurchase?: boolean } export const PurchaseSummaryTable = ({ @@ -35,15 +38,24 @@ export const PurchaseSummaryTable = ({ extraAmount, streamPurchaseCount, downloadPurchaseCount, - stemsPurchaseCount + stemsPurchaseCount, + isAlbumPurchase }: PurchaseSummaryTableProps) => { const items: SummaryTableItem[] = [] if (streamPurchaseCount) { - items.push({ - id: 'premiumTrack', - label: messages.premiumTrack, - value: messages.price(formatPrice(basePrice)) - }) + if (isAlbumPurchase) { + items.push({ + id: 'premiumAlbum', + label: messages.premiumAlbum, + value: messages.price(formatPrice(basePrice)) + }) + } else { + items.push({ + id: 'premiumTrack', + label: messages.premiumTrack, + value: messages.price(formatPrice(basePrice)) + }) + } } const downloadCount = (stemsPurchaseCount ?? 0) + (downloadPurchaseCount ?? 0) if (downloadCount > 0) { diff --git a/packages/web/src/components/track/DownloadSection.tsx b/packages/web/src/components/track/DownloadSection.tsx index ae40db300fc..dd1d3671b74 100644 --- a/packages/web/src/components/track/DownloadSection.tsx +++ b/packages/web/src/components/track/DownloadSection.tsx @@ -19,7 +19,8 @@ import { usePremiumContentPurchaseModal, CommonState, useWaitForDownloadModal, - toastActions + toastActions, + PurchaseableContentType } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { @@ -122,7 +123,7 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { setLockedContentModalVisibility(false) } openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackDetails } ) }, []) diff --git a/packages/web/src/components/track/GatedTrackSection.tsx b/packages/web/src/components/track/GatedContentSection.tsx similarity index 84% rename from packages/web/src/components/track/GatedTrackSection.tsx rename to packages/web/src/components/track/GatedContentSection.tsx index 51c38694902..35d9aeaf17f 100644 --- a/packages/web/src/components/track/GatedTrackSection.tsx +++ b/packages/web/src/components/track/GatedContentSection.tsx @@ -17,7 +17,8 @@ import { usersSocialActions as socialActions, tippingActions, usePremiumContentPurchaseModal, - gatedContentSelectors + gatedContentSelectors, + PurchaseableContentType } from '@audius/common/store' import { formatPrice, removeNullable, Nullable } from '@audius/common/utils' import { @@ -53,9 +54,9 @@ import { LockedStatusBadge } from './LockedStatusBadge' const { getUsers } = cacheUsersSelectors const { beginTip } = tippingActions -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors -const messages = { +const getMessages = (contentType: PurchaseableContentType) => ({ howToUnlock: 'HOW TO UNLOCK', payToUnlock: 'PAY TO UNLOCK', purchasing: 'PURCHASING', @@ -70,32 +71,30 @@ const messages = { exclamationMark: '!', ownCollectibleGatedPrefix: 'Users can unlock access by linking a wallet containing a collectible from ', - unlockCollectibleGatedTrack: - 'To unlock this track, you must link a wallet containing a collectible from ', + unlockCollectibleGatedContent: `To unlock this ${contentType}, you must link a wallet containing a collectible from `, aCollectibleFrom: 'A Collectible from ', - unlockingCollectibleGatedTrackSuffix: 'was found in a linked wallet.', - unlockedCollectibleGatedTrackSuffix: - 'was found in a linked wallet. This track is now available.', + unlockingCollectibleGatedContentSuffix: 'was found in a linked wallet.', + unlockedCollectibleGatedContentSuffix: `was found in a linked wallet. This ${contentType} is now available.`, ownFollowGated: 'Users can unlock access by following your account!', - unlockFollowGatedTrackPrefix: 'Follow', + unlockFollowGatedContentPrefix: 'Follow', thankYouForFollowing: 'Thank you for following', - unlockedFollowGatedTrackSuffix: '! This track is now available.', + unlockedFollowGatedContentSuffix: `! This ${contentType} is now available.`, ownTipGated: 'Users can unlock access by sending you a tip!', - unlockTipGatedTrackPrefix: 'Send', - unlockTipGatedTrackSuffix: 'a tip.', + unlockTipGatedContentPrefix: 'Send', + unlockTipGatedContentSuffix: 'a tip.', thankYouForSupporting: 'Thank you for supporting', - unlockingTipGatedTrackSuffix: 'by sending them a tip!', - unlockedTipGatedTrackSuffix: - 'by sending them a tip! This track is now available.', - unlockWithPurchase: 'Unlock this track with a one-time purchase!', - purchased: "You've purchased this track.", + unlockingTipGatedContentSuffix: 'by sending them a tip!', + unlockedTipGatedContentSuffix: `by sending them a tip! This ${contentType} is now available.`, + unlockWithPurchase: `Unlock this ${contentType} with a one-time purchase!`, + purchased: `You've purchased this ${contentType}.`, buy: (price: string) => `Buy $${price}`, usersCanPurchase: (price: string) => - `Users can unlock access to this track for a one time purchase of $${price}` -} + `Users can unlock access to this ${contentType} for a one time purchase of $${price}` +}) -type GatedTrackAccessSectionProps = { - trackId: ID +type GatedContentAccessSectionProps = { + contentId: ID + contentType: PurchaseableContentType trackOwner: Nullable streamConditions: AccessConditions followee: Nullable @@ -107,8 +106,9 @@ type GatedTrackAccessSectionProps = { buttonClassName?: string } -const LockedGatedTrackSection = ({ - trackId, +const LockedGatedContentSection = ({ + contentId, + contentType, streamConditions, followee, tippedUser, @@ -116,7 +116,8 @@ const LockedGatedTrackSection = ({ renderArtist, className, buttonClassName -}: GatedTrackAccessSectionProps) => { +}: GatedContentAccessSectionProps) => { + const messages = getMessages(contentType) const dispatch = useDispatch() const [lockedContentModalVisibility, setLockedContentModalVisibility] = useModalState('LockedContent') @@ -136,18 +137,19 @@ const LockedGatedTrackSection = ({ setLockedContentModalVisibility(false) } openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId, contentType }, { source: ModalSource.TrackDetails } ) }, [ - trackId, + contentId, + contentType, lockedContentModalVisibility, openPremiumContentPurchaseModal, setLockedContentModalVisibility ]) const handleSendTip = useAuthenticatedCallback(() => { - dispatch(beginTip({ user: tippedUser, source, trackId })) + dispatch(beginTip({ user: tippedUser, source, trackId: contentId })) if (lockedContentModalVisibility) { setLockedContentModalVisibility(false) @@ -156,7 +158,7 @@ const LockedGatedTrackSection = ({ dispatch, tippedUser, source, - trackId, + contentId, lockedContentModalVisibility, setLockedContentModalVisibility ]) @@ -167,7 +169,7 @@ const LockedGatedTrackSection = ({ socialActions.followUser( streamConditions.follow_user_id, followSource, - trackId + contentId ) ) } @@ -179,7 +181,7 @@ const LockedGatedTrackSection = ({ dispatch, streamConditions, followSource, - trackId, + contentId, lockedContentModalVisibility, setLockedContentModalVisibility ]) @@ -193,7 +195,7 @@ const LockedGatedTrackSection = ({ return (
- {messages.unlockCollectibleGatedTrack} + {messages.unlockCollectibleGatedContent}
- {messages.unlockFollowGatedTrackPrefix}  + {messages.unlockFollowGatedContentPrefix}  {messages.period} @@ -228,9 +230,9 @@ const LockedGatedTrackSection = ({ if (isContentTipGated(streamConditions) && tippedUser) { return ( - {messages.unlockTipGatedTrackPrefix}  + {messages.unlockTipGatedContentPrefix}  - {messages.unlockTipGatedTrackSuffix} + {messages.unlockTipGatedContentSuffix} ) } @@ -332,14 +334,16 @@ const LockedGatedTrackSection = ({ ) } -const UnlockingGatedTrackSection = ({ +const UnlockingGatedContentSection = ({ + contentType, streamConditions, followee, tippedUser, goToCollection, renderArtist, className -}: GatedTrackAccessSectionProps) => { +}: GatedContentAccessSectionProps) => { + const messages = getMessages(contentType) const renderUnlockingDescription = () => { if (isContentCollectibleGated(streamConditions)) { return ( @@ -348,7 +352,7 @@ const UnlockingGatedTrackSection = ({  {streamConditions.nft_collection?.name}  - {messages.unlockingCollectibleGatedTrackSuffix} + {messages.unlockingCollectibleGatedContentSuffix}
) } @@ -369,7 +373,7 @@ const UnlockingGatedTrackSection = ({ {messages.thankYouForSupporting}  {renderArtist(tippedUser)} - {messages.unlockingTipGatedTrackSuffix} + {messages.unlockingTipGatedContentSuffix} ) @@ -406,7 +410,8 @@ const UnlockingGatedTrackSection = ({ ) } -const UnlockedGatedTrackSection = ({ +const UnlockedGatedContentSection = ({ + contentType, streamConditions, followee, tippedUser, @@ -415,7 +420,8 @@ const UnlockedGatedTrackSection = ({ isOwner, trackOwner, className -}: GatedTrackAccessSectionProps) => { +}: GatedContentAccessSectionProps) => { + const messages = getMessages(contentType) const renderUnlockedDescription = () => { if (isContentCollectibleGated(streamConditions)) { return isOwner ? ( @@ -432,7 +438,7 @@ const UnlockedGatedTrackSection = ({ {streamConditions.nft_collection?.name}   - {messages.unlockedCollectibleGatedTrackSuffix} + {messages.unlockedCollectibleGatedContentSuffix} ) } @@ -444,7 +450,7 @@ const UnlockedGatedTrackSection = ({ <> {messages.thankYouForFollowing}  - {messages.unlockedFollowGatedTrackSuffix} + {messages.unlockedFollowGatedContentSuffix} ) } @@ -456,7 +462,7 @@ const UnlockedGatedTrackSection = ({ <> {messages.thankYouForSupporting}  - {messages.unlockedTipGatedTrackSuffix} + {messages.unlockedTipGatedContentSuffix} ) } @@ -471,18 +477,18 @@ const UnlockedGatedTrackSection = ({ ) : ( -
+ {messages.purchased}  {trackOwner ? ( <> - + {messages.thankYouForSupporting}  {renderArtist(trackOwner)} {messages.period} - + ) : null} -
+ ) } @@ -536,9 +542,10 @@ const UnlockedGatedTrackSection = ({ ) } -type GatedTrackSectionProps = { +type GatedContentSectionProps = { isLoading: boolean - trackId: ID + contentId: ID + contentType: PurchaseableContentType streamConditions: AccessConditions hasStreamAccess: boolean isOwner: boolean @@ -548,9 +555,10 @@ type GatedTrackSectionProps = { ownerId: ID } -export const GatedTrackSection = ({ +export const GatedContentSection = ({ isLoading, - trackId, + contentId, + contentType = PurchaseableContentType.TRACK, streamConditions, hasStreamAccess, isOwner, @@ -558,10 +566,10 @@ export const GatedTrackSection = ({ className, buttonClassName, ownerId -}: GatedTrackSectionProps) => { +}: GatedContentSectionProps) => { const dispatch = useDispatch() - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) - const gatedTrackStatus = gatedTrackStatusMap[trackId] ?? null + const gatedContentStatusMap = useSelector(getGatedContentStatusMap) + const gatedContentStatus = gatedContentStatusMap[contentId] ?? null const isFollowGated = isContentFollowGated(streamConditions) const isTipGated = isContentTipGated(streamConditions) @@ -615,7 +623,7 @@ export const GatedTrackSection = ({ component='span' >

dispatch(pushRoute(profilePage(emptyStringGuard(entity.handle)))) } @@ -640,8 +648,9 @@ export const GatedTrackSection = ({ if (hasStreamAccess) { return (
- - - {isStreamGated && streamConditions ? ( - + + + ) : null} diff --git a/packages/web/src/components/track/LockedTrackDetailsTile.module.css b/packages/web/src/components/track/LockedContentDetailsTile.module.css similarity index 90% rename from packages/web/src/components/track/LockedTrackDetailsTile.module.css rename to packages/web/src/components/track/LockedContentDetailsTile.module.css index 1e8649e60fd..d2b2e188143 100644 --- a/packages/web/src/components/track/LockedTrackDetailsTile.module.css +++ b/packages/web/src/components/track/LockedContentDetailsTile.module.css @@ -1,4 +1,4 @@ -.trackDetails { +.details { position: relative; display: flex; align-items: center; @@ -31,23 +31,23 @@ left: -1px; } -.trackTitle { +.title { margin-bottom: var(--harmony-unit-2); font-weight: var(--harmony-font-bold); font-size: var(--harmony-font-2xl); text-transform: capitalize; } -.trackImageWrapper { +.imageWrapper { width: 124px; height: 124px; } -.trackImage { +.image { border-radius: 4px; } -.trackTextWrapper { +.textWrapper { flex: 1; } @@ -56,12 +56,12 @@ color: var(--harmony-n-400); } -.trackOwner { +.owner { display: flex; align-items: center; } -.trackOwnerName { +.ownerName { margin: 0 var(--harmony-unit-1); color: var(--harmony-secondary); } diff --git a/packages/web/src/components/track/LockedTrackDetailsTile.tsx b/packages/web/src/components/track/LockedContentDetailsTile.tsx similarity index 58% rename from packages/web/src/components/track/LockedTrackDetailsTile.tsx rename to packages/web/src/components/track/LockedContentDetailsTile.tsx index ff4d23c1c76..96df64db41b 100644 --- a/packages/web/src/components/track/LockedTrackDetailsTile.tsx +++ b/packages/web/src/components/track/LockedContentDetailsTile.tsx @@ -1,10 +1,11 @@ +import { PurchaseableContentMetadata } from '@audius/common/hooks' import { SquareSizes, isContentCollectibleGated, isContentUSDCPurchaseGated, - ID, Track, - UserMetadata + UserMetadata, + Collection } from '@audius/common/models' import { getDogEarType, Nullable } from '@audius/common/utils' import { @@ -19,42 +20,47 @@ import cn from 'classnames' import { DogEar } from 'components/dog-ear' import DynamicImage from 'components/dynamic-image/DynamicImage' import { UserLink } from 'components/link' +import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' -import styles from './LockedTrackDetailsTile.module.css' +import styles from './LockedContentDetailsTile.module.css' const messages = { collectibleGated: 'COLLECTIBLE GATED', specialAccess: 'SPECIAL ACCESS', - premiumTrack: 'PREMIUM TRACK' + premiumTrack: (contentType: 'track' | 'album') => + `PREMIUM ${contentType.toUpperCase()}` } -export type LockedTrackDetailsTileProps = { - trackId: ID +export type LockedContentDetailsTileProps = { + metadata: PurchaseableContentMetadata | Track | Collection + owner: UserMetadata + showLabel?: boolean } -export const LockedTrackDetailsTile = ({ - track, +export const LockedContentDetailsTile = ({ + metadata, owner, showLabel = true -}: { - track: Track - owner: UserMetadata - showLabel?: boolean -}) => { - const { - track_id: trackId, - title, - stream_conditions: streamConditions, - download_conditions: downloadConditions, - is_download_gated: isDownloadGated - } = track - const image = useTrackCoverArt( - trackId, - track._cover_art_sizes ?? null, - SquareSizes.SIZE_150_BY_150, - '' +}: LockedContentDetailsTileProps) => { + const { stream_conditions: streamConditions } = metadata + const isAlbum = 'playlist_id' in metadata + const contentId = isAlbum ? metadata.playlist_id : metadata.track_id + const title = isAlbum ? metadata.playlist_name : metadata.title + const downloadConditions = !isAlbum ? metadata.download_conditions : null + const isDownloadGated = !isAlbum && metadata.is_download_gated + + const trackArt = useTrackCoverArt( + contentId, + metadata._cover_art_sizes ?? null, + SquareSizes.SIZE_150_BY_150 + ) + const albumArt = useCollectionCoverArt( + contentId, + metadata._cover_art_sizes ?? null, + SquareSizes.SIZE_150_BY_150 ) + const image = isAlbum ? albumArt : trackArt const dogEarType = getDogEarType({ streamConditions, @@ -73,7 +79,7 @@ export const LockedTrackDetailsTile = ({ message = messages.collectibleGated } else if (isUSDCPurchaseGated) { IconComponent = IconCart - message = messages.premiumTrack + message = messages.premiumTrack(isAlbum ? 'album' : 'track') } else if (isDownloadGated) { IconComponent = null message = null @@ -83,10 +89,10 @@ export const LockedTrackDetailsTile = ({ } return ( -
+
@@ -95,7 +101,7 @@ export const LockedTrackDetailsTile = ({
) : null} -
+
{showLabel && IconComponent && message ? (
{message}
) : null} -

{title}

-
+

{title}

+
By - +
diff --git a/packages/web/src/components/track/desktop/BottomRow.tsx b/packages/web/src/components/track/desktop/BottomRow.tsx index 1797a6391df..611ddc4ec71 100644 --- a/packages/web/src/components/track/desktop/BottomRow.tsx +++ b/packages/web/src/components/track/desktop/BottomRow.tsx @@ -16,7 +16,7 @@ import { GatedConditionsPill } from '../GatedConditionsPill' import styles from './TrackTile.module.css' -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors const messages = { repostLabel: 'Repost', @@ -68,7 +68,7 @@ export const BottomRow = ({ onClickShare, onClickPill }: BottomRowProps) => { - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const gatedTrackStatus = trackId && gatedTrackStatusMap[trackId] const repostLabel = isReposted ? messages.unrepostLabel : messages.repostLabel diff --git a/packages/web/src/components/track/desktop/TrackTile.tsx b/packages/web/src/components/track/desktop/TrackTile.tsx index 2e213976948..474c681475f 100644 --- a/packages/web/src/components/track/desktop/TrackTile.tsx +++ b/packages/web/src/components/track/desktop/TrackTile.tsx @@ -6,7 +6,8 @@ import { accountSelectors, usePremiumContentPurchaseModal, playbackPositionSelectors, - CommonState + CommonState, + PurchaseableContentType } from '@audius/common/store' import { formatCount, @@ -184,7 +185,7 @@ const TrackTile = ({ const onClickPill = useAuthenticatedClickCallback(() => { if (isPurchase && trackId) { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackTile } ) } else if (trackId && !hasStreamAccess && onClickLocked) { diff --git a/packages/web/src/components/track/mobile/BottomButtons.tsx b/packages/web/src/components/track/mobile/BottomButtons.tsx index 41c40e97d74..4c75ad0a442 100644 --- a/packages/web/src/components/track/mobile/BottomButtons.tsx +++ b/packages/web/src/components/track/mobile/BottomButtons.tsx @@ -3,7 +3,7 @@ import { MouseEvent, memo } from 'react' import { isContentUSDCPurchaseGated, AccessConditions, - GatedTrackStatus + GatedContentStatus } from '@audius/common/models' import { Nullable } from '@audius/common/utils' import { Text } from '@audius/harmony' @@ -35,7 +35,7 @@ type BottomButtonsProps = { hasStreamAccess?: boolean readonly?: boolean streamConditions?: Nullable - gatedTrackStatus?: GatedTrackStatus + gatedTrackStatus?: GatedContentStatus isMatrixMode: boolean } diff --git a/packages/web/src/components/track/mobile/TrackTile.tsx b/packages/web/src/components/track/mobile/TrackTile.tsx index cb7087989df..bba9e796a01 100644 --- a/packages/web/src/components/track/mobile/TrackTile.tsx +++ b/packages/web/src/components/track/mobile/TrackTile.tsx @@ -9,7 +9,8 @@ import { import { usePremiumContentPurchaseModal, gatedContentActions, - gatedContentSelectors + gatedContentSelectors, + PurchaseableContentType } from '@audius/common/store' import { formatCount, @@ -49,7 +50,7 @@ import styles from './TrackTile.module.css' import TrackTileArt from './TrackTileArt' const { setLockedContentId } = gatedContentActions -const { getGatedTrackStatusMap } = gatedContentSelectors +const { getGatedContentStatusMap } = gatedContentSelectors type ExtraProps = { permalink: string @@ -193,7 +194,7 @@ const TrackTile = (props: CombinedProps) => { const [, setModalVisibility] = useModalState('LockedContent') const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const gatedTrackStatusMap = useSelector(getGatedTrackStatusMap) + const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) const trackId = isStreamGated ? id : null const gatedTrackStatus = trackId ? gatedTrackStatusMap[trackId] : undefined const isPurchase = isContentUSDCPurchaseGated(streamConditions) @@ -229,7 +230,7 @@ const TrackTile = (props: CombinedProps) => { const onClickPill = useAuthenticatedClickCallback(() => { if (isPurchase && trackId) { openPremiumContentPurchaseModal( - { contentId: trackId }, + { contentId: trackId, contentType: PurchaseableContentType.TRACK }, { source: ModalSource.TrackTile } ) } else if (trackId && !hasStreamAccess) { diff --git a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx index 0728f833a71..8f16f609a73 100644 --- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx +++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx @@ -217,6 +217,17 @@ const CollectionPage = ({ gradient={gradient} icon={icon} imageOverride={imageOverride} + ownerId={playlistOwnerId} + isStreamGated={ + metadata?.variant === Variant.USER_GENERATED + ? metadata?.is_stream_gated + : null + } + streamConditions={ + metadata?.variant === Variant.USER_GENERATED + ? metadata?.stream_conditions + : null + } /> ) diff --git a/packages/web/src/pages/settings-page/SettingsPageProvider.tsx b/packages/web/src/pages/settings-page/SettingsPageProvider.tsx index db839086e7c..0371e263ddb 100644 --- a/packages/web/src/pages/settings-page/SettingsPageProvider.tsx +++ b/packages/web/src/pages/settings-page/SettingsPageProvider.tsx @@ -10,7 +10,7 @@ import { PushNotificationSetting, EmailFrequency, signOutActions, - makeGetTierAndVerifiedForUser, + getTierAndVerifiedForUser, themeActions, themeSelectors, modalsActions, @@ -228,8 +228,6 @@ class SettingsPage extends PureComponent< } function makeMapStateToProps() { - const getTier = makeGetTierAndVerifiedForUser() - return (state: AppState) => { const userId = getUserId(state) ?? 0 return { @@ -243,7 +241,7 @@ function makeMapStateToProps() { emailFrequency: getEmailFrequency(state), notificationSettings: getBrowserNotificationSettings(state), pushNotificationSettings: getPushNotificationSettings(state), - tier: getTier(state, { userId }).tier + tier: getTierAndVerifiedForUser(state, { userId }).tier } } } diff --git a/packages/web/src/pages/track-page/components/mobile/TrackHeader.module.css b/packages/web/src/pages/track-page/components/mobile/TrackHeader.module.css index 74a26354b97..c75bb01e3c0 100644 --- a/packages/web/src/pages/track-page/components/mobile/TrackHeader.module.css +++ b/packages/web/src/pages/track-page/components/mobile/TrackHeader.module.css @@ -213,18 +213,18 @@ fill: var(--harmony-text-subdued); } -.gatedTrackSectionWrapper { +.gatedContentSectionWrapper { margin: 0; background: var(--harmony-n-25); width: 100%; } -.gatedTrackSection { +.gatedContentSection { flex-direction: column; gap: var(--harmony-unit-6); } -.gatedTrackSectionButton { +.gatedContentSectionButton { width: 100%; } diff --git a/packages/web/src/pages/track-page/components/mobile/TrackHeader.tsx b/packages/web/src/pages/track-page/components/mobile/TrackHeader.tsx index b5461179256..95e4ba550f3 100644 --- a/packages/web/src/pages/track-page/components/mobile/TrackHeader.tsx +++ b/packages/web/src/pages/track-page/components/mobile/TrackHeader.tsx @@ -15,6 +15,7 @@ import { FeatureFlags } from '@audius/common/services' import { CommonState, OverflowAction, + PurchaseableContentType, cacheTracksSelectors } from '@audius/common/store' import { @@ -49,7 +50,7 @@ import { StaticImage } from 'components/static-image/StaticImage' import { AiTrackSection } from 'components/track/AiTrackSection' import Badge from 'components/track/Badge' import { DownloadSection } from 'components/track/DownloadSection' -import { GatedTrackSection } from 'components/track/GatedTrackSection' +import { GatedContentSection } from 'components/track/GatedContentSection' import { UserGeneratedText } from 'components/user-generated-text' import { useFlag } from 'hooks/useRemoteConfig' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' @@ -418,17 +419,20 @@ const TrackHeader = ({ /> ) : null} {streamConditions && trackId ? ( - + + + ) : null} {showPreview ? (