Merge branch 'develop' of github.com:fleekxyz/contracts into test/foundry

This commit is contained in:
zoruka 2022-12-15 14:31:40 -03:00
commit 0c96f1db9d
42 changed files with 2665 additions and 44 deletions

View File

@ -11,9 +11,16 @@
"author": "Fleek",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.13",
"@chakra-ui/react": "^2.4.2",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"formik": "^2.2.9",
"framer-motion": "^7.6.17",
"path": "^0.12.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.4"
},
"devDependencies": {
"@types/jest": "^29.2.3",
@ -28,7 +35,9 @@
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.11",
"ethers": "^5.7.2",
"prettier": "^2.8.0",
"react-query": "^3.39.2",
"ts-loader": "^9.4.1",
"typescript": "^4.9.3",
"vite": "^3.2.4",

View File

@ -1,3 +0,0 @@
.main {
text-align: center;
}

View File

@ -1,10 +1,17 @@
import React from 'react';
import './App.css';
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';
import { Home, MintSite, MintedSiteDetail } from './views';
export const App = () => {
return (
<div className="main">
<h1>Welcome to Sites as NFTs by Fleek</h1>
</div>
<BrowserRouter>
<Routes>
<Route path="/mint-site" element={<MintSite />} />
<Route path="/home" element={<Home />} />
<Route path="/detail" element={<MintedSiteDetail />} />
<Route path="*" element={<Navigate to="/home" />} />
</Routes>
</BrowserRouter>
);
};

View File

@ -0,0 +1,34 @@
import {
AccordionItem as AccordionItemChakra,
AccordionButton,
Box,
Heading,
AccordionIcon,
AccordionPanel,
AccordionPanelProps,
forwardRef,
} from '@chakra-ui/react';
import React from 'react';
type AccordionProps = AccordionPanelProps & {
children: React.ReactNode;
heading: string;
};
export const AccordionItem = forwardRef<AccordionProps, 'div'>(
({ children, heading, ...panelProps }, ref) => {
return (
<AccordionItemChakra>
<AccordionButton borderBottomWidth="1px">
<Box flex="1" textAlign="left">
<Heading size="md"> {heading}</Heading>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel ref={ref} {...panelProps} pb={4} overflowY="scroll">
{children}
</AccordionPanel>
</AccordionItemChakra>
);
}
);

View File

@ -0,0 +1,2 @@
export * from './accordion-item';

View File

@ -0,0 +1,30 @@
import { SiteNFTDetail } from '@/types';
import { HStack } from '@chakra-ui/react';
import { CardAttributes } from '../card';
type AttributesDetailProps = {
owner: string;
attributes: SiteNFTDetail['attributes'];
tokendId: string;
};
export const AttributesDetail = ({
owner,
attributes,
tokendId,
}: AttributesDetailProps) => {
return (
<HStack shouldWrapChildren display="inline" spacing="0px">
<CardAttributes heading="Owner" info={owner} />
{attributes.map((attribute) => (
<CardAttributes
key={attribute.trait_type}
heading={attribute.trait_type}
info={attribute.value}
/>
))}
<CardAttributes heading="Token ID" info={tokendId} />
</HStack>
);
};

View File

@ -0,0 +1 @@
export * from './attributes-detail';

View File

@ -0,0 +1,23 @@
import { Card, CardBody } from '@chakra-ui/react';
import { TileInfo } from '../tile-info';
type CardAttributesProps = {
heading: string;
info: string;
};
export const CardAttributes = ({ heading, info }: CardAttributesProps) => (
<Card
mr="10px"
mb="5px"
direction={{ base: 'column', sm: 'row' }}
overflow="hidden"
variant="outline"
width="200px"
>
<CardBody width="200px">
<TileInfo size="sm" heading={heading} info={info} width={160} />
</CardBody>
</Card>
);

View File

@ -0,0 +1,2 @@
export * from './card-attributes';

View File

@ -0,0 +1,18 @@
import { ArrowBackIcon } from '@chakra-ui/icons';
import { IconButton } from '@chakra-ui/react';
import { Link } from 'react-router-dom';
export const HomeButton = () => {
return (
<IconButton
as={Link}
to="/home"
aria-label="back home"
icon={<ArrowBackIcon />}
variant="link"
size={'xl'}
textDecoration={'none'}
/>
);
};

View File

@ -0,0 +1 @@
export * from './home-button';

View File

@ -0,0 +1,22 @@
import { forwardRef, Image, ImageProps } from '@chakra-ui/react';
type ImagePreviewProps = ImageProps & {
image: string;
};
export const ImagePreview = forwardRef<ImagePreviewProps, 'img'>(
({ image, ...imageProps }, ref) => {
return (
<>
{/* TODO add fallback Image */}
<Image
ref={ref}
src={image}
{...imageProps}
fallbackSrc="https://via.placeholder.com/150"
/>
</>
);
}
);

View File

@ -0,0 +1 @@
export * from './image-preview';

View File

@ -0,0 +1,9 @@
export * from './loading';
export * from './home-button';
export * from './image-preview';
export * from './tile-info';
export * from './card';
export * from './accordion-item';
export * from './input-field-form';
export * from './attributes-detail';

View File

@ -0,0 +1 @@
export * from './input-field-form';

View File

@ -0,0 +1,25 @@
import {
FormControl,
FormControlProps,
FormErrorMessage,
FormLabel,
forwardRef,
Input,
} from '@chakra-ui/react';
import { Field } from 'formik';
type InputFieldFormProps = FormControlProps & {
label: string;
fieldName: string;
error?: string;
};
export const InputFieldForm = forwardRef<InputFieldFormProps, 'div'>(
({ label, fieldName, error, ...formControlProps }, ref) => (
<FormControl ref={ref} {...formControlProps}>
<FormLabel htmlFor={fieldName}>{label}</FormLabel>
<Field as={Input} name={fieldName} id={fieldName} type="text" />
{error && <FormErrorMessage>{error}</FormErrorMessage>}
</FormControl>
)
);

View File

@ -0,0 +1 @@
export * from './loading';

View File

@ -0,0 +1,16 @@
import { Flex, Spinner } from '@chakra-ui/react';
export const Loading = () => {
return (
<Flex justifyContent="center" height="80vh" alignItems="center">
<Spinner
thickness="3px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
</Flex>
);
};

View File

@ -0,0 +1 @@
export * from './tile-info';

View File

@ -0,0 +1,33 @@
import {
Flex,
forwardRef,
Heading,
HeadingProps,
Text,
} from '@chakra-ui/react';
type TileInfoProps = HeadingProps & {
heading: string;
info: string;
width?: number;
};
export const TileInfo = forwardRef<TileInfoProps, 'h2'>(
({ heading, info, width = 250, ...headingProps }, ref) => (
<Flex direction="column" alignItems="center">
<Heading ref={ref} {...headingProps}>
{heading}
</Heading>
<Text
width={width}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
textAlign="center"
>
{info}
</Text>
</Flex>
)
);

View File

@ -1,21 +0,0 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}

