Commit 490d61b0 authored by Max Alekseenko's avatar Max Alekseenko

implement rating change

parent dc28dff6
......@@ -26,10 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string;
}
export type AppRating = {
recordId: string;
value: number;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport;
rating?: number;
ratingRecordId?: string;
rating?: AppRating;
}
export enum MarketplaceCategory {
......@@ -65,14 +69,3 @@ export type MarketplaceAppSecurityReportRaw = {
[chainId: string]: MarketplaceAppSecurityReport;
};
}
export type UserRatings = {
[appId: string]: number;
};
export type AppRatings = {
[appId: string]: {
recordId: string;
rating: number;
};
};
......@@ -2,16 +2,16 @@ import { IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra,
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void;
......@@ -21,8 +21,8 @@ interface Props extends MarketplaceAppWithSecurityReport {
onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
showContractList: (id: string, type: ContractListTypes) => void;
userRating: number | undefined;
rateApp: (appId: string, recordId: string | undefined, rating: number, source: EventPayload<EventTypes.APP_FEEDBACK>['Source']) => void;
userRating?: AppRating;
rateApp: RateFunction;
isSendingRating: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
......@@ -47,7 +47,6 @@ const MarketplaceAppCard = ({
className,
showContractList,
rating,
ratingRecordId,
userRating,
rateApp,
isSendingRating,
......@@ -175,7 +174,6 @@ const MarketplaceAppCard = ({
<Rating
appId={ id }
rating={ rating }
recordId={ ratingRecordId }
userRating={ userRating }
rate={ rateApp }
isSending={ isSendingRating }
......
......@@ -15,7 +15,10 @@ const props = {
data: {
...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'],
rating: 4.3,
rating: {
recordId: 'test',
value: 4.3,
},
} as MarketplaceAppWithSecurityReport,
isFavorite: false,
userRating: undefined,
......
......@@ -4,13 +4,12 @@ import {
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import * as mixpanel from 'lib/mixpanel/index';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
......@@ -18,6 +17,7 @@ import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
const feature = config.features.marketplace;
const isRatingEnabled = feature.isEnabled && feature.rating;
......@@ -28,8 +28,8 @@ type Props = {
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
userRating: number | undefined;
rateApp: (appId: string, recordId: string | undefined, rating: number, source: EventPayload<EventTypes.APP_FEEDBACK>['Source']) => void;
userRating?: AppRating;
rateApp: RateFunction;
isSendingRating: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
......@@ -64,7 +64,6 @@ const MarketplaceAppModal = ({
categories,
securityReport,
rating,
ratingRecordId,
} = data;
const socialLinks = [
......@@ -175,7 +174,6 @@ const MarketplaceAppModal = ({
<Rating
appId={ id }
rating={ rating }
recordId={ ratingRecordId }
userRating={ userRating }
rate={ rateApp }
isSending={ isSendingRating }
......
......@@ -89,8 +89,7 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props)
) }
<Rating
appId={ appId }
rating={ ratings?.[appId]?.rating }
recordId={ ratings?.[appId]?.recordId }
rating={ ratings[appId] }
userRating={ userRatings[appId] }
rate={ rateApp }
isSending={ isSendingRating }
......
......@@ -2,13 +2,13 @@ import { Grid } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, UserRatings } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
import type { RateFunction } from './Rating/useRatings';
type Props = {
apps: Array<MarketplaceAppWithSecurityReport>;
......@@ -19,8 +19,8 @@ type Props = {
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
userRatings: UserRatings;
rateApp: (appId: string, recordId: string, rating: number, source: EventPayload<EventTypes.APP_FEEDBACK>['Source']) => void;
userRatings: Record<string, AppRating>;
rateApp: RateFunction;
isSendingRating: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
......@@ -68,7 +68,6 @@ const MarketplaceList = ({
securityReport={ app.securityReport }
showContractList={ showContractList }
rating={ app.rating }
ratingRecordId={ app.ratingRecordId }
userRating={ userRatings[app.id] }
rateApp={ rateApp }
isSendingRating={ isSendingRating }
......
import { Text, Flex, Spinner, Button } from '@chakra-ui/react';
import { Text, Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
import { mdash } from 'lib/html-entities';
import type { AppRating } from 'types/client/marketplace';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import Stars from './Stars';
import type { RateFunction } from './useRatings';
const ratingDescriptions = [ 'Terrible', 'Poor', 'Average', 'Very good', 'Outstanding' ];
type Props = {
appId: string;
recordId?: string;
userRating: number | undefined;
rate: (appId: string, recordId: string | undefined, rating: number, source: EventPayload<EventTypes.APP_FEEDBACK>['Source']) => void;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'];
};
const PopoverContent = ({ appId, recordId, userRating, rate, isSending, source }: Props) => {
const PopoverContent = ({ appId, rating, userRating, rate, isSending, source }: Props) => {
const [ hovered, setHovered ] = React.useState(-1);
const [ selected, setSelected ] = React.useState(-1);
const filledIndex = React.useMemo(() => {
if (hovered >= 0) {
return hovered;
}
return userRating?.value ? userRating?.value - 1 : -1;
}, [ userRating, hovered ]);
const handleMouseOverFactory = React.useCallback((index: number) => () => {
setHovered(index);
}, []);
const handleSelectFactory = React.useCallback((index: number) => () => {
setSelected(index);
}, []);
const handleMouseOut = React.useCallback(() => {
setHovered(-1);
}, []);
const handleRate = React.useCallback(() => {
if (selected < 0) {
return;
}
rate(appId, recordId, selected + 1, source);
}, [ appId, recordId, selected, rate, source ]);
if (userRating) {
return (
<>
<Flex alignItems="center">
<IconSvg
name="verified"
color="green.400"
boxSize="30px"
mr={ 1 }
ml="-5px"
/>
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
App is already rated by you
</Text>
</Flex>
<Flex alignItems="center" h="32px">
<IconSvg
name="star_filled"
color="yellow.400"
boxSize={ 5 }
mr={ 1 }
/>
<Text fontSize="md" fontWeight="500" mr={ 3 }>
{ userRating.toFixed(1) }
</Text>
<Text fontSize="md">
{ mdash } { ratingDescriptions[ userRating - 1 ] }
</Text>
</Flex>
</>
);
}
const handleRateFactory = React.useCallback((index: number) => () => {
rate(appId, rating?.recordId, userRating?.recordId, index + 1, source);
}, [ appId, rating, rate, userRating, source ]);
if (isSending) {
return (
......@@ -85,25 +53,27 @@ const PopoverContent = ({ appId, recordId, userRating, rate, isSending, source }
return (
<>
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
How was your experience?
</Text>
<Flex alignItems="center">
{ userRating && (
<IconSvg name="verified" color="green.400" boxSize="30px" mr={ 1 } ml="-5px"/>
) }
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
{ userRating ? 'App is already rated by you' : 'How was your experience?' }
</Text>
</Flex>
<Flex alignItems="center" h="32px">
<Stars
filledIndex={ hovered >= 0 ? hovered : selected }
filledIndex={ filledIndex }
onMouseOverFactory={ handleMouseOverFactory }
onMouseOut={ handleMouseOut }
onClickFactory={ handleSelectFactory }
onClickFactory={ handleRateFactory }
/>
{ (hovered >= 0 || selected >= 0) && (
{ (filledIndex >= 0) && (
<Text fontSize="md" ml={ 2 }>
{ ratingDescriptions[ hovered >= 0 ? hovered : selected ] }
{ ratingDescriptions[filledIndex] }
</Text>
) }
</Flex>
<Button size="sm" px={ 4 } mt={ 3 } onClick={ handleRate } isDisabled={ selected < 0 }>
Rate it
</Button>
</>
);
};
......
import { Text, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Skeleton, useOutsideClick, Box } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import Popover from 'ui/shared/chakra/Popover';
......@@ -8,16 +10,16 @@ import Popover from 'ui/shared/chakra/Popover';
import Content from './PopoverContent';
import Stars from './Stars';
import TriggerButton from './TriggerButton';
import type { RateFunction } from './useRatings';
const feature = config.features.marketplace;
const isEnabled = feature.isEnabled && feature.rating;
type Props = {
appId: string;
rating?: number;
recordId?: string;
userRating: number | undefined;
rate: (appId: string, recordId: string | undefined, rating: number, source: EventPayload<EventTypes.APP_FEEDBACK>['Source']) => void;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
isLoading?: boolean;
fullView?: boolean;
......@@ -26,7 +28,7 @@ type Props = {
};
const Rating = ({
appId, rating, recordId, userRating, rate,
appId, rating, userRating, rate,
isSending, isLoading, fullView, canRate, source,
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
......@@ -47,8 +49,8 @@ const Rating = ({
>
{ fullView && (
<>
<Stars filledIndex={ (rating || 0) - 1 }/>
<Text fontSize="md" ml={ 1 }>{ rating }</Text>
<Stars filledIndex={ (rating?.value || 0) - 1 }/>
<Text fontSize="md" ml={ 1 }>{ rating?.value }</Text>
</>
) }
<Box ref={ popoverRef }>
......@@ -66,7 +68,7 @@ const Rating = ({
<PopoverBody p={ 4 }>
<Content
appId={ appId }
recordId={ recordId }
rating={ rating }
userRating={ userRating }
rate={ rate }
isSending={ isSending }
......
import { Button, chakra, useColorModeValue, Tooltip, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
rating?: number;
rating?: AppRating;
fullView?: boolean;
isActive: boolean;
onClick: () => void;
......@@ -76,7 +78,7 @@ const TriggerButton = (
) }
{ (rating && !fullView) ? (
<chakra.span color={ textColor } transition="inherit">
{ rating }
{ rating.value }
</chakra.span>
) : (
'Rate it!'
......
......@@ -2,7 +2,7 @@ import Airtable from 'airtable';
import { useEffect, useState, useCallback } from 'react';
import { useAccount } from 'wagmi';
import type { UserRatings, AppRatings } from 'types/client/marketplace';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
......@@ -16,6 +16,28 @@ const base = (feature.isEnabled && feature.rating) ?
new Airtable({ apiKey: feature.rating.airtableApiKey }).base(feature.rating.airtableBaseId) :
undefined;
export type RateFunction = (
appId: string,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => void;
function formatRatings(data: Airtable.Records<Airtable.FieldSet>) {
return data.reduce((acc: Record<string, AppRating>, record) => {
const fields = record.fields as { appId: string; rating: number };
if (!fields.appId || typeof fields.rating !== 'number') {
return acc;
}
acc[fields.appId] = {
recordId: record.id,
value: fields.rating,
};
return acc;
}, {});
}
export default function useRatings() {
const { address } = useAccount();
const toast = useToast();
......@@ -29,8 +51,8 @@ export default function useRatings() {
},
});
const [ ratings, setRatings ] = useState<AppRatings>({});
const [ userRatings, setUserRatings ] = useState<UserRatings>({});
const [ ratings, setRatings ] = useState<Record<string, AppRating>>({});
const [ userRatings, setUserRatings ] = useState<Record<string, AppRating>>({});
const [ isRatingLoading, setIsRatingLoading ] = useState<boolean>(false);
const [ isUserRatingLoading, setIsUserRatingLoading ] = useState<boolean>(false);
const [ isSending, setIsSending ] = useState<boolean>(false);
......@@ -41,14 +63,7 @@ export default function useRatings() {
return;
}
const data = await base('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all();
const ratings = data.reduce((acc: AppRatings, record) => {
const fields = record.fields as { appId: string; rating: number };
acc[fields.appId] = {
recordId: record.id,
rating: fields.rating,
};
return acc;
}, {});
const ratings = formatRatings(data);
setRatings(ratings);
}, []);
......@@ -64,20 +79,13 @@ export default function useRatings() {
useEffect(() => {
async function fetchUserRatings() {
setIsUserRatingLoading(true);
let userRatings = {} as UserRatings;
let userRatings = {} as Record<string, AppRating>;
if (address && base) {
const data = await base('users_ratings').select({
filterByFormula: `address = "${ address }"`,
fields: [ 'appId', 'rating' ],
}).all();
userRatings = data.reduce((acc: UserRatings, record) => {
const fields = record.fields as { appId: string; rating: number };
if (!fields.appId || typeof fields.rating !== 'number') {
return acc;
}
acc[fields.appId] = fields.rating;
return acc;
}, {});
userRatings = formatRatings(data);
}
setUserRatings(userRatings);
setIsUserRatingLoading(false);
......@@ -93,16 +101,18 @@ export default function useRatings() {
const rateApp = useCallback(async(
appId: string,
recordId: string | undefined,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => {
setIsSending(true);
try {
if (!address || !base) {
throw new Error('Address is missing');
}
let appRecordId = recordId;
if (!appRecordId) {
const records = await base('apps_ratings').create([ { fields: { appId } } ]);
appRecordId = records[0].id;
......@@ -110,22 +120,36 @@ export default function useRatings() {
throw new Error('Record ID is missing');
}
}
await base('users_ratings').create([
{
fields: {
address,
appRecordId: [ appRecordId ],
rating,
if (!userRecordId) {
const userRecords = await base('users_ratings').create([
{
fields: {
address,
appRecordId: [ appRecordId ],
rating,
},
},
]);
userRecordId = userRecords[0].id;
} else {
await base('users_ratings').update(userRecordId, { rating });
}
setUserRatings({
...userRatings,
[appId]: {
recordId: userRecordId,
value: rating,
},
]);
setUserRatings({ ...userRatings, [appId]: rating });
});
fetchRatings();
toast({
status: 'success',
title: 'Awesome! Thank you 💜',
description: 'Your rating improves the service',
});
fetchRatings();
mixpanel.logEvent(
mixpanel.EventTypes.APP_FEEDBACK,
{ Action: 'Rating', Source: source, AppId: appId, Score: rating },
......@@ -137,6 +161,7 @@ export default function useRatings() {
description: 'Please try again later',
});
}
setIsSending(false);
}, [ address, userRatings, fetchRatings, toast ]);
......
......@@ -97,8 +97,7 @@ export default function useMarketplaceApps(
data?.map((app) => ({
...app,
securityReport: securityReports?.[app.id],
rating: ratings?.[app.id]?.rating,
ratingRecordId: ratings?.[app.id]?.recordId,
rating: ratings?.[app.id],
})),
[ data, securityReports, ratings ]);
......@@ -110,7 +109,7 @@ export default function useMarketplaceApps(
return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0);
}
if (sorting === 'rating') {
return (b.rating || 0) - (a.rating || 0);
return (b.rating?.value || 0) - (a.rating?.value || 0);
}
return 0;
}) || [];
......
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