diff --git a/apps/web/src/components/Explore/ExploreUserCard.tsx b/apps/web/src/components/Explore/ExploreUserCard.tsx index 44e5c13f12..c4daf584b8 100644 --- a/apps/web/src/components/Explore/ExploreUserCard.tsx +++ b/apps/web/src/components/Explore/ExploreUserCard.tsx @@ -9,7 +9,7 @@ import { contexts } from '~/shared/analytics/constants'; import { removeNullValues } from '~/shared/relay/removeNullValues'; import { useLoggedInUserId } from '~/shared/relay/useLoggedInUserId'; import colors from '~/shared/theme/colors'; -import unescape from '~/shared/utils/unescape'; +import { getUnescapedBioFirstLine } from '~/utils/sanity'; import Badge from '../Badge/Badge'; import breakpoints from '../core/breakpoints'; @@ -66,8 +66,7 @@ export default function ExploreUserCard({ userRef, queryRef }: Props) { } const { bio } = user; - - const unescapedBio = useMemo(() => (bio ? unescape(bio) : ''), [bio]); + const bioFirstLine = useMemo(() => getUnescapedBioFirstLine(bio), [bio]); const userGalleries = useMemo(() => { return user.galleries ?? []; @@ -95,13 +94,6 @@ export default function ExploreUserCard({ userRef, queryRef }: Props) { const isMobile = useIsMobileWindowWidth(); - const bioFirstLine = useMemo(() => { - if (!unescapedBio) { - return ''; - } - return unescapedBio.split('\n')[0] ?? ''; - }, [unescapedBio]); - return ( void; +}; + +export default function SuggestedProfileCard({ + userRef, + queryRef, + onClick, + showFollowButton = true, +}: Props) { + const user = useFragment( + graphql` + fragment SuggestedProfileCardFragment on GalleryUser { + id + username + badges { + name + imageURL + ...BadgeFragment + } + bio + galleries { + tokenPreviews { + large + } + hidden + } + ...ProfilePictureFragment + ...FollowButtonUserFragment + } + `, + userRef + ); + + const query = useFragment( + graphql` + fragment SuggestedProfileCardFollowFragment on Query { + ...FollowButtonQueryFragment + ...useLoggedInUserIdFragment + } + `, + queryRef + ); + + const loggedInUserId = useLoggedInUserId(query); + const isOwnProfile = loggedInUserId && loggedInUserId === user?.id; + + if (!user) { + throw new Error('No user available to showcase SuggestedProfileCard'); + } + + const bioFirstLine = useMemo(() => getUnescapedBioFirstLine(user?.bio), [user?.bio]); + + const userGalleries = useMemo(() => { + return user.galleries ?? []; + }, [user.galleries]); + + const tokenPreviews = useMemo(() => { + const gallery = userGalleries.find( + (gallery) => !gallery?.hidden && removeNullValues(gallery?.tokenPreviews).length > 0 + ); + + return gallery?.tokenPreviews?.slice(0, 2) ?? []; + }, [userGalleries]); + + const userBadges = useMemo(() => { + const badges = []; + + for (const badge of user.badges ?? []) { + if (badge?.imageURL) { + badges.push(badge); + } + } + + return badges; + }, [user.badges]); + + const shouldShowFollowButton = useMemo( + () => showFollowButton && !isOwnProfile, + [showFollowButton, isOwnProfile] + ); + + return ( + + + + {tokenPreviews.map( + (url) => url?.large && + )} + + + + + + + + {user.username} + + + {userBadges.map((badge) => ( + + ))} + + + + + + + + {shouldShowFollowButton && ( + + )} + + + + + + ); +} + +const StyledSuggestedProfileCard = styled(HStack)` + border-radius: 4px; + background-color: ${colors.offWhite}; + padding: 8px; + cursor: pointer; + text-decoration: none; + overflow: hidden; + + &:hover { + background-color: ${colors.faint}; + } + + @media only screen and ${breakpoints.desktop} { + width: 230px; + } +`; + +const StyledContent = styled(VStack)` + height: 100%; + width: 100%; +`; + +const ProfileDetailsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + + @media only screen and ${breakpoints.desktop} { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +`; + +const ProfileDetailsText = styled(VStack)` + overflow: hidden; + width: 100%; +`; + +const StyledUserBio = styled(BaseM)` + height: 20px; // ensure consistent height even if bio is not present + + font-size: 14px; + font-weight: 400; + line-clamp: 1; + overflow: hidden; + -webkit-line-clamp: 1; + display: -webkit-box; + -webkit-box-orient: vertical; +`; + +export const TokenPreviewContainer = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + + min-height: 97px; + grid-gap: 2px; +`; + +export const TokenPreview = styled.img` + height: auto; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; +`; + +const Username = styled(BaseM)` + font-size: 16px; + font-weight: 700; + + overflow: hidden; + text-overflow: ellipsis; +`; + +const WideFollowButton = styled(FollowButton)` + padding: 2px 8px; + width: 100%; + height: 24px; + line-height: 0; + + @media only screen and ${breakpoints.desktop} { + width: 100%; + height: 24px; + } +`; diff --git a/apps/web/src/components/Search/Search.tsx b/apps/web/src/components/Search/Search.tsx index 336e2bf585..ca9c596f0d 100644 --- a/apps/web/src/components/Search/Search.tsx +++ b/apps/web/src/components/Search/Search.tsx @@ -12,6 +12,7 @@ import { useTrack } from '~/shared/contexts/AnalyticsContext'; import { VStack } from '../core/Spacer/Stack'; import { Spinner } from '../core/Spinner/Spinner'; import { useSearchContext } from './SearchContext'; +import SearchDefault from './SearchDefault'; import SearchFilter from './SearchFilter'; import SearchInput from './SearchInput'; import SearchResults from './SearchResults'; @@ -121,13 +122,19 @@ export default function Search() { } > - {keyword && ( + {keyword ? ( + ) : ( + )} diff --git a/apps/web/src/components/Search/SearchDefault.tsx b/apps/web/src/components/Search/SearchDefault.tsx new file mode 100644 index 0000000000..c8cdb970d6 --- /dev/null +++ b/apps/web/src/components/Search/SearchDefault.tsx @@ -0,0 +1,72 @@ +import { graphql, useLazyLoadQuery } from 'react-relay'; +import styled from 'styled-components'; + +import breakpoints from '~/components/core/breakpoints'; +import { SearchDefaultQuery } from '~/generated/SearchDefaultQuery.graphql'; + +import { VStack } from '../core/Spacer/Stack'; +import { SearchFilterType } from './Search'; +import SearchDefaultSuggestedUsersSection from './SearchDefaultSuggestedUsersSection'; +import SearchDefaultTrendingCuratorsSection from './SearchDefaultTrendingCuratorsSection'; +import SearchDefaultTrendingUsersSection from './SearchDefaultTrendingUsersSection'; +import { SearchItemType } from './types'; + +type Props = { + selectedFilter: SearchFilterType; + onChangeFilter: (filter: SearchFilterType) => void; + onSelect: (item: SearchItemType) => void; + variant?: 'default' | 'compact'; +}; + +export default function SearchDefault({ + variant = 'default', + onSelect, + selectedFilter, + onChangeFilter, +}: Props) { + const query = useLazyLoadQuery( + graphql` + query SearchDefaultQuery { + ...SearchDefaultSuggestedUsersSectionFragment + ...SearchDefaultTrendingUsersSectionFragment + ...SearchDefaultTrendingCuratorsSectionFragment + } + `, + {} + ); + + return ( + + {!selectedFilter && ( + + + + + )} + + + ); +} + +const SectionWrapper = styled(VStack)` + padding: 4px; + + @media only screen and ${breakpoints.desktop} { + padding: 12px; + } +`; diff --git a/apps/web/src/components/Search/SearchDefaultSuggestedUsersSection.tsx b/apps/web/src/components/Search/SearchDefaultSuggestedUsersSection.tsx new file mode 100644 index 0000000000..52c8864c49 --- /dev/null +++ b/apps/web/src/components/Search/SearchDefaultSuggestedUsersSection.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import { graphql, useFragment } from 'react-relay'; +import styled from 'styled-components'; + +import breakpoints from '~/components/core/breakpoints'; +import { SearchDefaultSuggestedUsersSectionFragment$key } from '~/generated/SearchDefaultSuggestedUsersSectionFragment.graphql'; + +import { fadeIn } from '../core/keyframes'; +import { HStack, VStack } from '../core/Spacer/Stack'; +import SuggestedProfileCard from '../Feed/SuggestedProfileCard'; +import SearchResultsHeader from './SearchResultsHeader'; +import { SearchItemType } from './types'; + +type Props = { + queryRef: SearchDefaultSuggestedUsersSectionFragment$key; + variant?: 'default' | 'compact'; + onSelect: (item: SearchItemType) => void; +}; + +export default function SearchDefaultSuggestedUsersSection({ queryRef, variant, onSelect }: Props) { + const query = useFragment( + graphql` + fragment SearchDefaultSuggestedUsersSectionFragment on Query { + viewer @required(action: THROW) { + ... on Viewer { + suggestedUsers(first: 2) @required(action: THROW) { + __typename + edges { + node { + __typename + ... on GalleryUser { + username + dbid + __typename + } + ...SuggestedProfileCardFragment + } + } + } + } + } + + ...SuggestedProfileCardFollowFragment + } + `, + queryRef + ); + + const nonNullProfiles = useMemo(() => { + const users = []; + + for (const edge of query.viewer?.suggestedUsers?.edges ?? []) { + if (edge?.node) { + users.push(edge.node); + } + } + + return users; + }, [query.viewer?.suggestedUsers?.edges]); + + if (query.viewer?.suggestedUsers?.__typename !== 'UsersConnection' || !nonNullProfiles) { + return null; + } + + return ( + + + + Suggested Collectors and Creators + + + + {nonNullProfiles?.map((profile) => ( + + onSelect({ + type: 'User' as const, + label: profile.username ?? '', + value: profile.dbid, + }) + } + showFollowButton={false} + /> + ))} + + + ); +} + +const HeaderWrapper = styled(HStack)` + padding: 0px 12px; + + @media only screen and ${breakpoints.desktop} { + padding-right: 12px; + padding-left: 8px; + } +`; + +const StyledWrapper = styled(VStack)` + animation: ${fadeIn} 0.2s ease-out forwards; +`; diff --git a/apps/web/src/components/Search/SearchDefaultTrendingCuratorsSection.tsx b/apps/web/src/components/Search/SearchDefaultTrendingCuratorsSection.tsx new file mode 100644 index 0000000000..4fe379784b --- /dev/null +++ b/apps/web/src/components/Search/SearchDefaultTrendingCuratorsSection.tsx @@ -0,0 +1,122 @@ +import { useCallback, useMemo } from 'react'; +import { graphql, useFragment } from 'react-relay'; +import styled from 'styled-components'; + +import breakpoints from '~/components/core/breakpoints'; +import { SearchDefaultTrendingCuratorsSectionFragment$key } from '~/generated/SearchDefaultTrendingCuratorsSectionFragment.graphql'; +import { contexts } from '~/shared/analytics/constants'; + +import GalleryLink from '../core/GalleryLink/GalleryLink'; +import { HStack, VStack } from '../core/Spacer/Stack'; +import { SearchFilterType } from './Search'; +import SearchResultsHeader from './SearchResultsHeader'; +import { SearchItemType } from './types'; +import UserSearchResult from './User/UserSearchResult'; + +type Props = { + queryRef: SearchDefaultTrendingCuratorsSectionFragment$key; + variant: 'default' | 'compact'; + onSelect: (item: SearchItemType) => void; + selectedFilter: SearchFilterType; + onChangeFilter: (newFilter: SearchFilterType) => void; + showAllButton?: boolean; +}; + +export default function SearchDefaultTrendingCuratorsSection({ + queryRef, + variant, + onSelect, + selectedFilter, + onChangeFilter, + showAllButton = false, +}: Props) { + const query = useFragment( + graphql` + fragment SearchDefaultTrendingCuratorsSectionFragment on Query { + trendingUsers5Days: trendingUsers(input: { report: LAST_5_DAYS }) { + ... on TrendingUsersPayload { + __typename + users { + id + ...UserSearchResultFragment + } + } + } + } + `, + queryRef + ); + + const trendingUsers = useMemo(() => { + if (query.trendingUsers5Days?.__typename === 'TrendingUsersPayload') { + const { users } = query.trendingUsers5Days; + + if (selectedFilter === 'curator') { + return users; + } + + return users?.slice(0, 4); + } + return []; + }, [query, selectedFilter]); + + const isSelectedFilterCurator = useMemo(() => selectedFilter === 'curator', [selectedFilter]); + + const handleToggleShowAll = useCallback(() => { + if (isSelectedFilterCurator) { + onChangeFilter(null); + } else { + onChangeFilter('curator'); + } + }, [isSelectedFilterCurator, onChangeFilter]); + + if ( + query.trendingUsers5Days?.__typename !== 'TrendingUsersPayload' || + !query.trendingUsers5Days.users + ) { + return null; + } + + return ( + + + Trending Curators + {showAllButton && ( + + {!isSelectedFilterCurator ? 'Show all' : 'Hide All'} + + )} + + + {trendingUsers?.map((user) => ( + + ))} + + + ); +} + +const StyledResultHeader = styled(HStack)` + padding: 0px 12px; + + @media only screen and ${breakpoints.desktop} { + padding-right: 12px; + padding-left: 8px; + } +`; + +const StyledGalleryLink = styled(GalleryLink)` + font-size: 12px; + line-height: 16px; +`; diff --git a/apps/web/src/components/Search/SearchDefaultTrendingUsersSection.tsx b/apps/web/src/components/Search/SearchDefaultTrendingUsersSection.tsx new file mode 100644 index 0000000000..fcfcc02fa1 --- /dev/null +++ b/apps/web/src/components/Search/SearchDefaultTrendingUsersSection.tsx @@ -0,0 +1,85 @@ +import { graphql, useFragment } from 'react-relay'; +import styled from 'styled-components'; + +import breakpoints from '~/components/core/breakpoints'; +import { SearchDefaultTrendingUsersSectionFragment$key } from '~/generated/SearchDefaultTrendingUsersSectionFragment.graphql'; + +import { fadeIn } from '../core/keyframes'; +import { HStack, VStack } from '../core/Spacer/Stack'; +import SuggestedProfileCard from '../Feed/SuggestedProfileCard'; +import SearchResultsHeader from './SearchResultsHeader'; +import { SearchItemType } from './types'; + +type Props = { + queryRef: SearchDefaultTrendingUsersSectionFragment$key; + variant?: 'default' | 'compact'; + onSelect: (item: SearchItemType) => void; +}; + +export default function SearchDefaultTrendingUsersSection({ queryRef, variant, onSelect }: Props) { + const query = useFragment( + graphql` + fragment SearchDefaultTrendingUsersSectionFragment on Query { + trendingUsers5Days: trendingUsers(input: { report: LAST_5_DAYS }) { + ... on TrendingUsersPayload { + __typename + users { + username + dbid + ...SuggestedProfileCardFragment + } + } + } + + ...SuggestedProfileCardFollowFragment + } + `, + queryRef + ); + + if (query.trendingUsers5Days?.__typename !== 'TrendingUsersPayload') { + return null; + } + + const { users: trendingUsers } = query.trendingUsers5Days; + + return ( + + + + Trending collectors and creators + + + + {trendingUsers?.slice(0, 2)?.map((profile) => ( + + onSelect({ + type: 'User' as const, + label: profile.username ?? '', + value: profile.dbid, + }) + } + showFollowButton={false} + /> + ))} + + + ); +} + +const HeaderWrapper = styled(HStack)` + padding: 0px 12px; + + @media only screen and ${breakpoints.desktop} { + padding-right: 12px; + padding-left: 8px; + } +`; + +const StyledWrapper = styled(VStack)` + animation: ${fadeIn} 0.2s ease-out forwards; +`; diff --git a/apps/web/src/components/Search/SearchResultsHeader.tsx b/apps/web/src/components/Search/SearchResultsHeader.tsx new file mode 100644 index 0000000000..77e6f1d1a8 --- /dev/null +++ b/apps/web/src/components/Search/SearchResultsHeader.tsx @@ -0,0 +1,22 @@ +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +import colors from '~/shared/theme/colors'; + +import { TitleXS } from '../core/Text/Text'; +import { SearchResultVariant } from './types'; + +type Props = PropsWithChildren<{ + variant?: SearchResultVariant; +}>; + +export default function SearchResultsHeader({ variant = 'default', children }: Props) { + return {children}; +} + +const StyledTitle = styled(TitleXS)<{ variant?: SearchResultVariant }>` + text-transform: uppercase; + color: ${colors.metal}; + + ${({ variant }) => variant === 'compact' && 'padding: 4px 0;'} +`; diff --git a/apps/web/src/components/Search/SearchSection.tsx b/apps/web/src/components/Search/SearchSection.tsx index 18a6e75deb..340786b245 100644 --- a/apps/web/src/components/Search/SearchSection.tsx +++ b/apps/web/src/components/Search/SearchSection.tsx @@ -2,12 +2,12 @@ import { useMemo } from 'react'; import styled from 'styled-components'; import { contexts } from '~/shared/analytics/constants'; -import colors from '~/shared/theme/colors'; import GalleryLink from '../core/GalleryLink/GalleryLink'; import { HStack, VStack } from '../core/Spacer/Stack'; -import { TitleDiatypeL, TitleXS } from '../core/Text/Text'; +import { TitleDiatypeL } from '../core/Text/Text'; import { NUM_PREVIEW_SEARCH_RESULTS } from './constants'; +import SearchResultsHeader from './SearchResultsHeader'; import { SearchResultVariant } from './types'; type Props = { @@ -46,7 +46,7 @@ export default function SearchSection({ return ( - {title} + {title} {showAllButton && ( ` - text-transform: uppercase; - color: ${colors.metal}; - - ${({ variant }) => variant === 'compact' && 'padding: 4px 0;'} -`; - const StyledResultHeader = styled(HStack)` padding: 0 12px; `; diff --git a/apps/web/src/contexts/globalLayout/GlobalSidebar/AnimatedSidebarDrawer.tsx b/apps/web/src/contexts/globalLayout/GlobalSidebar/AnimatedSidebarDrawer.tsx index 516eea199f..c68eb16088 100644 --- a/apps/web/src/contexts/globalLayout/GlobalSidebar/AnimatedSidebarDrawer.tsx +++ b/apps/web/src/contexts/globalLayout/GlobalSidebar/AnimatedSidebarDrawer.tsx @@ -80,7 +80,7 @@ const StyledMotion = styled(motion.div)` `; const StyledDrawer = styled(VStack)` - background-color: ${colors.offWhite}; + background-color: ${colors.white}; width: 100%; position: relative; min-height: 0; diff --git a/apps/web/src/scenes/ContentPages/ContentModules/FeaturedProfiles.tsx b/apps/web/src/scenes/ContentPages/ContentModules/FeaturedProfiles.tsx index d15f91100a..6232e6eb8d 100644 --- a/apps/web/src/scenes/ContentPages/ContentModules/FeaturedProfiles.tsx +++ b/apps/web/src/scenes/ContentPages/ContentModules/FeaturedProfiles.tsx @@ -76,7 +76,7 @@ type FeaturedProfileProps = { profile: CmsTypes.FeaturedProfile; }; -function FeaturedProfile({ profile }: FeaturedProfileProps) { +export function FeaturedProfile({ profile }: FeaturedProfileProps) { return (