View File

@ -1,7 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { App } from './App';
import { App } from './app';
import { ChakraProvider } from '@chakra-ui/react';
import { theme } from './theme';
import { QueryClient, QueryClientProvider } from 'react-query';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@ -9,7 +13,11 @@ const root = ReactDOM.createRoot(
root.render(
<React.StrictMode>
<App />
<ChakraProvider theme={theme} resetCSS>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ChakraProvider>
</React.StrictMode>
);

53
ui/src/mocks/detail.ts Normal file
View File

@ -0,0 +1,53 @@
const MINT_PARAMS = {
name: 'Fleek Test App',
description: 'Fleek Test App Description',
image: 'https://storageapi.fleek.co/fleek-team-bucket/site/fleek-logo.png',
ens: 'fleek.eth',
externalUrl: 'https://fleek.co',
commitHash: 'b72e47171746b6a9e29b801af9cb655ecf4d665c',
gitRepository: 'https://github.com/fleekxyz/contracts',
author: 'author',
};
const mockDetail = {
owner: '0x8f7b9e1b5f1f2c3c1f8b0b1b2e1b2f1f2c3c1f8b',
name: MINT_PARAMS.name,
description: MINT_PARAMS.description,
image: MINT_PARAMS.image,
external_url: MINT_PARAMS.externalUrl,
attributes: [
{
trait_type: 'ENS',
value: MINT_PARAMS.ens,
},
{
trait_type: 'Commit Hash',
value: MINT_PARAMS.commitHash,
},
{
trait_type: 'Repository',
value: MINT_PARAMS.gitRepository,
},
//As we're not showing this on the UI, we can remove it
// {
// trait_type: 'Author',
// value: MINT_PARAMS.author,
// },
// {
// trait_type: 'Version',
// value: '0',
// },
],
};
export const fetchSiteDetail = async (tokenId: string) => {
//TODO get site detail from api
return new Promise((resolved, reject) => {
setTimeout(() => {
resolved({
data: { ...mockDetail, externalUrl: mockDetail.external_url },
});
}, 2500);
});
};

3
ui/src/mocks/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './mint-site';
export * from './detail';

25
ui/src/mocks/mint-site.ts Normal file
View File

@ -0,0 +1,25 @@
import { SiteNFT } from '@/types';
export const mintSiteNFT = async (props: SiteNFT) => {
const { name, description, owner, externalUrl, ens, commitHash, repo } =
props;
return new Promise((resolved, rejected) => {
setTimeout(() => {
// returning data of the site for now
// just leave rejected for testing purposes
resolved({
status: 'success',
data: {
name,
description,
owner,
externalUrl,
ens,
commitHash,
repo,
},
});
}, 1000);
});
};

23
ui/src/theme/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { extendTheme } from '@chakra-ui/react';
const appTheme = {
styles: {
global: {
body: {
color: 'rgba(255, 255, 255)',
bg: '#161616',
margin: '50px',
},
},
},
fonts: {
heading: 'Nunito Sans,Helvetica,Arial,Lucida,sans-serif',
body: 'Nunito Sans,Helvetica,Arial,Lucida,sans-serif',
},
sizes: {
modalHeight: '345px',
},
};
export const theme = extendTheme(appTheme);

1
ui/src/types/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './mint-site';

20
ui/src/types/mint-site.ts Normal file
View File

@ -0,0 +1,20 @@
export type SiteNFT = {
name: string;
description: string;
owner: string;
externalUrl: string;
image: string;
ens?: string;
commitHash: string;
repo: string;
};
export type SiteNFTDetail = Omit<SiteNFT, 'ens' | 'commitHash' | 'repo'> & {
attributes: [
{
trait_type: string;
value: string;
}
];
};

8
ui/src/utils/format.ts Normal file
View File

@ -0,0 +1,8 @@
export const getRepoAndCommit = (url: string) => {
//TODO validate is a github url
url = url.replace('/commit', '');
const lastIndexSlash = url.lastIndexOf('/');
const repo = url.substring(0, lastIndexSlash + 1).slice(0, lastIndexSlash);
const commit_hash = url.substring(lastIndexSlash + 1, url.length);
return { repo, commit_hash };
};

2
ui/src/utils/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './format';
export * from './validation';

View File

@ -0,0 +1,11 @@
export const isValidUrl = (url: string) => {
const regex =
/(http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/;
return regex.test(url);
};
export const isValidImageUrl = (url: string) => {
const regex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|svg)$/;
return regex.test(url);
};

View File

@ -0,0 +1,125 @@
import { useSearchParams } from 'react-router-dom';
import { useQuery } from 'react-query';
import {
Accordion,
Box,
Card,
CardBody,
Flex,
Heading,
Link,
VStack,
} from '@chakra-ui/react';
import {
HomeButton,
ImagePreview,
AccordionItem,
Loading,
AttributesDetail,
} from '@/components';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { fetchSiteDetail } from '@/mocks';
import { ErrorScreen } from '@/views';
import { SiteNFTDetail } from '@/types';
export const MintedSiteDetail = () => {
const [searchParams] = useSearchParams();
const tokenIdParam = searchParams.get('tokenId');
//TODO handle response type
const { data, status } = useQuery('fetchDetail', () =>
fetchSiteDetail(tokenIdParam as string)
);
if (status === 'loading') {
return <Loading />;
}
if (status === 'error') {
return <ErrorScreen />;
}
const { owner, name, description, image, externalUrl, attributes } =
data.data as SiteNFTDetail;
return (
<>
<Flex width="full" align="center" justifyContent="center">
<Box width={{ base: '100%' }}>
<HomeButton />
<Box
flexDirection="row"
display="flex"
justifyContent="space-evenly"
mt={10}
>
<Box mr={5}>
<Box
display="flex"
flexDirection="row"
alignItems="flex-end"
mb={5}
>
<Heading mr={5}>{name}</Heading>
</Box>
<Card backgroundColor="transparent" border="1px">
<CardBody padding="1px 8px 10px 8px">
<Accordion defaultIndex={[0, 1]} allowMultiple width="45vw">
<AccordionItem
heading="Description"
minH={120}
maxH="auto"
children={<p>{description}</p>}
/>
<AccordionItem
heading="Attributes"
children={
<AttributesDetail
owner={owner}
attributes={attributes}
tokendId={tokenIdParam as string}
/>
}
padding="16px"
/>
</Accordion>
<Box ml={5} mt={2}>
<Link href={externalUrl} isExternal>
Visit site <ExternalLinkIcon mx="2px" />
</Link>
</Box>
</CardBody>
</Card>
</Box>
<VStack alignItems="flex-start">
<Box
border="1px"
width="-webkit-fill-available"
padding="5px 10px"
borderTopRadius={10}
>
<Heading size="md">Preview</Heading>
</Box>
<Box
mt="0px !important"
boxSize="md"
border="1px"
padding={10}
borderRadius={20}
borderTopRadius={0}
boxShadow="12px 10px 14px 6px #868686d1"
>
<ImagePreview
image={image}
width="auto"
height="auto"
maxW="100%"
maxH="100%"
/>
</Box>
</VStack>
</Box>
</Box>
</Flex>
</>
);
};

View File

@ -0,0 +1 @@
export * from './detail';

View File

@ -0,0 +1,9 @@
import { Flex, Heading } from '@chakra-ui/react';
export const ErrorScreen = () => {
return (
<Flex justifyContent="center" height="80vh" alignItems="center">
<Heading size="md">Something went wrong</Heading>
</Flex>
);
};

View File

@ -0,0 +1 @@
export * from './error-screen';

View File

@ -0,0 +1,17 @@
import React from 'react';
import { Heading, Button } from '@chakra-ui/react';
import { Link } from 'react-router-dom';
import { Flex } from '@chakra-ui/react';
export const Home = () => {
return (
<Flex flexDirection="column" alignItems="center">
<Heading>Welcome to Sites as NFTs by Fleek</Heading>
{/* TODO add list sites */}
<Button as={Link} to="/mint-site" mt={10}>
Mint your site
</Button>
</Flex>
);
};

View File

@ -0,0 +1 @@
export * from './home';

5
ui/src/views/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './home';
export * from './mint-site';
export * from './detail';
export * from './error-screen';

View File

@ -0,0 +1,3 @@
export * from './mint-site';
export * from './mint-site.utils';

View File

@ -0,0 +1,226 @@
import { useCallback } from 'react';
import {
Heading,
Flex,
Box,
FormControl,
FormLabel,
Button,
FormErrorMessage,
IconButton,
useToast,
UseToastOptions,
Textarea,
Grid,
GridItem,
} from '@chakra-ui/react';
import { Formik, Field } from 'formik';
import { ArrowBackIcon } from '@chakra-ui/icons';
import { Link } from 'react-router-dom';
import { mintSiteNFT } from '@/mocks';
import { getRepoAndCommit } from '@/utils';
import { validateFields } from './mint-site.utils';
import { InputFieldForm } from '@/components';
interface FormValues {
name: string;
description: string;
githubCommit: string;
ownerAddress: string;
externalUrl: string;
image: string;
ens?: string;
}
const initialValues = {
name: '',
description: '',
githubCommit: '',
ownerAddress: '',
externalUrl: '',
image: '',
ens: '',
} as FormValues;
export const MintSite = () => {
const toast = useToast();
//TODO add hook to show the toast
const showToast = (
title: string,
description: string,
status: UseToastOptions['status']
) => {
toast({
title,
description,
status,
duration: 3000,
isClosable: true,
});
};
const handleSubmitForm = useCallback(async (values: FormValues) => {
const {
name,
description,
githubCommit,
ownerAddress,
externalUrl,
image,
ens,
} = values;
const { repo, commit_hash } = getRepoAndCommit(githubCommit);
try {
await mintSiteNFT({
name,
description,
owner: ownerAddress,
externalUrl,
image,
ens,
commitHash: commit_hash,
repo,
});
//TODO connect with the integration
showToast('Success!', 'Your site has been minted.', 'success');
} catch (err) {
showToast(
'Error!',
'We had an error while minting your site. Please try again later',
'error'
);
}
}, []);
return (
<>
<Flex width="full" align="center" justifyContent="center" mt="50px">
<Box width={{ base: '100%', md: '80%' }}>
<IconButton
as={Link}
to="/home"
aria-label="back home"
icon={<ArrowBackIcon />}
variant="link"
size={'xl'}
textDecoration={'none'}
/>
<Box textAlign="center" mt={2}>
<Heading>Mint your Site</Heading>
</Box>
<Box my={4} textAlign="left">
<Formik
validate={validateFields}
initialValues={initialValues}
onSubmit={handleSubmitForm}
>
{({ values, touched, handleSubmit, isSubmitting, errors }) => (
<form onSubmit={handleSubmit}>
<Box
display="flex"
flexDirection={{ base: 'column', md: 'row' }}
>
<InputFieldForm
label="Name"
fieldName="name"
mr={5}
error={errors.name}
isInvalid={!!errors.name && touched.name}
isRequired
/>
<InputFieldForm
label="Owner address"
fieldName="ownerAddress"
error={errors.ownerAddress}
isInvalid={!!errors.ownerAddress && touched.ownerAddress}
isRequired
/>
</Box>
<FormControl
mt={6}
isRequired
isInvalid={!!errors.description && touched.description}
>
<FormLabel htmlFor="description">Description</FormLabel>
<Field as={Textarea} name="description" id="description" />
{errors.description && (
<FormErrorMessage>{errors.description}</FormErrorMessage>
)}
</FormControl>
<Box
display="flex"
flexDirection={{ base: 'column', md: 'row' }}
mt={6}
>
<InputFieldForm
label="Image (IPFS Link)"
fieldName="image"
mr={5}
error={errors.image}
isInvalid={!!errors.image && touched.image}
isRequired
/>
<InputFieldForm
label="External url"
fieldName="externalUrl"
error={errors.externalUrl}
isInvalid={!!errors.externalUrl && touched.externalUrl}
isRequired
/>
</Box>
<Grid
templateColumns={{
md: 'repeat(3, 1fr)',
}}
gap={4}
mt={6}
>
<GridItem colSpan={2}>
<InputFieldForm
label="Github commit url"
fieldName="githubCommit"
mr={5}
error={errors.githubCommit}
isInvalid={
!!errors.githubCommit && touched.githubCommit
}
isRequired
/>
</GridItem>
<GridItem colSpan={{ base: 2, md: 1 }}>
<InputFieldForm label="ENS" fieldName="ens" />
</GridItem>
</Grid>
<Button
colorScheme="blue"
backgroundColor="#1d4ed8"
width="full"
mt={4}
type="submit"
isLoading={isSubmitting}
loadingText="Minting..."
disabled={
isSubmitting ||
!values.name ||
!values.description ||
!values.githubCommit ||
!values.ownerAddress ||
!values.image ||
!values.externalUrl
}
>
Mint
</Button>
</form>
)}
</Formik>
</Box>
</Box>
</Flex>
</>
);
};

View File

@ -0,0 +1,36 @@
import { isValidImageUrl, isValidUrl } from '@/utils';
import { ethers } from 'ethers';
import { FormikValues } from 'formik';
export const validateFields = (values: FormikValues) => {
const errors: FormikValues = {};
if (!values.name) {
errors.name = 'Name cannot be empty';
}
if (!values.description) {
errors.description = 'Description cannot be empty';
}
if (!values.githubCommit) {
errors.githubCommit = 'Github commit cannot be empty';
} else if (!isValidUrl(values.githubCommit)) {
errors.githubCommit = 'Github commit is not a valid url';
}
if (!values.ownerAddress) {
errors.ownerAddress = 'Owner address cannot be empty';
} else if (!ethers.utils.isAddress(values.ownerAddress)) {
errors.ownerAddress = 'Owner address is not a valid address';
}
if (!values.externalUrl) {
errors.externalUrl = 'External url cannot be empty';
} else if (!isValidUrl(values.externalUrl)) {
errors.externalUrl = 'External url is not a valid url';
}
if (!values.image) {
errors.image = 'Image cannot be empty';
} else if (!isValidImageUrl(values.image)) {
errors.image = 'Image url is not a valid url';
}
//TODO check if ENS is a valid ens name
return errors;
};

File diff suppressed because it is too large Load Diff