feat: UI ap flow (#223)

* chore: AP flow based on designs

* chore: preview ap creation

* style: max-width for explore container

* fix: fix setArgs

* feat: add success step for access point creation

* feat: add dots spinner

* chore: AP flow finished

* chore: release changes

* feat: store ens once the user log in with wallet

* feat: add mocked function for bunny cdn creation

* chore: finish bunny cdn mocked functions

* chore: run eslint

* feat: add domain validator

* style: move animation to stitches keyframes

* chore: set ens only when is not set

* Update ui/src/store/features/bunny-cdn/async-thunk/verify-ap.ts

Co-authored-by: Felipe Mendes <zo.fmendes@gmail.com>

* chore: remove nfa-picker

* chore: get nfa name

* chore: change verify thunk name

* fix: fix verifyPullzone thunk

---------

Co-authored-by: Felipe Mendes <zo.fmendes@gmail.com>
This commit is contained in:
Camila Sosa Morales 2023-04-21 15:57:20 -03:00 committed by GitHub
parent 22a6d70e98
commit 21b9660164
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 868 additions and 251 deletions

View File

@ -21,7 +21,6 @@ export const App: React.FC = () => {
<Routes>
<Route path="/" element={<Explore />} />
<Route path="/mint" element={<Mint />} />
<Route path="/create-ap" element={<CreateAP />} />
<Route path="/create-ap/:id" element={<CreateAP />} />
<Route path="/nfa/:id" element={<IndexedNFAView />} />
{/** TODO remove for release */}

View File

@ -0,0 +1,10 @@
import { styled } from '@/theme';
export const CardTag = styled('span', {
fontSize: '$sm',
backgroundColor: '$slate4',
color: '$slate11',
p: '$1 $3h',
height: '$7',
borderRadius: '$md',
});

View File

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

View File

@ -24,6 +24,5 @@ export abstract class InputFileStyles {
'&[aria-invalid=true], &[data-invalid]': {
borderColor: '$red9',
},
//TODO add error state
});
}

View File

@ -7,4 +7,5 @@ export * from './toast';
export * from './step';
export * from './nfa-card';
export * from './nfa-preview';
export * from './card-tag';
export * from './resolved-address';

View File

