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 }