mirror of
https://github.com/MetaFam/TheGame.git
synced 2026-04-02 03:00:32 -04:00
[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:
@@ -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:
|
||||
|
||||
74
packages/design-system/src/ConfirmModal.tsx
Normal file
74
packages/design-system/src/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
8
packages/design-system/src/theme/texts.ts
Normal file
8
packages/design-system/src/theme/texts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const textStyles = {
|
||||
caption: {
|
||||
fontFamily: 'mono',
|
||||
fontSize: 'sm',
|
||||
color: 'blueLight',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const AllTypes = () => (
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text fontFamily="mono" fontSize="sm" color="blueLight">
|
||||
<Text textStyle="caption">
|
||||
About me
|
||||
</Text>
|
||||
|
||||
|
||||
73
packages/web/components/ConfirmModal.tsx
Normal file
73
packages/web/components/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
94
packages/web/components/Quest/CompletionForm.tsx
Normal file
94
packages/web/components/Quest/CompletionForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
181
packages/web/components/Quest/QuestCompletions.tsx
Normal file
181
packages/web/components/Quest/QuestCompletions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
packages/web/components/Quest/QuestDetails.tsx
Normal file
115
packages/web/components/Quest/QuestDetails.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
131
packages/web/components/Quest/QuestFilter.tsx
Normal file
131
packages/web/components/Quest/QuestFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
315
packages/web/components/Quest/QuestForm.tsx
Normal file
315
packages/web/components/Quest/QuestForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
packages/web/components/Quest/QuestList.tsx
Normal file
29
packages/web/components/Quest/QuestList.tsx
Normal 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>
|
||||
);
|
||||
77
packages/web/components/Quest/QuestTags.tsx
Normal file
77
packages/web/components/Quest/QuestTags.tsx
Normal 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>
|
||||
);
|
||||
78
packages/web/components/Quest/QuestTile.tsx
Normal file
78
packages/web/components/Quest/QuestTile.tsx
Normal 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>
|
||||
);
|
||||
96
packages/web/components/Skills.tsx
Normal file
96
packages/web/components/Skills.tsx
Normal 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>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
68
packages/web/graphql/getQuest.ts
Normal file
68
packages/web/graphql/getQuest.ts
Normal 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;
|
||||
};
|
||||
114
packages/web/graphql/getQuests.ts
Normal file
114
packages/web/graphql/getQuests.ts
Normal 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;
|
||||
};
|
||||
14
packages/web/graphql/mutations/createQuest.ts
Normal file
14
packages/web/graphql/mutations/createQuest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
17
packages/web/graphql/mutations/createQuestCompletion.ts
Normal file
17
packages/web/graphql/mutations/createQuestCompletion.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
23
packages/web/graphql/mutations/updateQuest.ts
Normal file
23
packages/web/graphql/mutations/updateQuest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
19
packages/web/graphql/mutations/updateQuestCompletion.ts
Normal file
19
packages/web/graphql/mutations/updateQuestCompletion.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
26
packages/web/lib/hooks/balances.ts
Normal file
26
packages/web/lib/hooks/balances.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
59
packages/web/lib/hooks/quests.ts
Normal file
59
packages/web/lib/hooks/quests.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,7 +20,11 @@ const GuildPage: React.FC<Props> = ({ guild }) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (router.isFallback) {
|
||||
return <LoadingState />;
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingState />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guild) {
|
||||
|
||||
@@ -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
|
||||
|
||||
164
packages/web/pages/quest/[id].tsx
Normal file
164
packages/web/pages/quest/[id].tsx
Normal 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;
|
||||
132
packages/web/pages/quest/[id]/complete.tsx
Normal file
132
packages/web/pages/quest/[id]/complete.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
137
packages/web/pages/quest/[id]/edit.tsx
Normal file
137
packages/web/pages/quest/[id]/edit.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
96
packages/web/pages/quest/create.tsx
Normal file
96
packages/web/pages/quest/create.tsx
Normal 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;
|
||||
92
packages/web/pages/quests.tsx
Normal file
92
packages/web/pages/quests.tsx
Normal 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;
|
||||
@@ -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/',
|
||||
|
||||
78
packages/web/utils/questHelpers.ts
Normal file
78
packages/web/utils/questHelpers.ts
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user