@ -1,11 +1,29 @@
import { Avatar, ConnectKitButton } from 'connectkit';
import { Button, Flex } from '@/components';
import { ENSActions, useAppDispatch, useENSStore } from '@/store';
export const ConnectWalletButton: React.FC = () => {
const { addressMap } = useENSStore();
const dispatch = useAppDispatch();
const setEnsNameStore = (ensName: string, address: string): void => {
const stored = addressMap[address] || {};
if (typeof stored.state !== 'undefined') return;
dispatch(
ENSActions.setAddress({
key: address,
value: { state: 'success', value: ensName },
})
);
};
return (
<ConnectKitButton.Custom>
{({ isConnected, show, truncatedAddress, address, ensName }) => {
if (ensName && address) setEnsNameStore(ensName, address);
return (
<Button onClick={show}>
{isConnected && !!address && !!truncatedAddress ? (

View File

@ -9,19 +9,20 @@ export type ResolvedAddressProps = React.ComponentPropsWithRef<
typeof RAS.Container
> & {
children: string;
truncated?: boolean;
};
export const ResolvedAddress = forwardStyledRef<
HTMLSpanElement,
ResolvedAddressProps
>(({ children, ...props }, ref) => {
>(({ children, truncated = false, ...props }, ref) => {
const [resolvedAddress, loading] = useResolvedAddress(children);
const text = useMemo(() => {
if (!resolvedAddress.endsWith('.eth'))
if (!resolvedAddress.endsWith('.eth') && truncated)
return `${resolvedAddress.slice(0, 6)}...${resolvedAddress.slice(-4)}`;
return resolvedAddress;
}, [resolvedAddress]);
}, [resolvedAddress, truncated]);
return (
<RAS.Container {...props} ref={ref} data-loading={loading}>

View File

@ -1 +1,2 @@
export * from './spinner';
export * from './spinner-dot';

View File

@ -0,0 +1,21 @@
import { SpinnerStyles } from './spinner.styles';
export const SpinnerDot: React.FC<SpinnerStyles.ContainerProps> = (props) => {
return (
<SpinnerStyles.Container
{...props}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<circle cx="12" cy="2.5" r="1" opacity=".14" fill="#FFFFFF" />
<circle cx="16.75" cy="3.77" r="1" opacity=".29" fill="#FFFFFF" />
<circle cx="20.23" cy="7.25" r="1" opacity=".43" fill="#FFFFFF" />
<circle cx="21.50" cy="12.00" r="1" opacity=".57" fill="#FFFFFF" />
<circle cx="20.23" cy="16.75" r="1" opacity=".71" fill="#FFFFFF" />
<circle cx="16.75" cy="20.23" r="1" opacity=".86" fill="#FFFFFF" />
<circle cx="12" cy="21.5" r="1" fill="#FFFFFF" />
</g>
</SpinnerStyles.Container>
);
};

View File

@ -1,12 +1,57 @@
import { styled } from '@/theme';
import { keyframes, styled } from '@/theme';
export abstract class SpinnerStyles {
static readonly Container = styled('svg', {
const DotSpinner = keyframes({
'8.3%': {
transform: 'rotate(30deg)',
},
'16.6%': {
transform: 'rotate(60deg)',
},
'25%': {
transform: 'rotate(90deg)',
},
'33.3%': {
transform: 'rotate(120deg)',
},
'41.6%': {
transform: 'rotate(150deg)',
},
'50%': {
transform: 'rotate(180deg)',
},
'58.3%': {
transform: 'rotate(210deg)',
},
'66.6%': {
transform: 'rotate(240deg)',
},
'75%': {
transform: 'rotate(270deg)',
},
'83.3%': {
transform: 'rotate(300deg)',
},
'91.6%': {
transform: 'rotate(330deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
});
export const SpinnerStyles = {
KeyFrames: {},
Container: styled('svg', {
fontSize: '1.5rem',
width: '1em',
height: '1em',
});
}
g: {
transformOrigin: 'center',
animation: `${DotSpinner} 0.75s step-end infinite`,
},
}),
};
export namespace SpinnerStyles {
export type ContainerProps = React.ComponentProps<

21
ui/src/mocks/bunny-cdn.ts Normal file
View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
export const createBunnyCDNMock = async (
domain: string,
targetDomain: string
): Promise<{ bunnyURL: string }> => {
return new Promise((resolved, reject) => {
setTimeout(() => {
resolved({
bunnyURL: '8c12c649402442d88b5f.b-cdn.net',
});
}, 3000);
});
};
export const verifyBunnyCDNMock = async (domain: string): Promise<boolean> => {
return new Promise((resolved, reject) => {
setTimeout(() => {
resolved(true);
}, 3000);
});
};

View File

@ -1,4 +1,5 @@
export * from './mint-site';
export * from './detail';
export * from './list';
export * from './bunny-cdn';
export * from './nfa';

View File

@ -0,0 +1,35 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { createBunnyCDNMock } from '@/mocks';
import { RootState } from '@/store';
import { AppLog } from '@/utils';
import { bunnyCDNActions } from '../bunny-cdn-slice';
type CNAMERecord = {
domain: string;
targetDomain: string;
};
export const createBunnyCDN = createAsyncThunk<void, CNAMERecord>(
'BunnyCDN/CreateCDN',
async ({ domain, targetDomain }, { dispatch, getState }) => {
const { state } = (getState() as RootState).bunnyCDN;
if (state === 'loading') return;
try {
dispatch(bunnyCDNActions.setState('loading'));
const CDNRecord = await createBunnyCDNMock(domain, targetDomain);
dispatch(bunnyCDNActions.setCDNRecordData(CDNRecord.bunnyURL));
} catch (error) {
AppLog.errorToast(
'Failed to create the CDN record. Please, try again',
error
);
dispatch(bunnyCDNActions.setState('failed'));
}
}
);

View File

@ -0,0 +1,2 @@
export * from './create-cdn';
export * from './verify-pullzone';

View File

@ -0,0 +1,31 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { verifyBunnyCDNMock } from '@/mocks';
import { RootState } from '@/store';
import { AppLog } from '@/utils';
import { bunnyCDNActions } from '../bunny-cdn-slice';
export const verifyBunnyPullzone = createAsyncThunk<void, string>(
'BunnyCDN/VerifyPullzone',
async (domain, { dispatch, getState }): Promise<void> => {
const { state } = (getState() as RootState).bunnyCDN;
if (state === 'loading') return;
try {
dispatch(bunnyCDNActions.setState('loading'));
const verifyAPState = await verifyBunnyCDNMock(domain);
if (verifyAPState) dispatch(bunnyCDNActions.setState('success'));
else throw new Error('Invalid AP state');
} catch (error) {
AppLog.errorToast(
'There was an error trying to verify the domain. Please, try again',
error
);
dispatch(bunnyCDNActions.setState('failed'));
}
}
);

View File

@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '@/store';
import { useAppSelector } from '@/store/hooks';
import * as asyncThunk from './async-thunk';
export namespace BunnyCDNState {
export type CreateCDNState =
| undefined
| 'loading'
| 'unferified'
| 'failed'
| 'success';
}
export interface BunnyCDNState {
state: BunnyCDNState.CreateCDNState;
bunnyURL: string;
}
const initialState: BunnyCDNState = {
state: undefined,
bunnyURL: '',
};
export const bunnyCDNSlice = createSlice({
name: 'BunnyCDNSlice',
initialState,
reducers: {
setState: (state, action: PayloadAction<BunnyCDNState.CreateCDNState>) => {
state.state = action.payload;
},
setCDNRecordData: (state, action: PayloadAction<string>) => {
state.bunnyURL = action.payload;
state.state = 'success';
},
},
});
export const bunnyCDNActions = {
...bunnyCDNSlice.actions,
...asyncThunk,
};
const selectENSState = (state: RootState): BunnyCDNState => state.bunnyCDN;
export const useBunnyCDNStore = (): BunnyCDNState =>
useAppSelector(selectENSState);
export default bunnyCDNSlice.reducer;

View File

@ -0,0 +1 @@
export * from './bunny-cdn-slice';

View File

@ -2,3 +2,4 @@ export * from './fleek-erc721';
export * from './github';
export * from './toasts';
export * from './ens';
export * from './bunny-cdn';

View File

@ -1,5 +1,6 @@
import { configureStore } from '@reduxjs/toolkit';
import bunnyCDNReducer from './features/bunny-cdn/bunny-cdn-slice';
import ENSReducer from './features/ens/ens-slice';
import fleekERC721Reducer from './features/fleek-erc721/fleek-erc721-slice';
import githubReducer from './features/github/github-slice';
@ -7,10 +8,11 @@ import toastsReducer from './features/toasts/toasts-slice';
export const store = configureStore({
reducer: {
bunnyCDN: bunnyCDNReducer,
ENS: ENSReducer,
fleekERC721: fleekERC721Reducer,
github: githubReducer,
toasts: toastsReducer,
ENS: ENSReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({

15
ui/src/utils/color.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Converts a hex color string to a number.
*/
export const parseColorToNumber = (color: string): number => {
const hexColor = color.replace('#', '');
return parseInt(hexColor, 16);
};
/**
* Converts string number to hex color string.
*/
export const parseNumberToHexColor = (color: number): string => {
const hexColor = color.toString(16);
return hexColor;
};

View File

@ -54,12 +54,23 @@ const hasSpecialCharacters: StringValidator = {
message: 'This field has special characters',
};
const isValidDomain: StringValidator = {
name: 'isValidDomain',
validate: (value = '') => {
const regex =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
return regex.test(value);
},
message: 'This field is not a valid domain',
};
export const StringValidators = {
required,
maxLength,
isUrl,
maxFileSize,
hasSpecialCharacters,
isValidDomain,
};
export const hasValidator = <

View File

@ -0,0 +1,156 @@
import { useQuery } from '@apollo/client';
import { ethers } from 'ethers';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAccount } from 'wagmi';
import {
Button,
CardTag,
Flex,
Form,
Spinner,
Stepper,
Text,
} from '@/components';
import { getNFADocument } from '@/graphclient';
import { useAppDispatch } from '@/store';
import { bunnyCDNActions, useBunnyCDNStore } from '@/store/features/bunny-cdn';
import { AppLog } from '@/utils';
import { CreateAccessPoint } from '../create-ap.context';
import { NFAIconFragment } from '../nfa-icon';
import { useAccessPointFormContext } from './create-ap.form.context';
export const SelectedNFA: React.FC = () => {
const { nfa } = CreateAccessPoint.useContext();
return (
<Flex
css={{
justifyContent: 'space-between',
}}
>
<Flex css={{ alignItems: 'center', maxWidth: '65%' }}>
<NFAIconFragment image={nfa.logo} color={nfa.color} />
<Text
css={{
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
>
{nfa.name}
</Text>
</Flex>
<CardTag css={{ minWidth: '$28' }}>Selected NFA</CardTag>
</Flex>
);
};
export const CreateAccessPointFormBody: React.FC = () => {
const { id } = useParams();
const { address } = useAccount();
const { nextStep } = Stepper.useContext();
const { nfa, setNfa, billing } = CreateAccessPoint.useContext();
const { setArgs } = CreateAccessPoint.useTransactionContext();
const { state } = useBunnyCDNStore();
const dispatch = useAppDispatch();
const {
form: {
domain: {
value: [domain],
},
isValid: [isValid],
},
} = useAccessPointFormContext();
const {
form: { domain: domainContext },
} = useAccessPointFormContext();
const { loading: nfaLoading } = useQuery(getNFADocument, {
skip: id === undefined,
variables: {
id: ethers.utils.hexlify(Number(id)),
},
onCompleted(data) {
if (data.token && id) {
const { name, tokenId, logo, color, externalURL: domain } = data.token;
setNfa({ name, tokenId, logo, color, domain });
} else {
AppLog.errorToast("We couldn't find the NFA you are looking for");
}
},
onError(error) {
AppLog.errorToast('Error fetching NFA', error);
},
});
useEffect(() => {
if (state === 'success') {
nextStep();
dispatch(bunnyCDNActions.setState(undefined));
}
}, [state, nextStep, dispatch]);
if (nfaLoading) {
return (
<Flex
css={{
justifyContent: 'center',
alignItems: 'center',
height: '$48',
}}
>
<Spinner />
</Flex>
);
}
const handleContinueClick = (): void => {
if (!address) {
AppLog.errorToast('No address found. Please connect your wallet.');
return;
}
if (nfa && domain) {
try {
setArgs([Number(nfa.tokenId), domain, { value: billing }]);
dispatch(
bunnyCDNActions.createBunnyCDN({
domain: 'domain',
targetDomain: domain,
})
);
} catch (e) {
AppLog.errorToast('Error setting transaction arguments');
}
}
};
return (
<Flex css={{ flexDirection: 'column', gap: '$6' }}>
<SelectedNFA />
<Text css={{ fontSize: '$sm', color: '$slate11' }}>
Enter the domain you want to host the NFA. You will need access to the
DNS settings in the next step.
</Text>
<Form.Field context={domainContext}>
<Form.Label>Domain</Form.Label>
<Form.Input placeholder="mydomain.com" />
<Form.Overline />
</Form.Field>
<Button
disabled={!isValid || nfa.tokenId === ''}
isLoading={state === 'loading'}
colorScheme="blue"
variant="solid"
onClick={handleContinueClick}
>
Continue
</Button>
</Flex>
);
};

View File

@ -1,6 +1,6 @@
import { Card, Grid, Icon, IconButton, Stepper } from '@/components';
import { Card, Flex, Icon, IconButton, Stepper } from '@/components';
import { CreateAccessPointFormBody } from './create-ap.form-body';
import { CreateAccessPointFormBody } from './create-ap-form-body';
export const CreateAccessPointForm: React.FC = () => {
const { prevStep } = Stepper.useContext();
@ -8,7 +8,7 @@ export const CreateAccessPointForm: React.FC = () => {
return (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title="Create Access Point"
title="Enter Domain"
leftIcon={
<IconButton
aria-label="Add"
@ -29,13 +29,14 @@ export const CreateAccessPointForm: React.FC = () => {
}
/>
<Card.Body>
<Grid
<Flex
css={{
rowGap: '$6',
flexDirection: 'column',
gap: '$6',
}}
>
<CreateAccessPointFormBody />
</Grid>
</Flex>
</Card.Body>
</Card.Container>
);

View File

@ -5,7 +5,7 @@ import { createContext, StringValidators } from '@/utils';
export type CreateAccessPointFormContext = {
form: {
appName: FormField;
domain: FormField;
isValid: ReactState<boolean>;
};
};
@ -20,9 +20,9 @@ export const [CreateAccessPointFormProvider, useAccessPointFormContext] =
export const useAccessPointFormContextInit =
(): CreateAccessPointFormContext => ({
form: {
appName: useFormField('appName', [
domain: useFormField('domain', [
StringValidators.required,
StringValidators.maxLength(50),
StringValidators.isValidDomain,
]),
isValid: useState(false),
},

View File

@ -0,0 +1,2 @@
export * from './create-ap.form.context';
export * from './create-ap-form';

View File

@ -0,0 +1,80 @@
import { useEffect, useMemo } from 'react';
import { Button, Card, Grid, SpinnerDot, Stepper, Text } from '@/components';
import { bunnyCDNActions, useAppDispatch, useBunnyCDNStore } from '@/store';
import { useAccessPointFormContext } from '../ap-form-step';
import { CreateAccessPoint } from '../create-ap.context';
import { DisplayText } from '../display-text';
import { isSubdomain } from './record-step.utils';
export const APRecordCardBody: React.FC = () => {
const dispatch = useAppDispatch();
const { bunnyURL, state } = useBunnyCDNStore();
const {
nfa: { domain: nfaDomain },
} = CreateAccessPoint.useContext();
const {
form: {
domain: {
value: [accesPointDomain],
},
},
} = useAccessPointFormContext();
const { nextStep } = Stepper.useContext();
const isSudomain = useMemo(
() => isSubdomain(accesPointDomain),
[accesPointDomain]
);
useEffect(() => {
if (state === 'success') {
dispatch(bunnyCDNActions.setState(undefined));
nextStep();
}
}, [state, nextStep, dispatch]);
const handleContinueClick = (): void => {
dispatch(bunnyCDNActions.verifyBunnyPullzone(nfaDomain));
};
return (
<Card.Body>
{state === 'loading' ? (
<Card.Text css={{ p: '$12 $10', gap: '$7' }}>
<SpinnerDot css={{ fontSize: '$7xl' }} />
<Text css={{ fontSize: '$md' }}>
Waiting for DNS propagation, allow a few minutes.
</Text>
</Card.Text>
) : (
<Grid
css={{
rowGap: '$6',
}}
>
<Text>
{`Create a ${
isSudomain ? 'CNAME' : 'ANAME'
} record in your DNS provider pointing to our CDN
endpoint.`}
</Text>
<DisplayText
label="Record Type"
value={isSudomain ? 'CNAME' : 'ANAME'}
/>
<DisplayText label="Host" value={isSudomain ? 'App' : '@'} />
<DisplayText label="Data (Points to)" value={bunnyURL} />
<Button
colorScheme="blue"
variant="solid"
onClick={handleContinueClick}
>
I added the record
</Button>
</Grid>
)}
</Card.Body>
);
};

View File

@ -0,0 +1,29 @@
import { Card, Icon, IconButton, Stepper } from '@/components';
export const APRecordCardHeader: React.FC = () => {
const { prevStep } = Stepper.useContext();
return (
<Card.Heading
title="Create Record"
leftIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="back" />}
css={{ mr: '$2' }}
onClick={prevStep}
/>
}
rightIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="info" />}
/>
}
/>
);
};

View File

@ -0,0 +1,13 @@
import { Card } from '@/components';
import { APRecordCardBody } from './ap-record-body';
import { APRecordCardHeader } from './ap-record-header';
export const APRecordStep: React.FC = () => {
return (
<Card.Container css={{ width: '$107h' }}>
<APRecordCardHeader />
<APRecordCardBody />
</Card.Container>
);
};

View File

@ -0,0 +1 @@
export * from './ap-record-step';

View File

@ -0,0 +1,5 @@
export const isSubdomain = (url: string): boolean => {
const urlParts = url.split('.');
return urlParts.length > 2;
};

View File

@ -1,36 +1,71 @@
import { ethers } from 'ethers';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useAccount } from 'wagmi';
import {
Button,
Card,
Flex,
Grid,
Icon,
IconButton,
ResolvedAddress,
Stepper,
Text,
} from '@/components';
import { useTransactionCost } from '@/hooks';
import { FleekERC721 } from '@/integrations';
import { AppLog } from '@/utils';
import { useAccessPointFormContext } from './ap-form-step/create-ap.form.context';
import { SelectedNFA } from './ap-form-step/create-ap-form-body';
import { CreateAccessPoint } from './create-ap.context';
import { useAccessPointFormContext } from './create-ap.form.context';
import { DisplayText } from './display-text';
export const AccessPointDataFragment: React.FC = () => {
const { address, status } = useAccount();
const {
form: {
domain: {
value: [domain],
},
},
} = useAccessPointFormContext();
if (status === 'connecting') return <div>Loading...</div>; //TODO replace with spinner
return (
<>
<SelectedNFA />
<DisplayText
label="Owner"
value={
address ? (
<ResolvedAddress truncated={false}>{address || ''}</ResolvedAddress>
) : (
'Please connect to wallet'
)
}
/>
<DisplayText label="Frontend URL" value={domain} />
</>
);
};
export const CreateAccessPointPreview: React.FC = () => {
const { prevStep } = Stepper.useContext();
const { address } = useAccount();
const {
prepare: { status: prepareStatus, data: prepareData, error: prepareError },
write: { status: writeStatus, write },
transaction: { status: transactionStatus },
} = CreateAccessPoint.useTransactionContext();
const {
form: {
appName: {
value: [appName],
},
isValid: [isValid],
},
} = useAccessPointFormContext();
const { nfa } = CreateAccessPoint.useContext();
const [cost, currency, isCostLoading] = useTransactionCost(
prepareData?.request.value,
@ -66,10 +101,19 @@ export const CreateAccessPointPreview: React.FC = () => {
[prepareStatus, writeStatus, transactionStatus]
);
useEffect(() => {
const error = [writeStatus, transactionStatus].some(
(status) => status === 'error'
);
if (error) {
AppLog.errorToast('An error occurred while minting the NFA');
}
}, [writeStatus, transactionStatus]);
return (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title={`Create Access Point ${nfa.label || ''}`}
title="Review Details"
leftIcon={
<IconButton
aria-label="Add"
@ -90,26 +134,19 @@ export const CreateAccessPointPreview: React.FC = () => {
}
/>
<Card.Body>
<Grid
css={{
rowGap: '$6',
}}
>
<Flex css={{ flexDirection: 'column' }}>
<span>NFA: {nfa.value}</span>
<span>{appName}</span>
<span className="text-slate11 text-sm">{message}</span>
</Flex>
<Flex css={{ flexDirection: 'column', gap: '$6' }}>
<AccessPointDataFragment />
<Text>{message}</Text>
<Button
disabled={!!prepareError || !nfa}
isLoading={isLoading}
isDisabled={isLoading || !isValid || !address}
colorScheme="blue"
variant="solid"
onClick={write}
isLoading={isLoading}
>
Create
</Button>
</Grid>
</Flex>
</Card.Body>
</Card.Container>
);

View File

@ -0,0 +1,47 @@
import { Button, Card, Flex, Icon, IconButton, Text } from '@/components';
import { CreateAccessPoint } from './create-ap.context';
import { AccessPointDataFragment } from './create-ap-preview';
export const CreateAccessPointSuccess: React.FC = () => {
const { nfa } = CreateAccessPoint.useContext();
return (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title="Hosting Successful"
leftIcon={
<Icon
name="check-circle"
css={{ color: '$green11', fontSize: '$xl', mr: '$2' }}
/>
}
rightIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="info" />}
/>
}
/>
<Card.Body>
<Flex css={{ flexDirection: 'column', gap: '$6' }}>
<Text css={{ fontSize: '$sm', color: '$slate11' }}>
{`You have successfully hosted a ${nfa.name} frontend on your own domain.`}
</Text>
<AccessPointDataFragment />
<Flex css={{ flexDirection: 'column', gap: '$4' }}>
<Button
colorScheme="blue"
variant="solid"
leftIcon={<Icon name="twitter" />}
>
Tweet about your frontend!
</Button>
<Button>Manage Frontend</Button>
</Flex>
</Flex>
</Card.Body>
</Card.Container>
);
};

View File

@ -1,16 +1,23 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState } from 'react';
import { Token } from '@/graphclient';
import { EthereumHooks } from '@/integrations';
import { useFleekERC721Billing } from '@/store';
import { AppLog, createContext, pushToast } from '@/utils';
import { AppLog, createContext } from '@/utils';
type NFA = Pick<Token, 'id' | 'name'>;
export type NFA = {
tokenId: string;
name: string;
logo: string;
color: number;
domain: string;
};
export type AccessPointContext = {
billing: string | undefined;
nfa: NFA | undefined;
setNfa: ReactState<NFA | undefined>[1];
nfa: NFA;
setNfa: (nfa: NFA) => void;
};
const [CreateAPProvider, useContext] = createContext<AccessPointContext>({
@ -31,7 +38,13 @@ export abstract class CreateAccessPoint {
children,
}) => {
const [billing] = useFleekERC721Billing('AddAccessPoint');
const [nfa, setNfa] = useState<NFA>();
const [nfa, setNfa] = useState<NFA>({
tokenId: '',
name: '',
logo: '',
color: 0,
domain: '',
});
const value = {
billing,
@ -44,12 +57,10 @@ export abstract class CreateAccessPoint {
<TransactionProvider
config={{
transaction: {
onSuccess: (data) => {
onSuccess: (data: any) => {
AppLog.info('Transaction:', data);
pushToast('success', 'Your transaction was successful!');
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onError: (error) => {
onError: (error: any) => {
AppLog.errorToast(
'There was an error trying to create the Access Point. Please try again'
);

View File

@ -1,100 +0,0 @@
import { useQuery } from '@apollo/client';
import { ethers } from 'ethers';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Flex, Form, Spinner, Stepper } from '@/components';
import { getNFADocument } from '@/graphclient';
import { AppLog } from '@/utils';
import { CreateAccessPoint } from './create-ap.context';
import { useAccessPointFormContext } from './create-ap.form.context';
import { NfaPicker } from './nfa-picker';
export const CreateAccessPointFormBody: React.FC = () => {
const { id } = useParams();
const { nextStep } = Stepper.useContext();
const { nfa, setNfa, billing } = CreateAccessPoint.useContext();
const { setArgs } = CreateAccessPoint.useTransactionContext();
const {
form: {
appName: {
value: [appName],
},
isValid: [isValid],
},
} = useAccessPointFormContext();
const {
form: { appName: appNameContext },
} = useAccessPointFormContext();
const {
data: nfaData,
error: nfaError,
loading: nfaLoading,
} = useQuery(getNFADocument, {
skip: id === undefined,
variables: {
id: ethers.utils.hexlify(Number(id)),
},
});
useEffect(() => {
if (nfaError) {
AppLog.errorToast('Error fetching NFA');
}
}, [nfaError]);
useEffect(() => {
if (nfaData) {
if (nfaData.token && id) {
const { name } = nfaData.token;
setNfa({ value: id, label: name });
} else {
AppLog.errorToast("We couldn't find the NFA you are looking for");
}
}
}, [nfaData, id, setNfa]);
if (nfaLoading) {
return (
<Flex
css={{
justifyContent: 'center',
alignItems: 'center',
height: '$48',
}}
>
<Spinner />
</Flex>
);
}
const handleContinueClick = (): void => {
if (nfa && appName) {
setArgs([Number(nfa.value), appName, { value: billing }]);
nextStep();
}
};
return (
<>
{/* TODO will have to do some changes on the Form.Combobox if we use this component for the NFA picker */}
{id === undefined && <NfaPicker />}
<Form.Field context={appNameContext}>
<Form.Label>App Name</Form.Label>
<Form.Input />
</Form.Field>
<Button
disabled={!isValid}
colorScheme="blue"
variant="solid"
onClick={handleContinueClick}
>
Continue
</Button>
</>
);
};

View File

@ -1,16 +1,29 @@
import { Form, Step, Stepper } from '@/components';
import { WalletStep } from '../mint/wallet-step';
import { useAccessPointFormContext } from './create-ap.form.context';
import { CreateAccessPointForm } from './create-ap-form';
import { useAccessPointFormContext } from './ap-form-step/create-ap.form.context';
import { CreateAccessPointForm } from './ap-form-step/create-ap-form';
import { APRecordStep } from './ap-record-step/ap-record-step';
import { isSubdomain } from './ap-record-step/record-step.utils';
import { CreateAccessPoint } from './create-ap.context';
import { CreateAccessPointPreview } from './create-ap-preview';
import { CreateAccessPointSuccess } from './create-ap-success';
export const CreateApStepper: React.FC = () => {
const {
transaction: { isSuccess },
} = CreateAccessPoint.useTransactionContext();
const {
form: {
domain: {
value: [accesPointDomain],
},
isValid: [, setIsValid],
},
} = useAccessPointFormContext();
if (isSuccess) return <CreateAccessPointSuccess />;
return (
<Stepper.Root initialStep={1}>
<Form.Root onValidationChange={setIsValid}>
@ -22,13 +35,23 @@ export const CreateApStepper: React.FC = () => {
</Stepper.Step>
<Stepper.Step>
<Step header="Set Access Point">
<Step header="Enter the domain you want to host the NFA">
<CreateAccessPointForm />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Create Access Point">
<Step
header={`Add a ${
isSubdomain(accesPointDomain) ? 'CNAME' : 'ANAME'
} record to your DNS provider`}
>
<APRecordStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Review your hosted frontend and confirm">
<CreateAccessPointPreview />
</Step>
</Stepper.Step>

View File

@ -1,10 +1,10 @@
import { Flex } from '@/components';
import { CreateAccessPoint } from './create-ap.context';
import {
CreateAccessPointFormProvider,
useAccessPointFormContextInit,
} from './create-ap.form.context';
} from './ap-form-step/create-ap.form.context';
import { CreateAccessPoint } from './create-ap.context';
import { CreateApStepper } from './create-ap.stepper';
export const CreateAP: React.FC = () => {

View File

@ -0,0 +1,25 @@
import { styled } from '@/theme';
import { Flex } from '../../../components/layout';
export const DisplayTextStyles = {
Container: styled(Flex, {
flexDirection: 'column',
}),
Label: styled('label', {
color: '$slate11',
mb: '$1h',
fontSize: '$xs',
//TODO add variants
}),
Input: styled('span', {
backgroundColor: '$slate1',
borderColor: '$slate1',
color: '$slate12',
borderRadius: '$lg',
fontSize: '$sm',
height: '$11',
p: '$3 $3h',
}),
};

View File

@ -0,0 +1,17 @@
import { DisplayTextStyles as S } from './display-text.styles';
type DisplayTextProps = {
label: string;
value: string | React.ReactNode;
};
export const DisplayText: React.FC<DisplayTextProps> = ({
label,
value,
}: DisplayTextProps) => {
return (
<S.Container>
<S.Label>{label}</S.Label>
<S.Input>{value}</S.Input>
</S.Container>
);
};

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import { styled } from '@/theme';
export const NFAIconStyles = {
Container: styled('span', {
p: '$1h',
borderRadius: '$full',
width: '$7',
height: '$7',
mr: '$2',
}),
Image: styled('img', {
width: '100%',
height: '100%',
}),
};

View File

@ -0,0 +1,21 @@
import { parseNumberToHexColor } from '@/utils/color';
import { NFAIconStyles as NS } from './nfa-icon.styles';
type NFAIconProps = {
image: string;
color: number;
};
export const NFAIconFragment: React.FC<NFAIconProps> = ({
image,
color,
}: NFAIconProps) => {
return (
<NS.Container
css={{ backgroundColor: `#${parseNumberToHexColor(color)}57` }}
>
<NS.Image src={image} />
</NS.Container>
);
};

View File

@ -1,36 +0,0 @@
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { Combobox } from '@/components';
import { getLatestNFAsDocument } from '@/graphclient';
import { AppLog } from '@/utils';
import { CreateAccessPoint } from './create-ap.context';
export const NfaPicker: React.FC = () => {
const { nfa, setNfa } = CreateAccessPoint.useContext();
const { data, loading, error } = useQuery(getLatestNFAsDocument);
const items = useMemo(() => data?.tokens || [], [data]);
if (error) {
AppLog.errorToast('Error loading NFA list', error);
}
return (
<Combobox
isLoading={loading}
items={items}
selected={[nfa, setNfa]}
queryKey={['name', 'id']}
>
{({ Field, Options }) => (
<>
<Field>{(selected) => selected?.name || 'Select NFA'}</Field>
<Options>{(item) => item.name}</Options>
</>
)}
</Combobox>
);
};

View File

@ -2,13 +2,15 @@ import { Flex, ResolvedAddress } from '@/components';
import { ColorPickerTest } from './color-picker';
import { ComboboxTest } from './combobox-test';
import { SpinnerTest } from './spinner-test';
import { ToastTest } from './toast-test';
export const ComponentsTest: React.FC = () => {
return (
<Flex css={{ flexDirection: 'column' }}>
<SpinnerTest />
<ResolvedAddress css={{ alignSelf: 'center' }}>
{'0x7ed735b7095c05d78df169f991f2b7f1a1f1a049'}
{'0x7ed735b7095c05d78df169f991f2b7f1a1f1a049a'}
</ResolvedAddress>
<ComboboxTest />
<ColorPickerTest />

View File

@ -0,0 +1,18 @@
import { Flex, Spinner, SpinnerDot } from '@/components';
export const SpinnerTest: React.FC = () => {
return (
<>
<Flex css={{ alignItems: 'center' }}>
<SpinnerDot css={{ fontSize: '$6xl' }} />
<SpinnerDot css={{ fontSize: '$4xl' }} />
<SpinnerDot css={{ fontSize: '$lg' }} />
</Flex>
<Flex css={{ alignItems: 'center' }}>
<Spinner css={{ fontSize: '$6xl' }} />
<Spinner css={{ fontSize: '$4xl' }} />
<Spinner css={{ fontSize: '$lg' }} />
</Flex>
</>
);
};

View File

@ -4,7 +4,7 @@ import { styled } from '@/theme';
export abstract class Explore {
static readonly Container = styled(Flex, {
flexDirection: 'column',
width: '64.75rem', //TODO replace for max-width
width: '63.4rem', //TODO replace for max-width
margin: '0 auto',
});
}

View File

@ -1,4 +1,4 @@
import { Button, Card, Flex, Stepper } from '@/components';
import { Button, Card, CardTag, Flex, Stepper } from '@/components';
import { Mint } from '@/views/mint/mint.context';
import { useMintFormContext } from '@/views/mint/nfa-step/form-step';
@ -25,17 +25,8 @@ export const RepoConfigurationBody: React.FC = () => {
<Flex css={{ rowGap: '$6', flexDirection: 'column' }}>
<RepoRow
repo={repositoryName.name}
css={{ mb: '0' }}
button={
<Button
colorScheme="gray"
disabled
variant="outline"
css={{ py: '$1', height: '$5', borderRadius: '$md' }}
>
Use for NFA
</Button>
}
css={{ mb: '0', cursor: 'default' }}
button={<CardTag>Use for NFA</CardTag>}
/>
<RepoBranchCommitFields />
<Button

View File

@ -31,6 +31,7 @@ export const Repository: React.FC<RepositoryProps> = ({
<RepoRow
onClick={handleSelectRepo}
repo={repository.name}
css={{ cursor: 'pointer' }}
button={
<Button
colorScheme="blue"

View File

@ -16,7 +16,6 @@ export const RepoRow = forwardRef<HTMLDivElement, RepoRowProps>(
justifyContent: 'space-between',
my: '$4',
...props.css,
cursor: 'pointer',
}}
>
<Flex css={{ alignItems: 'center' }}>

View File

@ -18,39 +18,37 @@ export const MintStepper: React.FC = () => {
},
} = useMintFormContext();
if (!isSuccess) {
return (
<Stepper.Root initialStep={1}>
<Form.Root onValidationChange={setIsValid}>
<Stepper.Container>
<Stepper.Step>
<Step header="Connect your Ethereum Wallet to mint an NFA">
<WalletStep />
</Step>
</Stepper.Step>
if (isSuccess) return <NftMinted />;
<Stepper.Step>
<Step header="Connect GitHub and select repository">
<GithubStep />
</Step>
</Stepper.Step>
return (
<Stepper.Root initialStep={1}>
<Form.Root onValidationChange={setIsValid}>
<Stepper.Container>
<Stepper.Step>
<Step header="Connect your Ethereum Wallet to mint an NFA">
<WalletStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Finalize a few key things for your NFA">
<NFAStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Connect GitHub and select repository">
<GithubStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Review your NFA and mint it on Ethereum">
<MintPreview />
</Step>
</Stepper.Step>
</Stepper.Container>
</Form.Root>
</Stepper.Root>
);
} else {
return <NftMinted />;
}
<Stepper.Step>
<Step header="Finalize a few key things for your NFA">
<NFAStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Review your NFA and mint it on Ethereum">
<MintPreview />
</Step>
</Stepper.Step>
</Stepper.Container>
</Form.Root>
</Stepper.Root>
);
};

View File

@ -8,11 +8,3 @@ export const fileToBase64 = (file: File): Promise<string> =>
reader.onload = () => resolve(reader.result?.toString() || '');
reader.onerror = reject;
});
/**
* Converts a hex color string to a number.
*/
export const parseColorToNumber = (color: string): number => {
const hexColor = color.replace('#', '');
return parseInt(hexColor, 16);
};

View File

@ -2,6 +2,7 @@ import { useAccount } from 'wagmi';
import { Button, Card, Grid, Stepper } from '@/components';
import { AppLog } from '@/utils';
import { parseColorToNumber } from '@/utils/color';
import { Mint } from '../../mint.context';
import { MintCardHeader } from '../../mint-card';
@ -11,7 +12,6 @@ import {
EnsDomainField,
LogoField,
} from './fields';
import { parseColorToNumber } from './form.utils';
import { useMintFormContext } from './mint-form.context';
export const MintFormStep: React.FC = () => {
@ -54,7 +54,7 @@ export const MintFormStep: React.FC = () => {
AppLog.errorToast('No address found. Please connect your wallet.');
return;
}
// setting the args otherwise mint may fail
setArgs([
address,
appName,
@ -62,7 +62,7 @@ export const MintFormStep: React.FC = () => {
domainURL,
ens,
gitCommit,
`${repositoryName.url}/tree/${gitBranch}`,
`${repositoryName?.url}/tree/${gitBranch}`,
appLogo,
parseColorToNumber(logoColor),
verifyNFA,