diff --git a/packages/harmony/src/foundations/spacing/spacing.ts b/packages/harmony/src/foundations/spacing/spacing.ts
index cbc67cc1757..48b80318cc3 100644
--- a/packages/harmony/src/foundations/spacing/spacing.ts
+++ b/packages/harmony/src/foundations/spacing/spacing.ts
@@ -44,6 +44,7 @@ export const spacing = {
}
export const iconSizes = {
+ '2xs': 12,
xs: 14,
s: 16,
m: 20,
diff --git a/packages/web/src/components/collection/CollectionCard.test.tsx b/packages/web/src/components/collection/CollectionCard.test.tsx
index e763a9b2349..479a5d1d14b 100644
--- a/packages/web/src/components/collection/CollectionCard.test.tsx
+++ b/packages/web/src/components/collection/CollectionCard.test.tsx
@@ -1,13 +1,14 @@
import { SquareSizes } from '@audius/common/models'
import { Text } from '@audius/harmony'
+import { merge } from 'lodash'
import { Routes, Route } from 'react-router-dom-v5-compat'
import { describe, it, expect } from 'vitest'
-import { render, screen } from 'test/test-utils'
+import { RenderOptions, render, screen } from 'test/test-utils'
import { CollectionCard } from './CollectionCard'
-function renderCollectionCard() {
+function renderCollectionCard(options?: RenderOptions) {
return render(
} />
@@ -20,42 +21,45 @@ function renderCollectionCard() {
element={Test User Page}
/>
,
- {
- reduxState: {
- collections: {
- entries: {
- 1: {
- metadata: {
- playlist_id: 1,
- playlist_name: 'Test Collection',
- playlist_owner_id: 2,
- permalink: '/test-user/test-collection',
- repost_count: 10,
- save_count: 5,
- _cover_art_sizes: {
- [SquareSizes.SIZE_150_BY_150]: 'image-small.jpg',
- [SquareSizes.SIZE_480_BY_480]: 'image-medium.jpg'
+ merge(
+ {
+ reduxState: {
+ collections: {
+ entries: {
+ 1: {
+ metadata: {
+ playlist_id: 1,
+ playlist_name: 'Test Collection',
+ playlist_owner_id: 2,
+ permalink: '/test-user/test-collection',
+ repost_count: 10,
+ save_count: 5,
+ _cover_art_sizes: {
+ [SquareSizes.SIZE_150_BY_150]: 'image-small.jpg',
+ [SquareSizes.SIZE_480_BY_480]: 'image-medium.jpg'
+ }
}
}
}
- }
- },
- users: {
- entries: {
- 2: { metadata: { handle: 'test-user', name: 'Test User' } }
+ },
+ users: {
+ entries: {
+ 2: { metadata: { handle: 'test-user', name: 'Test User' } }
+ }
}
}
- }
- }
+ },
+ options
+ )
)
}
describe('CollectionCard', () => {
- it('renders a button with the label comprising the collection and artist name', () => {
+ it('renders a button with the label comprising the collection and artist name, and favorites and reposts', () => {
renderCollectionCard()
expect(
screen.getByRole('button', {
- name: /test collection test user/i
+ name: /test collection test user reposts 10 favorites 5/i
})
).toBeInTheDocument()
})
@@ -89,12 +93,63 @@ describe('CollectionCard', () => {
).toBeInTheDocument()
})
- it('shows the number of reposts and favorites with the correct screen-reader text', () => {
- renderCollectionCard()
- expect(screen.getByTitle('Reposts')).toBeInTheDocument()
- expect(screen.getByText('10')).toBeInTheDocument()
+ it('hidden collections are rendered correctly', () => {
+ renderCollectionCard({
+ reduxState: {
+ collections: { entries: { 1: { metadata: { is_private: true } } } }
+ }
+ })
+
+ expect(
+ screen.getByRole('button', { name: /test collection test user hidden/i })
+ ).toBeInTheDocument()
+ })
+
+ it('premium locked collections are rendered correctly', () => {
+ renderCollectionCard({
+ reduxState: {
+ collections: {
+ entries: {
+ 1: {
+ metadata: {
+ access: { stream: false },
+ stream_conditions: {
+ usdc_purchase: { price: 10, albumTrackPrice: 1, splits: {} }
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+ expect(
+ screen.getByRole('button', {
+ name: /test collection test user reposts 10 favorites 5 available for purchase/i
+ })
+ )
+ })
- expect(screen.getByTitle('Favorites')).toBeInTheDocument()
- expect(screen.getByText('5')).toBeInTheDocument()
+ it('premium unlocked collections are rendered correctly', () => {
+ renderCollectionCard({
+ reduxState: {
+ collections: {
+ entries: {
+ 1: {
+ metadata: {
+ access: { stream: true },
+ stream_conditions: {
+ usdc_purchase: { price: 10, albumTrackPrice: 1, splits: {} }
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+ expect(
+ screen.getByRole('button', {
+ name: /test collection test user reposts 10 favorites 5 purchased/i
+ })
+ )
})
})
diff --git a/packages/web/src/components/collection/CollectionCard.tsx b/packages/web/src/components/collection/CollectionCard.tsx
index 31a6415483f..858a8d1afd3 100644
--- a/packages/web/src/components/collection/CollectionCard.tsx
+++ b/packages/web/src/components/collection/CollectionCard.tsx
@@ -1,11 +1,18 @@
-import { ID, SquareSizes } from '@audius/common/models'
+import {
+ DogEarType,
+ ID,
+ SquareSizes,
+ isContentUSDCPurchaseGated
+} from '@audius/common/models'
import { cacheCollectionsSelectors } from '@audius/common/store'
import { Divider, Flex, Paper, Text } from '@audius/harmony'
import IconHeart from '@audius/harmony/src/assets/icons/Heart.svg'
import IconRepost from '@audius/harmony/src/assets/icons/Repost.svg'
import { Link, useLinkClickHandler } from 'react-router-dom-v5-compat'
+import { DogEar } from 'components/dog-ear'
import { UserLink } from 'components/link'
+import { LockedStatusPill } from 'components/locked-status-pill'
import { useSelector } from 'utils/reducer'
import { CollectionImage } from './CollectionImage'
@@ -13,7 +20,8 @@ const { getCollection } = cacheCollectionsSelectors
const messages = {
repost: 'Reposts',
- favorites: 'Favorites'
+ favorites: 'Favorites',
+ hidden: 'Hidden'
}
type CardSize = 's' | 'm' | 'l'
@@ -51,50 +59,100 @@ export const CollectionCard = (props: CollectionCardProps) => {
permalink,
playlist_owner_id,
repost_count,
- save_count
+ save_count,
+ is_private,
+ access,
+ stream_conditions
} = collection
+ const isPurchase = isContentUSDCPurchaseGated(stream_conditions)
+
+ const dogEarType = is_private
+ ? DogEarType.HIDDEN
+ : isPurchase && !access.stream
+ ? DogEarType.USDC_PURCHASE
+ : null
+
return (
+ {dogEarType ? (
+
+ ) : null}
-
-
+
+
{playlist_name}
-
-
+
+
-
-
-
-
- {repost_count}
-
-
-
- {' '}
-
- {save_count}
+
+ {is_private ? (
+
+ {messages.hidden}
-
+ ) : (
+ <>
+
+
+
+ {repost_count}
+
+
+
+
+
+ {save_count}
+
+
+ {isPurchase ? (
+
+ ) : null}
+ >
+ )}
)
diff --git a/packages/web/src/components/dog-ear/DogEar.module.css b/packages/web/src/components/dog-ear/DogEar.module.css
deleted file mode 100644
index beb7a3827f4..00000000000
--- a/packages/web/src/components/dog-ear/DogEar.module.css
+++ /dev/null
@@ -1,49 +0,0 @@
-.container {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 10;
- border-radius: var(--harmony-unit-2) 0px 0px 0px;
- width: var(--harmony-unit-12);
- height: var(--harmony-unit-12);
- pointer-events: none;
- overflow: hidden;
-}
-
-.rectangle {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
-}
-
-/* Note: dogEarRectangle has multiple `path` elements, but we only want to fill one of them.
-That path uses `fill='currentColor'`, allowing us to set `color` here instead of `fill` */
-.gated {
- color: var(--harmony-blue);
-}
-
-.purchase {
- color: var(--harmony-light-green);
-}
-
-.artistPick {
- color: var(--harmony-secondary);
-}
-
-.hidden {
- color: var(--harmony-neutral);
-}
-
-.icon {
- position: absolute;
- top: var(--harmony-unit-1);
- left: var(--harmony-unit-1);
- width: var(--harmony-unit-4);
- height: var(--harmony-unit-4);
-}
-
-.icon path {
- fill: var(--harmony-static-white);
-}
diff --git a/packages/web/src/components/dog-ear/DogEar.tsx b/packages/web/src/components/dog-ear/DogEar.tsx
index e47141c7115..b46a1961b95 100644
--- a/packages/web/src/components/dog-ear/DogEar.tsx
+++ b/packages/web/src/components/dog-ear/DogEar.tsx
@@ -5,15 +5,13 @@ import {
IconSpecialAccess,
IconCart,
IconCollectible,
- IconLock
+ IconLock,
+ useTheme,
+ ColorTheme,
+ Box
} from '@audius/harmony'
-import cn from 'classnames'
import Rectangle from 'assets/img/dogEarRectangle.svg'
-import { useIsMobile } from 'hooks/useIsMobile'
-import { isMatrix } from 'utils/theme/theme'
-
-import styles from './DogEar.module.css'
export type DogEarProps = {
type: DogEarType
@@ -38,32 +36,56 @@ const getIcon = (type: DogEarType) => {
}
}
+const getColor = (type: DogEarType, color: ColorTheme['day']) => {
+ switch (type) {
+ case DogEarType.COLLECTIBLE_GATED:
+ case DogEarType.SPECIAL_ACCESS:
+ case DogEarType.LOCKED:
+ return color.special.blue
+ case DogEarType.USDC_PURCHASE:
+ return color.special.lightGreen
+ case DogEarType.STAR:
+ return color.secondary.secondary
+ case DogEarType.HIDDEN:
+ return color.neutral.neutral
+ }
+}
+
export const DogEar = (props: DogEarProps) => {
const { type, className } = props
- const isMatrixMode = isMatrix()
- const isMobile = useIsMobile()
const Icon = getIcon(type)
+ const { spacing, color } = useTheme()
+ const tagColor = getColor(type, color)
return (
-
+
-
-
+
)
}
diff --git a/packages/web/src/components/locked-status-pill/LockedStatusPill.tsx b/packages/web/src/components/locked-status-pill/LockedStatusPill.tsx
new file mode 100644
index 00000000000..c0425a34540
--- /dev/null
+++ b/packages/web/src/components/locked-status-pill/LockedStatusPill.tsx
@@ -0,0 +1,78 @@
+import {
+ Flex,
+ IconLockUnlocked,
+ IconSize,
+ Text,
+ useTheme
+} from '@audius/harmony'
+import IconLock from '@audius/harmony/src/assets/icons/Lock.svg'
+
+const messages = {
+ premiumLocked: 'Available for purchase',
+ premiumUnlocked: 'Purchased'
+}
+
+export type LockedStatusPillProps = {
+ locked: boolean
+ variant?: 'premium' | 'gated'
+ text?: string
+ /** Whether the badge is colored when locked */
+ coloredWhenLocked?: boolean
+ iconSize?: IconSize
+ id?: string
+}
+
+export const LockedStatusPill = (props: LockedStatusPillProps) => {
+ const {
+ locked,
+ variant = 'gated',
+ text,
+ coloredWhenLocked = false,
+ iconSize = 'xs',
+ id
+ } = props
+
+ const LockComponent = locked ? IconLock : IconLockUnlocked
+
+ const { color } = useTheme()
+
+ const backgroundColor =
+ !locked || coloredWhenLocked
+ ? variant === 'gated'
+ ? color.special.blue
+ : color.special.lightGreen
+ : color.neutral.n400
+
+ return (
+
+
+ {text ? (
+
+ {text}
+
+ ) : null}
+
+ )
+}
diff --git a/packages/web/src/components/locked-status-pill/index.ts b/packages/web/src/components/locked-status-pill/index.ts
new file mode 100644
index 00000000000..41f199262f1
--- /dev/null
+++ b/packages/web/src/components/locked-status-pill/index.ts
@@ -0,0 +1 @@
+export * from './LockedStatusPill'
diff --git a/packages/web/src/components/now-playing/NowPlaying.tsx b/packages/web/src/components/now-playing/NowPlaying.tsx
index bafcf735e34..46baf1825af 100644
--- a/packages/web/src/components/now-playing/NowPlaying.tsx
+++ b/packages/web/src/components/now-playing/NowPlaying.tsx
@@ -43,6 +43,7 @@ import { useRecord, make } from 'common/store/analytics/actions'
import CoSign, { Size } from 'components/co-sign/CoSign'
import { DogEar } from 'components/dog-ear'
import DynamicImage from 'components/dynamic-image/DynamicImage'
+import { LockedStatusPill } from 'components/locked-status-pill'
import PlayButton from 'components/play-bar/PlayButton'
import NextButtonProvider from 'components/play-bar/next-button/NextButtonProvider'
import PreviousButtonProvider from 'components/play-bar/previous-button/PreviousButtonProvider'
@@ -50,7 +51,6 @@ import RepeatButtonProvider from 'components/play-bar/repeat-button/RepeatButton
import ShuffleButtonProvider from 'components/play-bar/shuffle-button/ShuffleButtonProvider'
import { PlayButtonStatus } from 'components/play-bar/types'
import { GatedConditionsPill } from 'components/track/GatedConditionsPill'
-import { LockedStatusBadge } from 'components/track/LockedStatusBadge'
import UserBadges from 'components/user-badges/UserBadges'
import { useAuthenticatedClickCallback } from 'hooks/useAuthenticatedCallback'
import { useFlag } from 'hooks/useRemoteConfig'
@@ -487,9 +487,9 @@ const NowPlaying = g(
{title}
{shouldShowPurchasePreview ? (
-
{shouldShowPreviewLock ? (
- {name}
{shouldShowPreviewLock ? (
-
) : null}
diff --git a/packages/web/src/components/premium-content-purchase-modal/components/PayToUnlockInfo.tsx b/packages/web/src/components/premium-content-purchase-modal/components/PayToUnlockInfo.tsx
index 7f297e11523..c699b23d6de 100644
--- a/packages/web/src/components/premium-content-purchase-modal/components/PayToUnlockInfo.tsx
+++ b/packages/web/src/components/premium-content-purchase-modal/components/PayToUnlockInfo.tsx
@@ -3,7 +3,7 @@ import { useCallback } from 'react'
import { Name } from '@audius/common/models'
import { Text, TextLink } from '@audius/harmony'
-import { LockedStatusBadge } from 'components/track/LockedStatusBadge'
+import { LockedStatusPill } from 'components/locked-status-pill'
import { make, track } from 'services/analytics'
import { TERMS_OF_SERVICE } from 'utils/route'
@@ -30,7 +30,7 @@ export const PayToUnlockInfo = () => {
className={styles.header}
>
{messages.payToUnlock}
-
+
{messages.copyPart1}
diff --git a/packages/web/src/components/track/GatedContentSection.tsx b/packages/web/src/components/track/GatedContentSection.tsx
index 35d9aeaf17f..dbfbc5f1169 100644
--- a/packages/web/src/components/track/GatedContentSection.tsx
+++ b/packages/web/src/components/track/GatedContentSection.tsx
@@ -49,8 +49,9 @@ import { emptyStringGuard } from 'pages/track-page/utils'
import { AppState } from 'store/types'
import { profilePage } from 'utils/route'
+import { LockedStatusPill } from '../locked-status-pill'
+
import styles from './GiantTrackTile.module.css'
-import { LockedStatusBadge } from './LockedStatusBadge'
const { getUsers } = cacheUsersSelectors
const { beginTip } = tippingActions
@@ -317,7 +318,7 @@ const LockedGatedContentSection = ({
-
@@ -522,7 +523,7 @@ const UnlockedGatedContentSection = ({
{isOwner ? (
) : (
- {
- const LockComponent = locked ? IconLock : IconLockUnlocked
- return (
-
-
- {text ? (
-
- {text}
-
- ) : null}
-
- )
-}
diff --git a/packages/web/src/components/track/desktop/TrackTile.tsx b/packages/web/src/components/track/desktop/TrackTile.tsx
index 9f69216b1d2..8cd6c798a63 100644
--- a/packages/web/src/components/track/desktop/TrackTile.tsx
+++ b/packages/web/src/components/track/desktop/TrackTile.tsx
@@ -36,8 +36,11 @@ import Skeleton from 'components/skeleton/Skeleton'
import { useAuthenticatedClickCallback } from 'hooks/useAuthenticatedCallback'
import { useFlag } from 'hooks/useRemoteConfig'
+import {
+ LockedStatusPill,
+ LockedStatusPillProps
+} from '../../locked-status-pill'
import { GatedContentLabel } from '../GatedContentLabel'
-import { LockedStatusBadge, LockedStatusBadgeProps } from '../LockedStatusBadge'
import { messages } from '../trackTileMessages'
import {
TrackTileSize,
@@ -92,9 +95,9 @@ const renderLockedContentOrPlayCount = ({
| 'isStreamGated'
| 'listenCount'
> &
- Pick) => {
+ Pick) => {
if (isStreamGated && !isOwner) {
- return
+ return
}
const hidePlays = fieldVisibility
diff --git a/packages/web/src/components/track/mobile/PlaylistTile.tsx b/packages/web/src/components/track/mobile/PlaylistTile.tsx
index e3104551c15..df7ea7f364f 100644
--- a/packages/web/src/components/track/mobile/PlaylistTile.tsx
+++ b/packages/web/src/components/track/mobile/PlaylistTile.tsx
@@ -36,13 +36,16 @@ import FavoriteButton from 'components/alt-button/FavoriteButton'
import RepostButton from 'components/alt-button/RepostButton'
import { DogEar } from 'components/dog-ear'
import { TextLink, UserLink } from 'components/link'
+import {
+ LockedStatusPill,
+ LockedStatusPillProps
+} from 'components/locked-status-pill'
import Skeleton from 'components/skeleton/Skeleton'
import { PlaylistTileProps } from 'components/track/types'
import { useAuthenticatedClickCallback } from 'hooks/useAuthenticatedCallback'
import { GatedConditionsPill } from '../GatedConditionsPill'
import { GatedContentLabel } from '../GatedContentLabel'
-import { LockedStatusBadge, LockedStatusBadgeProps } from '../LockedStatusBadge'
import BottomButtons from './BottomButtons'
import styles from './PlaylistTile.module.css'
@@ -164,7 +167,7 @@ type LockedOrPlaysContentProps = Pick<
CombinedProps,
'hasStreamAccess' | 'isOwner' | 'isStreamGated' | 'streamConditions'
> &
- Pick & {
+ Pick & {
gatedTrackStatus?: GatedContentStatus
onClickGatedUnlockPill: (e: MouseEvent) => void
}
@@ -189,7 +192,7 @@ const renderLockedContent = ({
/>
)
}
- return
+ return
}
}
diff --git a/packages/web/src/components/track/mobile/TrackTile.tsx b/packages/web/src/components/track/mobile/TrackTile.tsx
index 8ba1251c896..2b6bc0ba39d 100644
--- a/packages/web/src/components/track/mobile/TrackTile.tsx
+++ b/packages/web/src/components/track/mobile/TrackTile.tsx
@@ -37,6 +37,10 @@ import FavoriteButton from 'components/alt-button/FavoriteButton'
import RepostButton from 'components/alt-button/RepostButton'
import { DogEar } from 'components/dog-ear'
import { TextLink, UserLink } from 'components/link'
+import {
+ LockedStatusPill,
+ LockedStatusPillProps
+} from 'components/locked-status-pill'
import Skeleton from 'components/skeleton/Skeleton'
import { GatedContentLabel } from 'components/track/GatedContentLabel'
import { TrackTileProps } from 'components/track/types'
@@ -44,7 +48,6 @@ import UserBadges from 'components/user-badges/UserBadges'
import { useAuthenticatedClickCallback } from 'hooks/useAuthenticatedCallback'
import { GatedConditionsPill } from '../GatedConditionsPill'
-import { LockedStatusBadge, LockedStatusBadgeProps } from '../LockedStatusBadge'
import { messages } from '../trackTileMessages'
import BottomButtons from './BottomButtons'
@@ -81,7 +84,7 @@ type LockedOrPlaysContentProps = Pick<
| 'streamConditions'
| 'listenCount'
> &
- Pick & {
+ Pick & {
gatedTrackStatus?: GatedContentStatus
onClickGatedUnlockPill: (e: MouseEvent) => void
}
@@ -108,7 +111,7 @@ const renderLockedContentOrPlayCount = ({
/>
)
}
- return
+ return
}
const hidePlays = fieldVisibility
diff --git a/packages/web/src/test/test-utils.tsx b/packages/web/src/test/test-utils.tsx
index 25e45d6a33c..659bc4c5538 100644
--- a/packages/web/src/test/test-utils.tsx
+++ b/packages/web/src/test/test-utils.tsx
@@ -40,10 +40,11 @@ const TestProviders =
)
}
-const customRender = (
- ui: ReactElement,
- options?: Omit & TestOptions
-) => render(ui, { wrapper: TestProviders(options), ...options })
+type CustomRenderOptions = Omit & TestOptions
+
+const customRender = (ui: ReactElement, options?: CustomRenderOptions) =>
+ render(ui, { wrapper: TestProviders(options), ...options })
export * from '@testing-library/react'
+export type { CustomRenderOptions as RenderOptions }
export { customRender as render }