[Quests] Frontend (#437)

* squash frontend changes

* Style quest explorer

* Style quest page

* Dates

* Dates

* Typecheck

* Prettier

* Fix create page layout

* Update only OPEN quests

* Repetition info

* Fix create quest errors

* Quest form Textarea

* Quest form Textarea

* Truncate texts

* Redirect if user not logged in

* Tooltips

* Factorize skills tags

* fix username in completions

* Metafam as default guild on creation

* Layouts

* Remove todo

* cooldown

* Rename to "claim quest"

* squash frontend changes

* Style quest explorer

* Style quest page

* Dates

* Dates

* Typecheck

* Prettier

* Fix create page layout

* Update only OPEN quests

* Repetition info

* Fix create quest errors

* Quest form Textarea

* Quest form Textarea

* Truncate texts

* Redirect if user not logged in

* Tooltips

* Factorize skills tags

* fix username in completions

* Metafam as default guild on creation

* Layouts

* Remove todo

* cooldown

* Rename to "claim quest"

* Move ConfirmModal in ds

* Extract pSeed balance

* Fix "created by me" switch

* Reword complete quest

* Style quest form

* prettier

* lint
This commit is contained in:
Pacien Boisson
2021-04-08 15:32:27 +04:00
committed by GitHub
parent d16f866391
commit dfff04ebaa
48 changed files with 2564 additions and 109 deletions

View File

@@ -520,8 +520,11 @@
- status
- title
filter:
created_by_player_id:
_eq: X-Hasura-User-Id
_and:
- created_by_player_id:
_eq: X-Hasura-User-Id
- status:
_eq: OPEN
check:
_or:
- repetition:

View File

@@ -0,0 +1,74 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
} from '@chakra-ui/react';
import React, { useRef } from 'react';
import { MetaButton } from './MetaButton';
type Props = {
isOpen: boolean,
onNope: () => void;
onYep: () => void;
header?: React.ReactNode,
body?: React.ReactNode,
loading?: boolean,
loadingText?: string,
}
export const ConfirmModal: React.FC<Props> = ({
isOpen,
onNope,
onYep,
header,
body,
loading,
loadingText,
}) => {
const cancelRef = useRef<HTMLButtonElement>(null)
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onNope}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{header || ' Are you sure ?'}
</AlertDialogHeader>
{body &&
<AlertDialogBody>
{body}
</AlertDialogBody>
}
<AlertDialogFooter>
<MetaButton
ref={cancelRef}
onClick={onNope}
isDisabled={loading}
>
Nope
</MetaButton>
<MetaButton
colorScheme="red"
onClick={onYep}
isLoading={loading}
loadingText={loadingText}
ml={3}
>
Yep
</MetaButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -2,11 +2,12 @@ import { Button, ButtonProps } from '@chakra-ui/react';
import React from 'react';
type LinkProps = { href?: string; target?: '_blank' };
type RefProps = { ref?: React.Ref<any> };
export const MetaButton: React.FC<ButtonProps & LinkProps> = ({
children,
...props
}) => (
export const MetaButton: React.FC<ButtonProps & LinkProps & RefProps> = React.forwardRef<HTMLButtonElement>((
{ children, ...props },
ref,
) => (
<Button
colorScheme="purple"
textTransform="uppercase"
@@ -14,8 +15,9 @@ export const MetaButton: React.FC<ButtonProps & LinkProps> = ({
letterSpacing="0.1em"
size="lg"
fontSize="sm"
ref={ref}
{...props}
>
{children}
</Button>
);
));

View File

@@ -1,15 +1,18 @@
import { Tag, TagProps } from '@chakra-ui/react';
import React from 'react';
export const MetaTag: React.FC<TagProps> = ({ children, ...props }) => (
<Tag
fontFamily="body"
fontSize="sm"
fontWeight="bold"
backgroundColor="purpleTag"
color="white"
{...props}
>
{children}
</Tag>
export const MetaTag: React.FC<TagProps> = React.forwardRef<HTMLSpanElement>(
({ children, ...props }, ref) => (
<Tag
fontFamily="body"
fontSize="sm"
fontWeight="bold"
backgroundColor="purpleTag"
color="white"
ref={ref}
{...props}
>
{children}
</Tag>
),
);

View File

@@ -1,4 +1,5 @@
export { BoxedNextImage } from './BoxedNextImage';
export { ConfirmModal } from './ConfirmModal';
export { BrightIdIcon, Icon3box } from './icons';
export { LoadingState } from './LoadingState';
export { MetaBox } from './MetaBox';
@@ -14,6 +15,12 @@ export { theme as MetaTheme } from './theme';
export { H1, P } from './typography';
export { EmailIcon } from '@chakra-ui/icons';
export {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Avatar,
Badge,
Box,
@@ -29,6 +36,7 @@ export {
Flex,
FlexProps,
Grid,
GridItem,
Heading,
HStack,
Icon,
@@ -50,7 +58,9 @@ export {
Spacer,
Spinner,
Stack,
Switch,
Text,
Textarea,
Tooltip,
useDisclosure,
useTheme,

View File

@@ -1,6 +1,7 @@
import { extendTheme, Theme as ChakraTheme } from '@chakra-ui/react';
import { colors, MetaColors } from './colors';
import { textStyles } from './texts';
type Theme = ChakraTheme & {
colors: MetaColors;
@@ -22,6 +23,7 @@ export const theme: Theme = extendTheme({
},
},
colors,
textStyles,
fonts: {
body: '"IBM Plex Sans", sans-serif',
mono: '"IBM Plex Mono", monospace',

View File

@@ -0,0 +1,8 @@
export const textStyles = {
caption: {
fontFamily: 'mono',
fontSize: 'sm',
color: 'blueLight',
textTransform: 'uppercase',
},
}

View File

@@ -49,7 +49,7 @@ export const AllTypes = () => (
</Text>
</Box>
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
About me
</Text>

View File

@@ -0,0 +1,73 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
MetaButton,
} from '@metafam/ds';
import React, { useRef } from 'react';
type Props = {
isOpen: boolean,
onNope: () => void;
onYep: () => void;
header?: React.ReactNode,
body?: React.ReactNode,
loading?: boolean,
loadingText?: string,
}
export const ConfirmModal: React.FC<Props> = ({
isOpen,
onNope,
onYep,
header,
body,
loading,
loadingText,
}) => {
const cancelRef = useRef<HTMLButtonElement>(null)
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onNope}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{header || ' Are you sure ?'}
</AlertDialogHeader>
{body &&
<AlertDialogBody>
{body}
</AlertDialogBody>
}
<AlertDialogFooter>
<MetaButton
ref={cancelRef}
onClick={onNope}
isDisabled={loading}
>
Nope
</MetaButton>
<MetaButton
colorScheme="red"
onClick={onYep}
isLoading={loading}
loadingText={loadingText}
ml={3}
>
Yep
</MetaButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -38,7 +38,7 @@ export const GuildTile: React.FC<Props> = ({ guild }) => (
) : null}
{guild.description ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
ABOUT
</Text>
<Text fontSize="sm">{guild.description}</Text>
@@ -47,7 +47,7 @@ export const GuildTile: React.FC<Props> = ({ guild }) => (
</MetaTileHeader>
<MetaTileBody>
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
LINKS
</Text>
<HStack mt="2">

View File

@@ -87,7 +87,7 @@ export const PatronTile: React.FC<Props> = ({ patron }) => {
</Wrap>
{player.box_profile?.description ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
ABOUT
</Text>
<Text fontSize="sm">{player.box_profile.description}</Text>
@@ -97,7 +97,7 @@ export const PatronTile: React.FC<Props> = ({ patron }) => {
<MetaTileBody>
{player.daohausMemberships.length ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
MEMBER OF
</Text>
<PlayerTileMemberships player={player} />
@@ -105,7 +105,7 @@ export const PatronTile: React.FC<Props> = ({ patron }) => {
) : null}
{player.Accounts.length ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
CONTACT
</Text>
<HStack mt="2">

View File

@@ -15,8 +15,8 @@ import {
import { MetaLink } from 'components/Link';
import { PlayerContacts } from 'components/Player/PlayerContacts';
import { PlayerTileMemberships } from 'components/Player/PlayerTileMemberships';
import { PlayerTileSkills } from 'components/Player/PlayerTileSkills';
import { PlayerFragmentFragment } from 'graphql/autogen/types';
import { SkillsTags } from 'components/Skills';
import { PlayerFragmentFragment, Skill } from 'graphql/autogen/types';
import React from 'react';
import {
getPlayerCoverImage,
@@ -83,7 +83,7 @@ export const PlayerTile: React.FC<Props> = ({ player }) => {
</Wrap>
{player.box_profile?.description ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
ABOUT
</Text>
<Text fontSize="sm">{player.box_profile.description}</Text>
@@ -93,16 +93,16 @@ export const PlayerTile: React.FC<Props> = ({ player }) => {
<MetaTileBody>
{player.Player_Skills.length ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
SKILLS
</Text>
<PlayerTileSkills player={player} />
<SkillsTags skills={player.Player_Skills.map(s => s.Skill) as Skill[]} />
</VStack>
) : null}
{player.daohausMemberships.length ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
MEMBER OF
</Text>
<PlayerTileMemberships player={player} />
@@ -111,7 +111,7 @@ export const PlayerTile: React.FC<Props> = ({ player }) => {
{player.Accounts.length ? (
<VStack spacing={2} align="stretch">
<Text fontFamily="mono" fontSize="sm" color="blueLight">
<Text textStyle="caption">
CONTACT
</Text>
<HStack mt="2">

View File

@@ -1,35 +0,0 @@
import { MetaTag, Wrap, WrapItem } from '@metafam/ds';
import { PlayerFragmentFragment } from 'graphql/autogen/types';
import { SkillColors } from 'graphql/types';
import React from 'react';
type Props = {
player: PlayerFragmentFragment;
};
const SHOW_SKILLS = 4;
export const PlayerTileSkills: React.FC<Props> = ({ player }) => {
return (
<Wrap>
{player.Player_Skills.slice(0, SHOW_SKILLS).map(({ Skill }) => (
<WrapItem key={Skill.id}>
<MetaTag
size="md"
fontWeight="normal"
backgroundColor={SkillColors[Skill.category]}
>
{Skill.name}
</MetaTag>
</WrapItem>
))}
{player.Player_Skills.length > SHOW_SKILLS && (
<WrapItem>
<MetaTag size="md" fontWeight="normal">
{`+${player.Player_Skills.length - SHOW_SKILLS}`}
</MetaTag>
</WrapItem>
)}
</Wrap>
);
};

View File

@@ -0,0 +1,94 @@
import {
ConfirmModal,
HStack,
Input,
MetaButton,
Text,
VStack,
} from '@metafam/ds';
import {
CreateQuestCompletionInput,
QuestFragmentFragment,
} from 'graphql/autogen/types';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { UriRegexp } from '../../utils/questHelpers';
const validations = {
submission_text: {
required: true,
},
submission_link: {
pattern: UriRegexp,
},
};
type Props = {
quest: QuestFragmentFragment;
onSubmit: (data: CreateQuestCompletionInput) => void;
success?: boolean;
fetching?: boolean;
};
export const CompletionForm: React.FC<Props> = ({
quest,
onSubmit,
success,
fetching,
}) => {
const { register, errors, handleSubmit } = useForm<
CreateQuestCompletionInput
>();
const [exitAlert, setExitAlert] = useState<boolean>(false);
const router = useRouter();
return (
<VStack>
<Text>Description</Text>
<Input
background="dark"
placeholder="What did you do ?"
isRequired
name="submission_text"
ref={register(validations.submission_text)}
isInvalid={!!errors.submission_text}
/>
<Text>Link</Text>
<Input
background="dark"
placeholder="External link"
name="submission_link"
ref={register(validations.submission_link)}
isInvalid={!!errors.submission_link}
/>
<HStack>
<MetaButton
variant="outline"
onClick={() => setExitAlert(true)}
isDisabled={fetching || success}
>
Cancel
</MetaButton>
<MetaButton
mt={10}
isLoading={fetching}
loadingText="Submitting..."
onClick={handleSubmit(onSubmit)}
isDisabled={success}
>
Submit
</MetaButton>
</HStack>
<ConfirmModal
isOpen={exitAlert}
onNope={() => setExitAlert(false)}
onYep={() => router.push(`/quest/${quest.id}`)}
header="Are you sure you want to leave ?"
/>
</VStack>
);
};

View File

@@ -0,0 +1,181 @@
import {
Avatar,
Box,
ConfirmModal,
HStack,
MetaButton,
Text,
useToast,
VStack,
} from '@metafam/ds';
import { MetaLink } from 'components/Link';
import {
Player,
Quest,
QuestCompletionStatus_ActionEnum,
QuestCompletionStatus_Enum,
QuestWithCompletionFragmentFragment,
useUpdateQuestCompletionMutation,
} from 'graphql/autogen/types';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { FaExternalLinkAlt } from 'react-icons/fa';
import { useUser } from '../../lib/hooks';
import { getPlayerName } from '../../utils/playerHelpers';
import { CompletionStatusTag } from './QuestTags';
interface AlertSubmission {
status: QuestCompletionStatus_ActionEnum;
quest_completion_id: string;
}
type Props = {
quest: QuestWithCompletionFragmentFragment;
};
export const QuestCompletions: React.FC<Props> = ({ quest }) => {
const { user } = useUser();
const toast = useToast();
const [
alertSubmission,
setAlertSubmission,
] = useState<AlertSubmission | null>(null);
const [
updateQuestCompletionStatus,
updateQuestCompletion,
] = useUpdateQuestCompletionMutation();
const isMyQuest = user?.id === (quest as Quest).player.id;
const onConfirmAlert = useCallback(() => {
if (!alertSubmission) return;
updateQuestCompletion({
quest_completion_id: alertSubmission.quest_completion_id,
status: alertSubmission.status,
}).then((response) => {
if (response.data?.updateQuestCompletion?.success) {
toast({
title: 'Quest completion updated',
description: `The completion is now ${alertSubmission.status}`,
status: 'success',
isClosable: true,
duration: 4000,
});
setAlertSubmission(null);
} else {
toast({
title: 'Error while updating completion',
description:
response.error?.message ||
response.data?.updateQuestCompletion?.error ||
'unknown error',
status: 'error',
isClosable: true,
duration: 10000,
});
}
});
}, [alertSubmission, updateQuestCompletion, toast]);
return (
<Box>
<VStack spacing={4}>
{quest.quest_completions.length === 0 && (
<Text>There are no proposal for this quest yet.</Text>
)}
{quest.quest_completions.map((questCompletion) => (
<Box key={questCompletion.id} w="100%">
<HStack px={4} py={4}>
<Avatar name={getPlayerName(questCompletion.player as Player)} />
<CompletionStatusTag status={questCompletion.status} />
<Text>
<i>
by{' '}
<MetaLink
as={`/player/${questCompletion.player.username}`}
href="/player/[username]"
>
{getPlayerName(questCompletion.player as Player)}
</MetaLink>
</i>
</Text>
<Text>{moment(questCompletion.submitted_at).fromNow()}</Text>
</HStack>
<Box bg="whiteAlpha.200" px={8} py={4} rounded="lg">
<Text textStyle="caption" mb={2}>
Message
</Text>
<Text>{questCompletion.submission_text}</Text>
{questCompletion.submission_link && (
<MetaLink href={questCompletion.submission_link} isExternal>
<MetaButton
variant="link"
colorScheme="cyan"
leftIcon={<FaExternalLinkAlt />}
size="md"
mt={4}
>
Open link
</MetaButton>
</MetaLink>
)}
</Box>
{isMyQuest &&
questCompletion.status === QuestCompletionStatus_Enum.Pending && (
<HStack mt={4}>
<MetaButton
variant="solid"
size="md"
onClick={() =>
setAlertSubmission({
status: QuestCompletionStatus_ActionEnum.Accepted,
quest_completion_id: questCompletion.id,
})
}
>
Accept submission
</MetaButton>
<MetaButton
variant="outline"
colorScheme="pink"
size="md"
onClick={() =>
setAlertSubmission({
status: QuestCompletionStatus_ActionEnum.Rejected,
quest_completion_id: questCompletion.id,
})
}
>
Reject submission
</MetaButton>
</HStack>
)}
</Box>
))}
</VStack>
<ConfirmModal
isOpen={!!alertSubmission}
onNope={() => setAlertSubmission(null)}
onYep={onConfirmAlert}
loading={updateQuestCompletionStatus.fetching}
loadingText="Updating..."
header={
<>
{alertSubmission?.status ===
QuestCompletionStatus_ActionEnum.Accepted &&
'Are you sure you want to accept this submission ?'}
{alertSubmission?.status ===
QuestCompletionStatus_ActionEnum.Rejected &&
'Are you sure you want to reject this submission ?'}
</>
}
/>
</Box>
);
};

View File

@@ -0,0 +1,115 @@
import {
Avatar,
Box,
Flex,
Heading,
HStack,
MetaButton,
MetaTile,
MetaTileBody,
MetaTileHeader,
Text,
VStack,
} from '@metafam/ds';
import { MetaLink } from 'components/Link';
import {
Quest,
QuestRepetition_Enum,
QuestStatus_Enum,
QuestWithCompletionFragmentFragment,
Skill,
} from 'graphql/autogen/types';
import moment from 'moment';
import React from 'react';
import BackgroundImage from '../../assets/main-background.jpg';
import { useUser } from '../../lib/hooks';
import { SkillsTags } from '../Skills';
import { RepetitionTag, StatusTag } from './QuestTags';
type Props = {
quest: QuestWithCompletionFragmentFragment;
};
export const QuestDetails: React.FC<Props> = ({ quest }) => {
const { user } = useUser();
const isMyQuest = user?.id === (quest as Quest).player.id;
return (
<MetaTile maxW={undefined}>
<Box
bgImage={`url(${BackgroundImage})`}
bgSize="cover"
bgPosition="center"
position="absolute"
top="0"
left="0"
w="100%"
h="3.5rem"
/>
<Flex justify="center" mb={4}>
<Avatar
size="lg"
src={quest.guild.logo || undefined}
name={quest.guild.name}
/>
</Flex>
<MetaTileHeader>
<VStack>
<MetaLink as={`/quest/${quest.id}`} href="/quest/[id]">
<Heading size="sm" color="white" align="center">
{quest.title}
</Heading>
</MetaLink>
<HStack mt={2}>
<RepetitionTag
repetition={quest.repetition}
cooldown={quest.cooldown}
/>
<StatusTag status={quest.status} />
<Text>
<i>{moment(quest.created_at).fromNow()}</i>
</Text>
</HStack>
<HStack w="100%" mt={2}>
{isMyQuest && quest.status === QuestStatus_Enum.Open && (
<MetaLink as={`/quest/${quest.id}/edit`} href="/quest/[id]/edit">
<MetaButton size="md">Edit Quest</MetaButton>
</MetaLink>
)}
{quest.external_link && (
<MetaLink href={quest.external_link} isExternal>
<MetaButton variant="outline" colorScheme="cyan" size="md">
Open link
</MetaButton>
</MetaLink>
)}
</HStack>
</VStack>
</MetaTileHeader>
<MetaTileBody>
<VStack spacing={2} align="stretch">
<Text textStyle="caption">DESCRIPTION</Text>
<Text>{quest.description}</Text>
{quest.repetition === QuestRepetition_Enum.Recurring && (
<>
<Text textStyle="caption">Cooldown</Text>
<Text>
Doable every{' '}
{moment.duration(quest.cooldown, 'second').humanize()}
</Text>
</>
)}
<Text textStyle="caption">SKILLS</Text>
<SkillsTags
skills={quest.quest_skills.map((s) => s.skill) as Skill[]}
maxSkills={4}
/>
</VStack>
</MetaTileBody>
</MetaTile>
);
};

View File

@@ -0,0 +1,131 @@
import {
Flex,
MetaButton,
Select,
Switch,
Text,
Wrap,
WrapItem,
} from '@metafam/ds';
import {
GetQuestsQueryVariables,
Order_By,
QuestFragmentFragment,
QuestStatus_Enum,
} from 'graphql/autogen/types';
import React from 'react';
import { useUser } from '../../lib/hooks';
import { QuestAggregates } from '../../lib/hooks/quests';
type Props = {
quests: QuestFragmentFragment[];
aggregates: QuestAggregates;
queryVariables: GetQuestsQueryVariables;
setQueryVariable: (_: string, __: any) => void;
};
/* TODO
- text search
- remove limit
*/
export const QuestFilter: React.FC<Props> = ({
quests,
aggregates,
queryVariables,
setQueryVariable,
}) => {
const { user } = useUser();
const myId = user?.id;
return (
<Wrap justify="space-between">
<WrapItem>
<Wrap>
<WrapItem>
<Select
value={queryVariables.limit as number}
onChange={(e) =>
setQueryVariable('limit', Number(e.target.value))
}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</Select>
</WrapItem>
<WrapItem>
<Select
value={queryVariables.order as string}
onChange={(e) => setQueryVariable('order', e.target.value)}
>
<option value={Order_By.Desc}>Newest</option>
<option value={Order_By.Asc}>Oldest</option>
</Select>
</WrapItem>
<WrapItem>
<Select
value={queryVariables.status as string}
onChange={(e) => setQueryVariable('status', e.target.value)}
>
<option value={QuestStatus_Enum.Open}>Open</option>
<option value={QuestStatus_Enum.Closed}>Closed</option>
</Select>
</WrapItem>
<WrapItem>
<Select
value={(queryVariables.guild_id as string) || ''}
onChange={(e) => setQueryVariable('guild_id', e.target.value)}
>
<option value="">All guilds</option>
{aggregates.guilds &&
aggregates.guilds.map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
</WrapItem>
{myId && (
<WrapItem>
<Flex align="center">
<MetaButton
size="md"
colorScheme="cyan"
variant="outline"
px={4}
onClick={() =>
setQueryVariable(
'created_by_player_id',
queryVariables.created_by_player_id ? '' : myId,
)
}
>
<Text mr={2}>Created by me</Text>
<Switch
isChecked={
myId && queryVariables.created_by_player_id === myId
}
onClick={(e) => {
setQueryVariable(
'created_by_player_id',
queryVariables.created_by_player_id ? '' : myId,
);
e.preventDefault();
}}
/>
</MetaButton>
</Flex>
</WrapItem>
)}
</Wrap>
</WrapItem>
{quests && (
<WrapItem>
<Text align="center">{quests.length} quests</Text>
</WrapItem>
)}
</Wrap>
);
};

View File

@@ -0,0 +1,315 @@
import {
Box,
ConfirmModal,
Flex,
HStack,
Input,
MetaButton,
MetaTag,
Select,
Text,
Textarea,
VStack,
} from '@metafam/ds';
import {
GuildFragmentFragment,
QuestFragmentFragment,
QuestRepetition_Enum,
QuestStatus_Enum,
} from 'graphql/autogen/types';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
import { Controller, FieldError, useForm } from 'react-hook-form';
import { QuestRepetitionHint, UriRegexp } from '../../utils/questHelpers';
import { CategoryOption, SkillOption } from '../../utils/skillHelpers';
import { FlexContainer } from '../Container';
import { SkillsSelect } from '../Skills';
import { RepetitionColors } from './QuestTags';
const validations = {
title: {
required: true,
minLength: 4,
maxLength: 50,
},
description: {
required: true,
minLength: 4,
},
repetition: {
required: true,
},
guild_id: {
required: true,
},
external_link: {
pattern: UriRegexp,
},
cooldown: {
valueAsNumber: true,
min: 1,
},
};
export interface CreateQuestFormInputs {
title: string;
description: string | undefined | null;
repetition: QuestRepetition_Enum;
status: QuestStatus_Enum;
guild_id: string | null;
external_link: string | undefined | null;
cooldown: number | undefined | null;
skills: SkillOption[];
}
const MetaFamGuildId = 'f94b7cd4-cf29-4251-baa5-eaacab98a719';
const getDefaultFormValues = (
editQuest: QuestFragmentFragment | undefined,
guilds: GuildFragmentFragment[],
): CreateQuestFormInputs => ({
title: editQuest?.title || '',
repetition: editQuest?.repetition || QuestRepetition_Enum.Unique,
description: editQuest?.description || '',
external_link: editQuest?.external_link || '',
guild_id:
editQuest?.guild_id ||
guilds.find((g) => g.id === MetaFamGuildId)?.id ||
guilds[0].id,
status: editQuest?.status || QuestStatus_Enum.Open,
cooldown: editQuest?.cooldown || null,
skills: editQuest
? editQuest.quest_skills
.map((s) => s.skill)
.map((s) => ({
value: s.id,
label: s.name,
...s,
}))
: [],
});
type FieldProps = {
children: React.ReactNode;
label: string;
error?: FieldError;
};
const Field: React.FC<FieldProps> = ({ children, error, label }) => (
<Flex mb={2} w="100%" align="center" direction="column">
<Flex justify="space-between" w="100%" mb={2}>
<Text textStyle="caption" textAlign="left" ml={4}>
{label}
</Text>
<Text textStyle="caption" textAlign="left" color="red.400" mr={4}>
{error?.type === 'required' && 'Required'}
{error?.type === 'pattern' && 'Invalid URL'}
{error?.type === 'minLength' && 'Too short'}
{error?.type === 'maxLength' && 'Too long'}
{error?.type === 'min' && 'Too small'}
</Text>
</Flex>
{children}
</Flex>
);
type Props = {
guilds: GuildFragmentFragment[];
editQuest?: QuestFragmentFragment;
skillChoices: Array<CategoryOption>;
onSubmit: (data: CreateQuestFormInputs) => void;
success?: boolean;
fetching?: boolean;
submitLabel: string;
loadingLabel: string;
};
export const QuestForm: React.FC<Props> = ({
guilds,
skillChoices,
onSubmit,
success,
fetching,
submitLabel,
loadingLabel,
editQuest,
}) => {
const defaultValues = useMemo<CreateQuestFormInputs>(
() => getDefaultFormValues(editQuest, guilds),
[editQuest, guilds],
);
const { register, control, errors, watch, handleSubmit } = useForm<
CreateQuestFormInputs
>({
defaultValues,
});
const router = useRouter();
const [exitAlert, setExitAlert] = useState<boolean>(false);
const createQuestInput = watch();
console.log(errors);
return (
<Box w="100%" maxW="30rem">
<VStack>
<Field label="Title" error={errors.title}>
<Input
background="dark"
placeholder="Buidl stuff"
isRequired
name="title"
ref={register(validations.title)}
isInvalid={!!errors.title}
minLength={validations.title.minLength}
maxLength={validations.title.maxLength}
/>
</Field>
<Field label="Description" error={errors.description}>
<Textarea
background="dark"
placeholder="Please describe in details what needs to be done"
isRequired
name="description"
ref={register(validations.description)}
isInvalid={!!errors.description}
/>
</Field>
<Field label="Link" error={errors.external_link}>
<Input
background="dark"
placeholder="External link"
name="external_link"
ref={register(validations.external_link)}
isInvalid={!!errors.external_link}
/>
</Field>
<Field label="Repetition">
<Select
isRequired
name="repetition"
ref={register(validations.repetition)}
isInvalid={!!errors.repetition}
bg="dark"
color="white"
>
{Object.entries(QuestRepetition_Enum).map(([key, value]) => (
<option key={value} value={value}>
{key}
</option>
))}
</Select>
<MetaTag
size="md"
fontWeight="normal"
p={2}
mt={2}
backgroundColor={RepetitionColors[createQuestInput.repetition]}
>
{QuestRepetitionHint[createQuestInput.repetition]}
</MetaTag>
</Field>
{createQuestInput.repetition === QuestRepetition_Enum.Recurring && (
<Field label="Cooldown (hours)" error={errors.cooldown}>
<Input
isRequired
background="dark"
placeholder="3600"
name="cooldown"
type="number"
ref={register(validations.cooldown)}
isInvalid={!!errors.cooldown}
/>
</Field>
)}
<Field label="Guild">
<Select
isRequired
name="guild_id"
ref={register(validations.guild_id)}
isInvalid={!!errors.guild_id}
bg="dark"
color="white"
>
{guilds.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</Select>
</Field>
{editQuest && (
<Field label="Status">
<Select
isRequired
name="status"
bg="dark"
color="white"
ref={register}
isInvalid={!!errors.status}
>
{Object.entries(QuestStatus_Enum).map(([key, value]) => (
<option key={value} value={value}>
{key}
</option>
))}
</Select>
</Field>
)}
<Field label="Skills">
<FlexContainer w="100%" align="stretch" maxW="50rem">
<Controller
name="skills"
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<SkillsSelect
skillChoices={skillChoices}
skills={value}
setSkills={onChange}
placeHolder="Select required skills"
/>
)}
/>
</FlexContainer>
</Field>
<HStack justify="space-between" mt={4} w="100%">
<MetaButton
variant="outline"
colorScheme="pink"
onClick={() => setExitAlert(true)}
isDisabled={fetching || success}
>
Cancel
</MetaButton>
<MetaButton
mt={10}
isLoading={fetching}
loadingText={loadingLabel}
onClick={handleSubmit(onSubmit)}
isDisabled={success}
>
{submitLabel}
</MetaButton>
</HStack>
</VStack>
<ConfirmModal
isOpen={exitAlert}
onNope={() => setExitAlert(false)}
onYep={() =>
router.push(editQuest ? `/quest/${editQuest.id}` : '/quests')
}
header="Are you sure you want to leave ?"
/>
</Box>
);
};

View File

@@ -0,0 +1,29 @@
import { Box, SimpleGrid, Text } from '@metafam/ds';
import { QuestTile } from 'components/Quest/QuestTile';
import { QuestFragmentFragment } from 'graphql/autogen/types';
import React from 'react';
type Props = {
quests: QuestFragmentFragment[];
};
/* TODO
- infinite scroll
*/
export const QuestList: React.FC<Props> = ({ quests }) => (
<Box>
{quests.length > 0 ? (
<SimpleGrid
columns={[1, null, 2, 3]}
spacing="8"
autoRows="minmax(30rem, auto)"
>
{quests.map((q) => (
<QuestTile key={q.id} quest={q} />
))}
</SimpleGrid>
) : (
<Text>No quests found</Text>
)}
</Box>
);

View File

@@ -0,0 +1,77 @@
import { MetaTag, Tooltip } from '@metafam/ds';
import {
QuestCompletionStatus_Enum,
QuestRepetition_Enum,
QuestStatus_Enum,
} from 'graphql/autogen/types';
import moment from 'moment';
import React from 'react';
import { QuestRepetitionHint } from '../../utils/questHelpers';
export const RepetitionColors: Record<QuestRepetition_Enum, string> = {
[QuestRepetition_Enum.Recurring]: 'cyan.700',
[QuestRepetition_Enum.Personal]: 'blue.700',
[QuestRepetition_Enum.Unique]: 'yellow.700',
};
interface RepetitionProps {
repetition: QuestRepetition_Enum;
cooldown: number | undefined | null;
}
function getRepetitionText(props: RepetitionProps) {
if (props.cooldown && props.repetition === QuestRepetition_Enum.Recurring) {
const cd = moment.duration(7200, 'second').humanize();
return `${QuestRepetitionHint[QuestRepetition_Enum.Recurring]} (${cd})`;
}
return QuestRepetitionHint[props.repetition];
}
export const RepetitionTag: React.FC<RepetitionProps> = ({
repetition,
cooldown,
}) => (
<Tooltip label={getRepetitionText({ repetition, cooldown })}>
<MetaTag
size="md"
fontWeight="normal"
backgroundColor={RepetitionColors[repetition]}
>
{repetition}
</MetaTag>
</Tooltip>
);
export const StatusColors: Record<QuestStatus_Enum, string> = {
[QuestStatus_Enum.Open]: 'green.700',
[QuestStatus_Enum.Closed]: 'pink.700',
};
interface StatusProps {
status: QuestStatus_Enum;
}
export const StatusTag: React.FC<StatusProps> = ({ status }) => (
<MetaTag fontWeight="normal" size="md" backgroundColor={StatusColors[status]}>
{status}
</MetaTag>
);
export const CompletionStatusColors: Record<
QuestCompletionStatus_Enum,
string
> = {
[QuestCompletionStatus_Enum.Accepted]: 'green.700',
[QuestCompletionStatus_Enum.Rejected]: 'pink.700',
[QuestCompletionStatus_Enum.Pending]: 'yellow.700',
};
interface QuestCompletionProps {
status: QuestCompletionStatus_Enum;
}
export const CompletionStatusTag: React.FC<QuestCompletionProps> = ({
status,
}) => (
<MetaTag
fontWeight="normal"
size="md"
backgroundColor={CompletionStatusColors[status]}
>
{status}
</MetaTag>
);

View File

@@ -0,0 +1,78 @@
import {
Avatar,
Box,
Flex,
Heading,
HStack,
MetaTile,
MetaTileBody,
MetaTileHeader,
Text,
VStack,
} from '@metafam/ds';
import BackgroundImage from 'assets/main-background.jpg';
import { MetaLink } from 'components/Link';
import { QuestFragmentFragment, Skill } from 'graphql/autogen/types';
import moment from 'moment';
import React from 'react';
import { SkillsTags } from '../Skills';
import { RepetitionTag, StatusTag } from './QuestTags';
type Props = {
quest: QuestFragmentFragment;
};
export const QuestTile: React.FC<Props> = ({ quest }) => (
<MetaTile>
<Box
bgImage={`url(${BackgroundImage})`}
bgSize="cover"
bgPosition="center"
position="absolute"
top="0"
left="0"
w="100%"
h="3.5rem"
/>
<Flex justify="center" mb={4}>
<Avatar
size="lg"
src={quest.guild.logo || undefined}
name={quest.guild.name}
/>
</Flex>
<MetaTileHeader>
<VStack>
<MetaLink as={`/quest/${quest.id}`} href="/quest/[id]">
<Heading size="sm" color="white" align="center">
{quest.title}
</Heading>
</MetaLink>
<HStack mt={2}>
<RepetitionTag
repetition={quest.repetition}
cooldown={quest.cooldown}
/>
<StatusTag status={quest.status} />
<Text>
<i>{moment(quest.created_at).fromNow()}</i>
</Text>
</HStack>
</VStack>
</MetaTileHeader>
<MetaTileBody flex={1}>
<VStack spacing={2} align="stretch">
<Text textStyle="caption">DESCRIPTION</Text>
<Text noOfLines={4}>{quest.description}</Text>
<Text textStyle="caption">SKILLS</Text>
<SkillsTags
skills={quest.quest_skills.map((s) => s.skill) as Skill[]}
maxSkills={4}
/>
</VStack>
</MetaTileBody>
</MetaTile>
);

View File

@@ -0,0 +1,96 @@
import {
MetaTag, MetaTheme,
SelectSearch,
selectStyles,
Tooltip, Wrap, WrapItem,
} from '@metafam/ds';
import { Skill, SkillCategory_Enum } from 'graphql/autogen/types';
import { SkillColors } from 'graphql/types';
import React from 'react';
import { CategoryOption, SkillOption } from 'utils/skillHelpers';
export type SetupSkillsProps = {
skillChoices: Array<CategoryOption>;
skills: Array<SkillOption>;
setSkills: React.Dispatch<React.SetStateAction<Array<SkillOption>>>;
placeHolder: string;
id?: string;
};
export const SkillsSelect: React.FC<SetupSkillsProps> = ({
skillChoices,
skills,
setSkills,
placeHolder,
id,
}) => {
const styles: typeof selectStyles = {
...selectStyles,
multiValue: (s, { data }) => ({
...s,
background: SkillColors[data.category as SkillCategory_Enum],
color: MetaTheme.colors.white,
}),
multiValueLabel: (s, { data }) => ({
...s,
background: SkillColors[data.category as SkillCategory_Enum],
color: MetaTheme.colors.white,
}),
groupHeading: (s, { children }) => {
return {
...s,
...(selectStyles.groupHeading &&
selectStyles.groupHeading(s, { children })),
background: SkillColors[children as SkillCategory_Enum],
};
},
};
return (
<SelectSearch
isMulti
styles={styles}
value={skills}
onChange={(value) => setSkills(value as Array<SkillOption>)}
options={skillChoices}
autoFocus
closeMenuOnSelect={false}
placeholder={placeHolder}
id={`skills-select-container-${id || ''}`}
inputId={`skills-select-input-${id || ''}`}
/>
);
};
interface SkillsProps {
skills: Skill[];
maxSkills?: number;
}
export const SkillsTags: React.FC<SkillsProps> = ({
maxSkills = 4,
skills,
}) => (
<Wrap>
{skills.slice(0, maxSkills).map((skill) => (
<WrapItem key={skill.id}>
<Tooltip label={skill.category}>
<MetaTag
size="md"
fontWeight="normal"
backgroundColor={SkillColors[skill.category]}
>
{skill.name}
</MetaTag>
</Tooltip>
</WrapItem>
))}
{skills.length > maxSkills && (
<WrapItem>
<MetaTag size="md" fontWeight="normal">
{`+${skills.length - maxSkills}`}
</MetaTag>
</WrapItem>
)}
</Wrap>
);

View File

@@ -1,5 +1,5 @@
export const CONFIG = {
graphqlURL: ((() => {
graphqlURL: (() => {
const {
NEXT_PUBLIC_GRAPHQL_URL: url,
NEXT_PUBLIC_GRAPHQL_HOST: host,
@@ -10,7 +10,7 @@ export const CONFIG = {
return `https://${host}.onrender.com/v1/graphql`;
}
return 'http://localhost:8080/v1/graphql';
})()),
})(),
infuraId:
process.env.NEXT_PUBLIC_INFURA_ID || '781d8466252d47508e177b8637b1c2fd',
openseaApiKey: process.env.NEXT_OPENSEA_API_KEY || undefined,

View File

@@ -1,8 +1,73 @@
import { createClient } from 'urql';
import {
initUrqlClient,
NextComponentType,
withUrqlClient,
WithUrqlProps,
} from 'next-urql';
import React, { createElement } from 'react';
import {
cacheExchange,
Client,
createClient,
dedupExchange,
fetchExchange,
ssrExchange,
} from 'urql';
import { CONFIG } from '../config';
import { getTokenFromStore } from '../lib/auth';
export const client = createClient({
url: CONFIG.graphqlURL,
suspense: false,
});
export const getSsrClient = (): [Client, ReturnType<typeof ssrExchange>] => {
const ssrCache = ssrExchange({ isClient: false });
const ssrClient = initUrqlClient(
{
url: CONFIG.graphqlURL,
exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange],
},
false,
);
if (!ssrClient) throw new Error('wtf');
return [ssrClient, ssrCache];
};
// We do this to enable ssr cache on pages that are not directly wrapped in 'withUrqlClient' (but on _app)
// https://github.com/FormidableLabs/urql/issues/1481
const customWithUrqlClient = (
WithUrql: NextComponentType,
): React.FC<WithUrqlProps> => ({ pageProps, urqlState, ...props }) => {
return createElement(WithUrql, {
urqlState: pageProps.urqlState || urqlState,
pageProps,
...props,
});
};
export const wrapUrqlClient = (AppOrPage: React.FC<any>) =>
customWithUrqlClient(
withUrqlClient(
(_ssrExchange, ctx) => ({
url: CONFIG.graphqlURL,
fetchOptions: () => {
const token = ctx
? ctx?.req?.headers?.authorization
: getTokenFromStore();
return {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
};
},
}),
{
neverSuspend: true,
ssr: false,
},
)(AppOrPage),
);

View File

@@ -77,6 +77,81 @@ export const GuildFragment = gql`
}
`;
export const QuestFragment = gql`
fragment QuestFragment on quest {
id
created_at
cooldown
description
external_link
guild_id
status
title
repetition
guild {
name
logo
}
player {
id
ethereum_address
}
quest_skills {
skill {
id
name
category
}
}
}
`;
export const QuestWithCompletionFragment = gql`
fragment QuestWithCompletionFragment on quest {
id
created_at
cooldown
description
external_link
guild_id
status
title
repetition
guild {
name
logo
}
quest_skills {
skill {
id
name
category
}
}
quest_completions(order_by: [{ submitted_at: desc }]) {
...QuestCompletionFragment
player {
id
ethereum_address
username
}
}
}
`;
export const QuestCompletionFragment = gql`
fragment QuestCompletionFragment on quest_completion {
id
completed_by_player_id
status
submission_link
submission_text
submitted_at
}
`;
export const TokenBalancesFragment = gql`
fragment TokenBalancesFragment on TokenBalances {
address: id

View File

@@ -12,6 +12,16 @@ import { client } from './client';
import { PlayerFragment, TokenBalancesFragment } from './fragments';
import { Patron } from './types';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetpSeedBalance($address: String!) {
getTokenBalances(address: $address) {
...TokenBalancesFragment
}
}
${TokenBalancesFragment}
`;
const patronsQuery = gql`
query GetPatrons($addresses: [String!], $limit: Int) {
player(where: { ethereum_address: { _in: $addresses } }, limit: $limit) {

View File

@@ -0,0 +1,68 @@
import gql from 'fake-tag';
import { Client } from 'urql';
import {
GetQuestDocument,
GetQuestQuery,
GetQuestQueryVariables,
GetQuestWithCompletionsDocument,
GetQuestWithCompletionsQuery,
GetQuestWithCompletionsQueryVariables,
} from './autogen/types';
import { client as defaultClient } from './client';
import {
PlayerFragment,
QuestFragment,
QuestWithCompletionFragment,
} from './fragments';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetQuest($id: uuid!) {
quest_by_pk(id: $id) {
...QuestFragment
}
}
${QuestFragment}
`;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetQuestWithCompletions($id: uuid!) {
quest_by_pk(id: $id) {
...QuestWithCompletionFragment
player {
...PlayerFragment
}
}
}
${QuestWithCompletionFragment}
${PlayerFragment}
`;
export const getQuest = async (
id: string | undefined,
client: Client = defaultClient,
) => {
if (!id) return null;
const { data } = await client
.query<GetQuestQuery, GetQuestQueryVariables>(GetQuestDocument, { id })
.toPromise();
return data?.quest_by_pk;
};
export const getQuestWithCompletions = async (
id: string | undefined,
client: Client = defaultClient,
) => {
if (!id) return null;
const { data } = await client
.query<GetQuestWithCompletionsQuery, GetQuestWithCompletionsQueryVariables>(
GetQuestWithCompletionsDocument,
{ id },
)
.toPromise();
return data?.quest_by_pk;
};

View File

@@ -0,0 +1,114 @@
import gql from 'fake-tag';
import { Client } from 'urql';
import {
GetQuestIdsDocument,
GetQuestIdsQuery,
GetQuestIdsQueryVariables,
GetQuestsDocument,
GetQuestsQuery,
GetQuestsQueryVariables,
Order_By,
QuestStatus_Enum,
} from './autogen/types';
import { client as defaultClient } from './client';
import { QuestFragment } from './fragments';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetQuestIds($limit: Int) {
quest(limit: $limit, order_by: { created_at: desc }) {
id
}
}
`;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetQuests(
$limit: Int
$status: QuestStatus_enum
$guild_id: uuid
$order: order_by
$created_by_player_id: uuid
) {
quest(
limit: $limit
order_by: { created_at: $order }
where: {
status: { _eq: $status }
guild_id: { _eq: $guild_id }
created_by_player_id: { _eq: $created_by_player_id }
}
) {
...QuestFragment
}
quest_aggregate(distinct_on: guild_id) {
nodes {
guild_id
guild {
name
}
}
}
}
${QuestFragment}
`;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
gql`
query GetQuestGuilds {
quest_aggregate(distinct_on: guild_id) {
nodes {
guild_id
guild {
name
}
}
}
}
`;
export const defaultQueryVariables: GetQuestsQueryVariables = {
limit: 10,
status: QuestStatus_Enum.Open,
guild_id: undefined,
order: Order_By.Desc,
created_by_player_id: undefined,
};
export const getQuestIds = async (
limit = 50,
client: Client = defaultClient,
) => {
const { data } = await client
.query<GetQuestIdsQuery, GetQuestIdsQueryVariables>(GetQuestIdsDocument, {
limit,
})
.toPromise();
return data?.quest.map((q) => q.id) || [];
};
export const getQuests = async (
queryVariables = defaultQueryVariables,
client: Client = defaultClient,
) => {
const { data, error } = await client
.query<GetQuestsQuery, GetQuestsQueryVariables>(
GetQuestsDocument,
queryVariables,
)
.toPromise();
if (!data) {
if (error) {
throw error;
}
return [];
}
return data.quest;
};

View File

@@ -0,0 +1,14 @@
import gql from 'fake-tag';
export const CreateQuestMutation = gql`
mutation CreateQuest($input: CreateQuestInput!) {
createQuest(quest: $input) {
success
error
quest_id
quest {
id # We add this for urql to update the cache
}
}
}
`;

View File

@@ -0,0 +1,17 @@
import gql from 'fake-tag';
export const CreateQuestCompletionMutation = gql`
mutation CreateQuestCompletion($input: CreateQuestCompletionInput!) {
createQuestCompletion(questCompletion: $input) {
success
error
quest_completion_id
quest_completion {
id # We add this for urql to update the cache
quest {
id
}
}
}
}
`;

View File

@@ -0,0 +1,23 @@
import gql from 'fake-tag';
export const UpdateQuestMutation = gql`
mutation UpdateQuest(
$id: uuid!
$input: quest_set_input!
$skills: [quest_skill_insert_input!]!
) {
update_quest_by_pk(pk_columns: { id: $id }, _set: $input) {
id
}
delete_quest_skill(where: { quest_id: { _eq: $id } }) {
affected_rows
}
insert_quest_skill(objects: $skills) {
affected_rows
returning {
quest_id
skill_id
}
}
}
`;

View File

@@ -0,0 +1,19 @@
import gql from 'fake-tag';
export const UpdateQuestCompletionMutation = gql`
mutation UpdateQuestCompletion(
$quest_completion_id: String!
$status: QuestCompletionStatus_ActionEnum!
) {
updateQuestCompletion(
updateData: { quest_completion_id: $quest_completion_id, status: $status }
) {
error
success
quest_completion_id
quest_completion {
id # We add this for urql to update the cache
}
}
}
`;

View File

@@ -0,0 +1,26 @@
import { useGetpSeedBalanceQuery } from '../../graphql/autogen/types';
import { useUser } from './index';
interface PSeedBalanceHook {
pSeedBalance: string | null;
fetching: boolean;
}
export const usePSeedBalance: () => PSeedBalanceHook = () => {
const { user } = useUser();
const [respSeedBalance] = useGetpSeedBalanceQuery({
variables: {
address: user?.ethereum_address || '',
},
pause: !user?.ethereum_address,
});
const pSeedBalance =
(user?.ethereum_address &&
respSeedBalance.data?.getTokenBalances?.pSeedBalance) ||
null;
return {
pSeedBalance,
fetching: respSeedBalance.fetching,
};
};

View File

@@ -21,11 +21,11 @@ export const useUser = ({ redirectTo, redirectIfFound }: UseUserOpts = {}): {
const [{ data, error, fetching }] = useGetMeQuery({
pause: !authToken,
});
const user = data?.me[0];
const me = data?.me[0];
const user = (error || !authToken || !me) ? null : me;
useEffect(() => {
if (!redirectTo || !user) return;
if (!redirectTo) return;
if (
// If redirectTo is set, redirect if the user was not found.
@@ -37,7 +37,7 @@ export const useUser = ({ redirectTo, redirectIfFound }: UseUserOpts = {}): {
}
}, [router, user, redirectIfFound, redirectTo]);
return { user: error ? null : user, fetching };
return { user, fetching };
};
export const useMounted = (): boolean => {

View File

@@ -0,0 +1,59 @@
import {
GetQuestsQueryVariables,
QuestFragmentFragment,
useGetQuestGuildsQuery,
useGetQuestsQuery,
} from 'graphql/autogen/types';
import { useState } from 'react';
import { defaultQueryVariables } from '../../graphql/getQuests';
interface QuestFilter {
quests: QuestFragmentFragment[] | null;
fetching: boolean;
queryVariables: GetQuestsQueryVariables;
setQueryVariable: (_: string, __: any) => void;
aggregates: QuestAggregates;
error?: Error;
}
export interface QuestAggregates {
guilds: { id: string; name: string }[];
}
export const useQuestFilter = (): QuestFilter => {
const [queryVariables, setQueryVariables] = useState<GetQuestsQueryVariables>(
defaultQueryVariables,
);
const [resQuests] = useGetQuestsQuery({
variables: queryVariables,
});
const [resGuilds] = useGetQuestGuildsQuery();
const { fetching, data, error } = resQuests;
const quests = data?.quest || null;
const guilds =
resGuilds?.data?.quest_aggregate.nodes.map((q) => ({
id: q.guild_id,
name: q.guild.name,
})) || [];
const aggregates = {
guilds,
};
function setQueryVariable(key: string, value: any) {
setQueryVariables({
...queryVariables,
[key]: value !== '' ? value : null,
});
}
return {
quests,
aggregates,
fetching,
error,
queryVariables,
setQueryVariable,
};
};

View File

@@ -22,6 +22,7 @@
"framer-motion": "3.1.1",
"graphql": "15.4.0",
"isomorphic-unfetch": "3.1.0",
"moment": "^2.29.1",
"next": "10.0.3",
"next-images": "1.6.0",
"next-urql": "3.0.0",
@@ -31,6 +32,7 @@
"react-dom": "16.14.0",
"react-icons": "3.11.0",
"react-is": "16.13.1",
"react-hook-form": "6.15.5",
"react-qr-svg": "^2.3.0",
"spacetime": "6.12.2",
"spacetime-informal": "0.5.0",

View File

@@ -1,14 +1,18 @@
import { ChakraProvider, CSSReset, MetaTheme } from '@metafam/ds';
import { MobileFooter } from 'components/MobileFooter';
import { PageHeader } from 'components/PageHeader';
import { CONFIG } from 'config';
import { Web3ContextProvider } from 'contexts/Web3Context';
import { getTokenFromStore } from 'lib/auth';
import Head from 'next/head';
import { withUrqlClient, WithUrqlProps } from 'next-urql';
import { WithUrqlProps } from 'next-urql';
import React from 'react';
const app: React.FC<WithUrqlProps> = ({ pageProps, resetUrqlClient, Component }) => {
import { wrapUrqlClient } from '../graphql/client';
const App: React.FC<WithUrqlProps> = ({
pageProps,
resetUrqlClient,
Component,
}) => {
return (
<ChakraProvider theme={MetaTheme}>
<CSSReset />
@@ -27,18 +31,4 @@ const app: React.FC<WithUrqlProps> = ({ pageProps, resetUrqlClient, Component })
);
};
export default withUrqlClient(
(_ssrExchange, ctx) => ({
url: CONFIG.graphqlURL,
fetchOptions: () => ({
headers: {
Authorization: ctx
? `Bearer ${ctx?.req?.headers?.authorization ?? ''}`
: `Bearer ${getTokenFromStore() ?? ''}`,
},
}),
}),
{
neverSuspend: true,
},
)(app);
export default wrapUrqlClient(App);

View File

@@ -20,7 +20,11 @@ const GuildPage: React.FC<Props> = ({ guild }) => {
const router = useRouter();
if (router.isFallback) {
return <LoadingState />;
return (
<PageContainer>
<LoadingState />
</PageContainer>
);
}
if (!guild) {

View File

@@ -40,7 +40,11 @@ const PlayerPage: React.FC<Props> = ({ player }) => {
]);
if (router.isFallback) {
return <LoadingState />;
return (
<PageContainer>
<LoadingState />
</PageContainer>
);
}
if (!player) {
@@ -187,7 +191,7 @@ export const getStaticProps = async (
destination: '/',
permanent: false,
},
}
};
}
let player = await getPlayer(username);
@@ -199,10 +203,10 @@ export const getStaticProps = async (
destination: `/player/${username.toLowerCase()}`,
permanent: false,
},
}
};
}
}
return {
props: {
player: player || null, // must be serializable

View File

@@ -0,0 +1,164 @@
import {
Box,
Flex,
Heading,
HStack,
LoadingState,
MetaButton,
Wrap,
WrapItem,
} from '@metafam/ds';
import { MetaLink } from 'components/Link';
import {
QuestRepetition_Enum,
useGetQuestWithCompletionsQuery,
} from 'graphql/autogen/types';
import { getQuestWithCompletions } from 'graphql/getQuest';
import { getQuestIds } from 'graphql/getQuests';
import { GetStaticPaths, GetStaticPropsContext } from 'next';
import { useRouter } from 'next/router';
import React, { useMemo } from 'react';
import { FaArrowLeft } from 'react-icons/fa';
import { PageContainer } from '../../components/Container';
import { PlayerTile } from '../../components/Player/PlayerTile';
import { QuestCompletions } from '../../components/Quest/QuestCompletions';
import { QuestDetails } from '../../components/Quest/QuestDetails';
import { getSsrClient } from '../../graphql/client';
import { useUser } from '../../lib/hooks';
import { canCompleteQuest } from '../../utils/questHelpers';
type Props = {
quest_id: string;
};
const QuestPage: React.FC<Props> = ({ quest_id }) => {
const router = useRouter();
const { user } = useUser();
const [res] = useGetQuestWithCompletionsQuery({
variables: {
id: quest_id,
},
});
const quest = res.data?.quest_by_pk;
const canSubmit = useMemo<boolean>(() => canCompleteQuest(quest, user), [
quest,
user,
]);
if (router.isFallback || !quest) {
return (
<PageContainer>
<LoadingState />
</PageContainer>
);
}
return (
<PageContainer>
<Box w="100%" maxW="80rem">
<Box mb={4} px={2}>
<MetaButton
as="a"
variant="link"
href="/quests"
leftIcon={<FaArrowLeft />}
>
Back to quest explorer
</MetaButton>
</Box>
<Wrap w="100%" justify="center" spacing={8}>
<WrapItem flexGrow={3} flexShrink={1} flexBasis={0}>
<Flex
w="100%"
align={{ base: 'center', lg: 'start' }}
direction="column"
>
<Heading mb={4} ml={2}>
Quest details
</Heading>
<QuestDetails quest={quest} />
</Flex>
</WrapItem>
<WrapItem
flexGrow={1}
flexShrink={1}
flexBasis={{ base: '100%', lg: 0 }}
>
<Flex
w="100%"
align={{ base: 'center', lg: 'start' }}
direction="column"
>
<Heading mb={4} ml={2}>
Created by
</Heading>
<PlayerTile player={quest.player} />
</Flex>
</WrapItem>
</Wrap>
<Flex w="100%" direction="column" mt={8}>
<HStack mb={4} justify="space-between">
<Heading>Proposals</Heading>
{canSubmit && (
<MetaLink
as={`/quest/${quest.id}/complete`}
href="/quest/[id]/complete"
>
<MetaButton variant="outline" colorScheme="cyan">
Claim quest
</MetaButton>
</MetaLink>
)}
{!canSubmit && quest.repetition === QuestRepetition_Enum.Recurring && (
<MetaButton variant="outline" colorScheme="cyan" isDisabled>
(Cooldown)
</MetaButton>
)}
</HStack>
<QuestCompletions quest={quest} />
</Flex>
</Box>
</PageContainer>
);
};
type QueryParams = { id: string };
export const getStaticPaths: GetStaticPaths<QueryParams> = async () => {
const questIds = await getQuestIds(50);
return {
paths: questIds.map((id) => ({
params: { id },
})),
fallback: true,
};
};
export const getStaticProps = async (
context: GetStaticPropsContext<QueryParams>,
) => {
const [ssrClient, ssrCache] = getSsrClient();
const id = context.params?.id;
const quest = await getQuestWithCompletions(id, ssrClient);
if (!quest) {
return {
notFound: true,
};
}
return {
props: {
quest_id: id,
urqlState: ssrCache.extractData(),
},
revalidate: 1,
};
};
export default QuestPage;

View File

@@ -0,0 +1,132 @@
import { Flex, Heading, LoadingState, Stack, useToast } from '@metafam/ds';
import { MetaLink } from 'components/Link';
import {
CreateQuestCompletionInput,
useCreateQuestCompletionMutation,
} from 'graphql/autogen/types';
import { getQuest } from 'graphql/getQuest';
import {
GetStaticPaths,
GetStaticPropsContext,
InferGetStaticPropsType,
} from 'next';
import Error from 'next/error';
import { useRouter } from 'next/router';
import React from 'react';
import { PageContainer } from '../../../components/Container';
import { CompletionForm } from '../../../components/Quest/CompletionForm';
import { getSsrClient } from '../../../graphql/client';
import { useUser } from '../../../lib/hooks';
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const SubmitQuestCompletionPage: React.FC<Props> = ({ quest }) => {
useUser({ redirectTo: '/quests' });
const toast = useToast();
const router = useRouter();
const [
createQuestCompletionState,
createQuestCompletion,
] = useCreateQuestCompletionMutation();
if (router.isFallback) {
return (
<PageContainer>
<LoadingState />
</PageContainer>
);
}
if (!quest) {
return <Error statusCode={404} />;
}
const onSubmit = (data: CreateQuestCompletionInput) => {
createQuestCompletion({
input: {
...data,
quest_id: quest.id,
},
}).then((response) => {
const createQuestCompletionResponse =
response.data?.createQuestCompletion;
if (createQuestCompletionResponse?.success) {
router.push(`/quest/${quest.id}`);
toast({
title: 'Submitted quest completion',
description: `Now, wait until it gets accepted 😉`,
status: 'success',
isClosable: true,
duration: 4000,
});
} else {
toast({
title: 'Error while submitting completion,',
description:
response.error?.message ||
createQuestCompletionResponse?.error ||
'unknown error',
status: 'error',
isClosable: true,
duration: 10000,
});
}
});
};
return (
<PageContainer>
<Stack
spacing={6}
align="center"
direction={{ base: 'column', lg: 'row' }}
alignItems="flex-start"
maxWidth="7xl"
>
<Flex flex={1} d="column">
<MetaLink as={`/quest/${quest.id}`} href="/quest/[id]">
Back to Quest
</MetaLink>
<Heading>Claim quest</Heading>
<CompletionForm
onSubmit={onSubmit}
quest={quest}
success={
!!createQuestCompletionState.data?.createQuestCompletion
?.quest_completion_id
}
fetching={createQuestCompletionState.fetching}
/>
</Flex>
</Stack>
</PageContainer>
);
};
export default SubmitQuestCompletionPage;
type QueryParams = { id: string };
export const getStaticPaths: GetStaticPaths<QueryParams> = async () => {
return {
paths: [],
fallback: true,
};
};
export const getStaticProps = async (
context: GetStaticPropsContext<QueryParams>,
) => {
const [ssrClient] = getSsrClient();
const id = context.params?.id;
const quest = await getQuest(id, ssrClient);
return {
props: {
quest: quest === undefined ? null : quest,
},
revalidate: 1,
};
};

View File

@@ -0,0 +1,137 @@
import { Heading, LoadingState, useToast } from '@metafam/ds';
import { getQuest } from 'graphql/getQuest';
import { GetStaticPaths, GetStaticPropsContext } from 'next';
import { useRouter } from 'next/router';
import React from 'react';
import { PageContainer } from '../../../components/Container';
import {
CreateQuestFormInputs,
QuestForm,
} from '../../../components/Quest/QuestForm';
import {
GuildFragmentFragment,
QuestFragmentFragment,
useUpdateQuestMutation,
} from '../../../graphql/autogen/types';
import { getSsrClient } from '../../../graphql/client';
import { getGuilds } from '../../../graphql/getGuilds';
import { getSkills } from '../../../graphql/getSkills';
import { useUser } from '../../../lib/hooks';
import { transformCooldownForBackend } from '../../../utils/questHelpers';
import { CategoryOption, parseSkills } from '../../../utils/skillHelpers';
type Props = {
quest: QuestFragmentFragment;
guilds: GuildFragmentFragment[];
skillChoices: Array<CategoryOption>;
};
const EditQuestPage: React.FC<Props> = ({ quest, skillChoices, guilds }) => {
useUser({ redirectTo: '/quests' });
const router = useRouter();
const toast = useToast();
const [updateQuestResult, updateQuest] = useUpdateQuestMutation();
const onSubmit = (data: CreateQuestFormInputs) => {
const updateQuestInput = {
title: data.title,
description: data.description,
external_link: data.external_link,
repetition: data.repetition,
cooldown: transformCooldownForBackend(data.cooldown, data.repetition),
status: data.status,
};
const skillsObjects = data.skills.map((s) => ({
quest_id: quest.id,
skill_id: s.id,
}));
updateQuest({
id: quest.id,
input: updateQuestInput,
skills: skillsObjects,
}).then((res) => {
if (res.data?.update_quest_by_pk && !res.error) {
router.push(`/quest/${quest.id}`);
toast({
title: 'Quest edited',
description: `The quest details have been updated`,
status: 'success',
isClosable: true,
duration: 4000,
});
} else {
toast({
title: 'Error while updating quest',
description: res.error?.message || 'unknown error',
status: 'error',
isClosable: true,
duration: 10000,
});
}
});
};
if (router.isFallback) {
return (
<PageContainer>
<LoadingState />
</PageContainer>
);
}
return (
<PageContainer>
<Heading mb={4}>Edit Quest</Heading>
<QuestForm
guilds={guilds}
skillChoices={skillChoices}
onSubmit={onSubmit}
success={!!updateQuestResult.data}
fetching={updateQuestResult.fetching}
submitLabel="Edit Quest"
loadingLabel="Editing quest..."
editQuest={quest}
/>
</PageContainer>
);
};
export default EditQuestPage;
type QueryParams = { id: string };
export const getStaticPaths: GetStaticPaths<QueryParams> = async () => {
return {
paths: [],
fallback: true,
};
};
export const getStaticProps = async (
context: GetStaticPropsContext<QueryParams>,
) => {
const [ssrClient] = getSsrClient();
const id = context.params?.id;
const quest = await getQuest(id, ssrClient);
if (!quest) {
return {
notFound: true,
};
}
const guilds = await getGuilds();
const skills = await getSkills();
const skillChoices = parseSkills(skills);
return {
props: {
quest,
guilds,
skillChoices,
},
revalidate: 1,
};
};

View File

@@ -0,0 +1,96 @@
import { MetaHeading, useToast } from '@metafam/ds';
import {
QuestRepetition_ActionEnum,
useCreateQuestMutation,
} from 'graphql/autogen/types';
import { InferGetStaticPropsType } from 'next';
import { useRouter } from 'next/router';
import React from 'react';
import { PageContainer } from '../../components/Container';
import {
CreateQuestFormInputs,
QuestForm,
} from '../../components/Quest/QuestForm';
import { getGuilds } from '../../graphql/getGuilds';
import { getSkills } from '../../graphql/getSkills';
import { useUser } from '../../lib/hooks';
import { transformCooldownForBackend } from '../../utils/questHelpers';
import { parseSkills } from '../../utils/skillHelpers';
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const CreateQuestPage: React.FC<Props> = ({ guilds, skillChoices }) => {
useUser({ redirectTo: '/quests' });
const router = useRouter();
const toast = useToast();
const [createQuestState, createQuest] = useCreateQuestMutation();
const onSubmit = (data: CreateQuestFormInputs) => {
const { skills, repetition, cooldown, ...createQuestInputs } = data;
const input = {
...createQuestInputs,
repetition: (data.repetition as unknown) as QuestRepetition_ActionEnum,
cooldown: transformCooldownForBackend(cooldown, repetition),
skills_id: skills.map((s) => s.id),
};
createQuest({
input,
}).then((response) => {
const createQuestResponse = response.data?.createQuest;
if (createQuestResponse?.success) {
router.push(`/quest/${createQuestResponse.quest_id}`);
toast({
title: 'Quest created',
description: `Your quest is now live!`,
status: 'success',
isClosable: true,
duration: 4000,
});
} else {
toast({
title: 'Error while creating quest',
description:
response.error?.message ||
createQuestResponse?.error ||
'unknown error',
status: 'error',
isClosable: true,
duration: 10000,
});
}
});
};
return (
<PageContainer>
<MetaHeading mb={4}>Create quest</MetaHeading>
<QuestForm
guilds={guilds}
skillChoices={skillChoices}
onSubmit={onSubmit}
success={!!createQuestState.data?.createQuest?.success}
fetching={createQuestState.fetching}
submitLabel="Create Quest"
loadingLabel="Creating quest..."
/>
</PageContainer>
);
};
export const getStaticProps = async () => {
const guilds = await getGuilds();
const skills = await getSkills();
const skillChoices = parseSkills(skills);
return {
props: {
guilds,
skillChoices,
},
revalidate: 1,
};
};
export default CreateQuestPage;

View File

@@ -0,0 +1,92 @@
import {
Box,
Heading,
HStack,
LoadingState,
MetaButton,
Text,
Tooltip,
} from '@metafam/ds';
import { PageContainer } from 'components/Container';
import { QuestFilter } from 'components/Quest/QuestFilter';
import { QuestList } from 'components/Quest/QuestList';
import { getQuests } from 'graphql/getQuests';
import { InferGetStaticPropsType } from 'next';
import { useRouter } from 'next/router';
import React, { useMemo } from 'react';
import { getSsrClient } from '../graphql/client';
import { usePSeedBalance } from '../lib/hooks/balances';
import { useQuestFilter } from '../lib/hooks/quests';
import { isAllowedToCreateQuest } from '../utils/questHelpers';
type Props = InferGetStaticPropsType<typeof getStaticProps>;
export const getStaticProps = async () => {
const [ssrClient, ssrCache] = getSsrClient();
// This populate the cache server-side
await getQuests(undefined, ssrClient);
return {
props: {
urqlState: ssrCache.extractData(),
},
revalidate: 1,
};
};
const QuestsPage: React.FC<Props> = () => {
const router = useRouter();
const {
quests,
aggregates,
fetching,
error,
queryVariables,
setQueryVariable,
} = useQuestFilter();
const { pSeedBalance, fetching: fetchingBalance } = usePSeedBalance();
const canCreateQuest = useMemo(() => isAllowedToCreateQuest(pSeedBalance), [
pSeedBalance,
]);
return (
<PageContainer>
<Box w="100%" maxW="80rem">
<HStack justify="space-between" w="100%">
<Heading>Quest explorer</Heading>
<Tooltip
label={
!canCreateQuest &&
'You need to hold at least 100 pSEED to create a quest'
}
>
<MetaButton
fontFamily="mono"
// disabled={!canCreateQuest} // if disabled, tooltip doesn't show...
isLoading={fetchingBalance}
onClick={() => canCreateQuest && router.push('/quest/create')}
>
New Quest
</MetaButton>
</Tooltip>
</HStack>
<Box mt={8} w="100%">
<QuestFilter
aggregates={aggregates}
queryVariables={queryVariables}
setQueryVariable={setQueryVariable}
quests={quests || []}
/>
</Box>
<Box mt={8} w="100%">
{fetching && <LoadingState />}
{error && <Text>Error: {error.message}</Text>}
{quests && !fetching && <QuestList quests={quests} />}
</Box>
</Box>
</PageContainer>
);
};
export default QuestsPage;

View File

@@ -1,6 +1,6 @@
const emojiUrl = (fileName: string): string => {
return `/assets/emojis/${fileName}`;
}
};
export interface DrawerItemType {
href: string;
@@ -29,11 +29,11 @@ export const DrawerItemsLeft: DrawerItemType[] = [
export const DrawerItemsRight: DrawerItemType[] = [
{
href: 'https://forum.metagame.wtf/',
isExternal: true,
src: emojiUrl('classical-building.png'),
alt: 'MetaForm',
text: 'Forum',
href: '/quests',
isExternal: false,
src: emojiUrl('question-mark.png'),
alt: 'MetaQuests',
text: 'Quests',
},
{
href:
@@ -47,11 +47,11 @@ export const DrawerItemsRight: DrawerItemType[] = [
export const DrawerSubItems: DrawerItemType[] = [
{
href: 'https://discord.gg/WYUkVpe',
href: 'https://forum.metagame.wtf/',
isExternal: true,
src: emojiUrl('question-mark.png'),
alt: 'MetaQuests',
text: 'Quests',
src: emojiUrl('classical-building.png'),
alt: 'MetaForm',
text: 'Forum',
},
{
href: 'https://metagame.substack.com/',

View File

@@ -0,0 +1,78 @@
import { numbers } from '@metafam/utils';
import {
QuestRepetition_Enum,
QuestStatus_Enum,
QuestWithCompletionFragmentFragment,
} from '../graphql/autogen/types';
import { MeType } from '../graphql/types';
const { BN, amountToDecimal } = numbers;
export const UriRegexp = /\w+:(\/?\/?)[^\s]+/;
// Hours to seconds
export function transformCooldownForBackend(
cooldown: number | undefined | null,
repetition: QuestRepetition_Enum | undefined | null,
) {
if (!cooldown || !repetition || repetition !== QuestRepetition_Enum.Recurring)
return null;
return cooldown * 60 * 60;
}
export function isAllowedToCreateQuest(
balance: string | undefined | null,
): boolean {
if (!balance) return false;
const pSEEDDecimals = 18;
const minimumPooledSeedBalance = new BN(100);
const pSEEDBalanceInDecimal = amountToDecimal(balance, pSEEDDecimals);
const allowed = new BN(pSEEDBalanceInDecimal).gt(minimumPooledSeedBalance);
return allowed;
}
// TODO factorize this with backend
export function canCompleteQuest(
quest: QuestWithCompletionFragmentFragment | null | undefined,
user: MeType | null | undefined,
): boolean {
if (!user || !quest) return false;
if (quest.status !== QuestStatus_Enum.Open) {
return false;
}
// Personal or unique, check if not already done by player
if (
quest.repetition === QuestRepetition_Enum.Unique ||
quest.repetition === QuestRepetition_Enum.Personal
) {
return !quest.quest_completions.some((qc) => qc.player.id === user.id);
}
if (quest.repetition === QuestRepetition_Enum.Recurring && quest.cooldown) {
const myLastCompletion = quest.quest_completions.find(
(qc) => qc.player.id === user.id,
);
if (myLastCompletion) {
const submittedAt = new Date(myLastCompletion.submitted_at);
const now = new Date();
const diff = +now - +submittedAt;
if (diff < quest.cooldown * 1000) {
return false;
}
}
}
return true;
}
export const QuestRepetitionHint: Record<QuestRepetition_Enum, string> = {
[QuestRepetition_Enum.Recurring]:
'Recurring quests can be done multiple time per player after a cooldown.',
[QuestRepetition_Enum.Personal]:
'Personal quests can be done once per player',
[QuestRepetition_Enum.Unique]: 'Unique quests can be done only once',
};

View File

@@ -18,14 +18,14 @@ export const parseSkills = (
skills: Array<PlayerSkillFragment>,
): Array<CategoryOption> => {
const skillsMap: SkillMap = {};
skills.map((skill) => {
skills.forEach((skill) => {
if (!(skill.category in skillsMap)) {
skillsMap[skill.category] = {
label: skill.category,
options: [],
};
}
return skillsMap[skill.category].options?.push({
skillsMap[skill.category].options?.push({
value: skill.id,
label: skill.name,
...skill,

View File

@@ -23015,6 +23015,11 @@ modify-values@^1.0.0:
resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
moment@^2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
mortice@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mortice/-/mortice-2.0.0.tgz#7be171409c2115561ba3fc035e4527f9082eefde"
@@ -26562,6 +26567,11 @@ react-helmet-async@^1.0.2:
react-fast-compare "^3.2.0"
shallowequal "^1.1.0"
react-hook-form@6.15.5:
version "6.15.5"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.15.5.tgz#c2578f9ce6a6df7b33015587d40cd880dc13e2db"
integrity sha512-so2jEPYKdVk1olMo+HQ9D9n1hVzaPPFO4wsjgSeZ964R7q7CHsYRbVF0PGBi83FcycA5482WHflasdwLIUVENg==
react-hotkeys@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f"