Commit e2081d49 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #2198 from blockscout/rating-count

Add rating count
parents 54a512b8 6a330951
<svg viewBox="0 0 15 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.502 11.242a2.491 2.491 0 0 0-1.802.77L4.919 8.758c.112-.42.112-.863 0-1.284L10.7 4.222a2.492 2.492 0 1 0-.614-1.088L4.303 6.386a2.5 2.5 0 1 0 0 3.461l5.781 3.253a2.5 2.5 0 1 0 2.417-1.858Z" fill="currentColor"/>
</svg>
...@@ -6,6 +6,7 @@ export const ratings = { ...@@ -6,6 +6,7 @@ export const ratings = {
fields: { fields: {
appId: apps[0].id, appId: apps[0].id,
rating: 4.3, rating: 4.3,
count: 15,
}, },
}, },
], ],
......
...@@ -117,6 +117,7 @@ ...@@ -117,6 +117,7 @@
| "score/score-not-ok" | "score/score-not-ok"
| "score/score-ok" | "score/score-ok"
| "search" | "search"
| "share"
| "social/canny" | "social/canny"
| "social/coingecko" | "social/coingecko"
| "social/coinmarketcap" | "social/coinmarketcap"
......
...@@ -29,6 +29,7 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia ...@@ -29,6 +29,7 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
export type AppRating = { export type AppRating = {
recordId: string; recordId: string;
value: number | undefined; value: number | undefined;
count?: number;
} }
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & { export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
......
...@@ -9,13 +9,13 @@ type Props = { ...@@ -9,13 +9,13 @@ type Props = {
} }
const FavoriteIcon = ({ isFavorite, color }: Props) => { const FavoriteIcon = ({ isFavorite, color }: Props) => {
const heartFilledColor = useColorModeValue('blue.700', 'gray.400'); const heartFilledColor = useColorModeValue('blue.600', 'blue.300');
const defaultColor = isFavorite ? heartFilledColor : 'gray.400'; const defaultColor = isFavorite ? heartFilledColor : (color || 'gray.400');
return ( return (
<IconSvg <IconSvg
name={ isFavorite ? 'heart_filled' : 'heart_outline' } name={ isFavorite ? 'heart_filled' : 'heart_outline' }
color={ color || defaultColor } color={ defaultColor }
boxSize={ 5 } boxSize={ 5 }
/> />
); );
......
...@@ -5,6 +5,8 @@ import React, { useCallback } from 'react'; ...@@ -5,6 +5,8 @@ import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import isBrowser from 'lib/isBrowser';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon'; import FavoriteIcon from './FavoriteIcon';
...@@ -168,7 +170,7 @@ const MarketplaceAppCard = ({ ...@@ -168,7 +170,7 @@ const MarketplaceAppCard = ({
> >
More info More info
</Link> </Link>
<Flex alignItems="center" gap={ 3 }> <Flex alignItems="center">
<Rating <Rating
appId={ id } appId={ id }
rating={ rating } rating={ rating }
...@@ -188,6 +190,21 @@ const MarketplaceAppCard = ({ ...@@ -188,6 +190,21 @@ const MarketplaceAppCard = ({
h={{ base: 6, md: '30px' }} h={{ base: 6, md: '30px' }}
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ <FavoriteIcon isFavorite={ isFavorite }/> } icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
ml={ 2 }
/>
<CopyToClipboard
text={ isBrowser() ? window.location.origin + `/apps/${ id }` : '' }
icon="share"
size={ 4 }
variant="ghost"
colorScheme="gray"
w={{ base: 6, md: '30px' }}
h={{ base: 6, md: '30px' }}
color="gray.400"
_hover={{ color: 'gray.400' }}
ml={{ base: 1, md: 0 }}
display="inline-flex"
borderRadius="base"
/> />
</Flex> </Flex>
</Flex> </Flex>
......
...@@ -10,7 +10,9 @@ import { ContractListTypes } from 'types/client/marketplace'; ...@@ -10,7 +10,9 @@ import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import isBrowser from 'lib/isBrowser';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import type { IconName } from 'ui/shared/IconSvg'; import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -113,6 +115,8 @@ const MarketplaceAppModal = ({ ...@@ -113,6 +115,8 @@ const MarketplaceAppModal = ({
} catch (err) {} } catch (err) {}
} }
const iconColor = useColorModeValue('blue.600', 'gray.400');
return ( return (
<Modal <Modal
isOpen={ Boolean(data.id) } isOpen={ Boolean(data.id) }
...@@ -206,8 +210,23 @@ const MarketplaceAppModal = ({ ...@@ -206,8 +210,23 @@ const MarketplaceAppModal = ({
colorScheme="gray" colorScheme="gray"
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
flexShrink={ 0 }
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ <FavoriteIcon isFavorite={ isFavorite } color={ useColorModeValue('blue.700', 'gray.400') }/> } icon={ <FavoriteIcon isFavorite={ isFavorite } color={ iconColor }/> }
/>
<CopyToClipboard
text={ isBrowser() ? window.location.origin + `/apps/${ id }` : '' }
icon="share"
size={ 4 }
variant="outline"
colorScheme="gray"
w={ 9 }
h={ 8 }
color={ iconColor }
_hover={{ color: iconColor }}
display="inline-flex"
borderRadius="base"
/> />
</Flex> </Flex>
</Flex> </Flex>
......
...@@ -53,7 +53,7 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) ...@@ -53,7 +53,7 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props)
return ( return (
<> <>
<Flex alignItems="center" flexWrap="wrap" mb={{ base: 3, md: 2 }} rowGap={ 3 } columnGap={ 2 }> <Flex alignItems="center" mb={{ base: 3, md: 2 }} rowGap={ 3 } columnGap={ 2 }>
{ !isMobile && <NetworkLogo isCollapsed/> } { !isMobile && <NetworkLogo isCollapsed/> }
<Tooltip label="Back to dApps list"> <Tooltip label="Back to dApps list">
<LinkInternal display="inline-flex" href={ goBackUrl } h="32px" isLoading={ isLoading } ml={ isMobile ? 0 : 4 }> <LinkInternal display="inline-flex" href={ goBackUrl } h="32px" isLoading={ isLoading } ml={ isMobile ? 0 : 4 }>
......
...@@ -45,10 +45,7 @@ const MarketplaceList = ({ ...@@ -45,10 +45,7 @@ const MarketplaceList = ({
return apps.length > 0 ? ( return apps.length > 0 ? (
<> <>
<Grid <Grid
templateColumns={{ templateColumns={{ md: 'repeat(auto-fill, minmax(270px, 1fr))' }}
md: 'repeat(auto-fill, minmax(230px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr" autoRows="1fr"
gap={{ base: '16px', md: '24px' }} gap={{ base: '16px', md: '24px' }}
marginTop={{ base: 0, lg: 3 }} marginTop={{ base: 0, lg: 3 }}
......
...@@ -51,6 +51,7 @@ const Rating = ({ ...@@ -51,6 +51,7 @@ const Rating = ({
<> <>
<Stars filledIndex={ (rating?.value || 0) - 1 }/> <Stars filledIndex={ (rating?.value || 0) - 1 }/>
<Text fontSize="md" ml={ 2 }>{ rating?.value }</Text> <Text fontSize="md" ml={ 2 }>{ rating?.value }</Text>
{ rating?.count && <Text variant="secondary" fontSize="md" ml={ 1 }>({ rating?.count })</Text> }
</> </>
) } ) }
<Box ref={ popoverRef }> <Box ref={ popoverRef }>
...@@ -58,6 +59,7 @@ const Rating = ({ ...@@ -58,6 +59,7 @@ const Rating = ({
<PopoverTrigger> <PopoverTrigger>
<TriggerButton <TriggerButton
rating={ rating?.value } rating={ rating?.value }
count={ rating?.count }
fullView={ fullView } fullView={ fullView }
isActive={ isOpen } isActive={ isOpen }
onClick={ onToggle } onClick={ onToggle }
......
import { Button, chakra, useColorModeValue, Tooltip, useDisclosure } from '@chakra-ui/react'; import { Button, chakra, useColorModeValue, Tooltip, useDisclosure, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
...@@ -7,6 +7,7 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -7,6 +7,7 @@ import IconSvg from 'ui/shared/IconSvg';
type Props = { type Props = {
rating?: number; rating?: number;
count?: number;
fullView?: boolean; fullView?: boolean;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
...@@ -24,7 +25,7 @@ const getTooltipText = (canRate: boolean | undefined) => { ...@@ -24,7 +25,7 @@ const getTooltipText = (canRate: boolean | undefined) => {
}; };
const TriggerButton = ( const TriggerButton = (
{ rating, fullView, isActive, onClick, canRate }: Props, { rating, count, fullView, isActive, onClick, canRate }: Props,
ref: React.ForwardedRef<HTMLButtonElement>, ref: React.ForwardedRef<HTMLButtonElement>,
) => { ) => {
const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800'); const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
...@@ -75,8 +76,9 @@ const TriggerButton = ( ...@@ -75,8 +76,9 @@ const TriggerButton = (
/> />
) } ) }
{ (rating && !fullView) ? ( { (rating && !fullView) ? (
<chakra.span color={ textColor } transition="inherit"> <chakra.span color={ textColor } transition="inherit" display="inline-flex">
{ rating } { rating }
<Text variant="secondary" ml={ 1 }>({ count })</Text>
</chakra.span> </chakra.span>
) : ( ) : (
'Rate it!' 'Rate it!'
......
...@@ -28,11 +28,12 @@ export type RateFunction = ( ...@@ -28,11 +28,12 @@ export type RateFunction = (
function formatRatings(data: Airtable.Records<Airtable.FieldSet>) { function formatRatings(data: Airtable.Records<Airtable.FieldSet>) {
return data.reduce((acc: Record<string, AppRating>, record) => { return data.reduce((acc: Record<string, AppRating>, record) => {
const fields = record.fields as { appId: string | Array<string>; rating: number | undefined }; const fields = record.fields as { appId: string | Array<string>; rating: number | undefined; count?: number };
const appId = Array.isArray(fields.appId) ? fields.appId[0] : fields.appId; const appId = Array.isArray(fields.appId) ? fields.appId[0] : fields.appId;
acc[appId] = { acc[appId] = {
recordId: record.id, recordId: record.id,
value: fields.rating, value: fields.rating,
count: fields.count,
}; };
return acc; return acc;
}, {}); }, {});
...@@ -63,7 +64,7 @@ export default function useRatings() { ...@@ -63,7 +64,7 @@ export default function useRatings() {
return; return;
} }
try { try {
const data = await airtable('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all(); const data = await airtable('apps_ratings').select({ fields: [ 'appId', 'rating', 'count' ] }).all();
const ratings = formatRatings(data); const ratings = formatRatings(data);
setRatings(ratings); setRatings(ratings);
} catch (error) { } catch (error) {
......
...@@ -22,7 +22,7 @@ test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page }) ...@@ -22,7 +22,7 @@ test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page })
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg'))); await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg')));
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({ await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating&fields%5B%5D=count', (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(ratingsMock), body: JSON.stringify(ratingsMock),
})); }));
......
...@@ -35,7 +35,7 @@ const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, m ...@@ -35,7 +35,7 @@ const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, m
Method: 'eth_chainId', Method: 'eth_chainId',
ReturnType: numberToHex(Number(config.chain.id)), ReturnType: numberToHex(Number(config.chain.id)),
}); });
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({ await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating&fields%5B%5D=count', (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(ratingsMock), body: JSON.stringify(ratingsMock),
})); }));
......
import { IconButton, Tooltip, useClipboard, chakra, useDisclosure, Skeleton, useColorModeValue } from '@chakra-ui/react'; import { IconButton, Tooltip, useClipboard, chakra, useDisclosure, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
export interface Props { export interface Props {
...@@ -10,14 +11,18 @@ export interface Props { ...@@ -10,14 +11,18 @@ export interface Props {
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
size?: number; size?: number;
type?: 'link'; type?: 'link';
icon?: IconName;
variant?: string;
colorScheme?: string;
} }
const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type }: Props) => { const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type, icon, variant = 'simple', colorScheme }: Props) => {
const { hasCopied, onCopy } = useClipboard(text, 1000); const { hasCopied, onCopy } = useClipboard(text, 1000);
const [ copied, setCopied ] = useState(false); const [ copied, setCopied ] = useState(false);
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 // have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const iconColor = useColorModeValue('gray.400', 'gray.500'); const iconColor = useColorModeValue('gray.400', 'gray.500');
const iconName = icon || (type === 'link' ? 'link' : 'copy');
useEffect(() => { useEffect(() => {
if (hasCopied) { if (hasCopied) {
...@@ -40,10 +45,11 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type } ...@@ -40,10 +45,11 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type }
<Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }> <Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }>
<IconButton <IconButton
aria-label="copy" aria-label="copy"
icon={ <IconSvg name={ type === 'link' ? 'link' : 'copy' } boxSize={ size }/> } icon={ <IconSvg name={ iconName } boxSize={ size }/> }
boxSize={ size } boxSize={ size }
color={ iconColor } color={ iconColor }
variant="simple" variant={ variant }
colorScheme={ colorScheme }
display="inline-block" display="inline-block"
flexShrink={ 0 } flexShrink={ 0 }
onClick={ handleClick } onClick={ handleClick }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment