Add Onboarding components

This commit is contained in:
Justin 2022-04-12 09:52:04 -04:00 committed by Alex Gleason
parent c8c715ee4b
commit 98c77006ce
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
11 changed files with 711 additions and 10 deletions

View file

@ -19,7 +19,9 @@ import { FE_SUBDIRECTORY } from 'soapbox/build_config';
import { NODE_ENV } from 'soapbox/build_config';
import Helmet from 'soapbox/components/helmet';
import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
import { createGlobals } from 'soapbox/globals';
import messages from 'soapbox/locales/messages';
@ -27,10 +29,9 @@ import { makeGetAccount } from 'soapbox/selectors';
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
import { generateThemeCss } from 'soapbox/utils/theme';
import { INTRODUCTION_VERSION } from '../actions/onboarding';
import { checkOnboardingStatus } from '../actions/onboarding';
import { preload } from '../actions/preload';
import ErrorBoundary from '../components/error_boundary';
// import Introduction from '../features/introduction';
import UI from '../features/ui';
import { store } from '../store';
@ -54,6 +55,7 @@ store.dispatch(fetchMe())
// Postpone for authenticated fetch
store.dispatch(loadInstance());
store.dispatch(loadSoapboxConfig());
store.dispatch(checkOnboardingStatus());
if (!account) {
store.dispatch(fetchVerificationConfig());
@ -66,7 +68,6 @@ const makeAccount = makeGetAccount();
const mapStateToProps = (state) => {
const me = state.get('me');
const account = makeAccount(state, me);
const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false;
const settings = getSettings(state);
const soapboxConfig = getSoapboxConfig(state);
const locale = settings.get('locale');
@ -74,7 +75,6 @@ const mapStateToProps = (state) => {
const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile');
return {
showIntroduction,
me,
account,
instanceLoaded: isInstanceLoaded(state),
@ -88,6 +88,7 @@ const mapStateToProps = (state) => {
brandColor: soapboxConfig.get('brandColor'),
themeMode: settings.get('themeMode'),
singleUserMode,
needsOnboarding: state.onboarding.needsOnboarding,
};
};
@ -95,13 +96,13 @@ const mapStateToProps = (state) => {
class SoapboxMount extends React.PureComponent {
static propTypes = {
showIntroduction: PropTypes.bool,
me: SoapboxPropTypes.me,
account: ImmutablePropTypes.record,
instanceLoaded: PropTypes.bool,
reduceMotion: PropTypes.bool,
underlineLinks: PropTypes.bool,
systemFont: PropTypes.bool,
needsOnboarding: PropTypes.bool,
dyslexicFont: PropTypes.bool,
demetricator: PropTypes.bool,
locale: PropTypes.string.isRequired,
@ -151,11 +152,23 @@ class SoapboxMount extends React.PureComponent {
const waitlisted = account && !account.getIn(['source', 'approved'], true);
// Disabling introduction for launch
// const { showIntroduction } = this.props;
//
// if (showIntroduction) {
// return <Introduction />;
// }
const { needsOnboarding } = this.props;
if (needsOnboarding) {
return (
<IntlProvider locale={locale} messages={this.state.messages}>
<Helmet>
<html lang='en' className={classNames({ dark: this.props.themeMode === 'dark' })} />
<body className={bodyClass} />
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
<meta name='theme-color' content={this.props.brandColor} />
</Helmet>
<OnboardingWizard />
<NotificationsContainer />
</IntlProvider>
);
}
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', {
'no-reduce-motion': !this.props.reduceMotion,

View file

@ -0,0 +1,111 @@
import classNames from 'classnames';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'soapbox/actions/onboarding';
import { HStack } from 'soapbox/components/ui';
import AvatarSelectionStep from './steps/avatar-selection-step';
import BioStep from './steps/bio-step';
import CompletedStep from './steps/completed-step';
import CoverPhotoSelectionStep from './steps/cover-photo-selection-step';
import DisplayNameStep from './steps/display-name-step';
import SuggestedAccountsStep from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
const dispatch = useDispatch();
const [currentStep, setCurrentStep] = React.useState<number>(0);
const handleSwipe = (nextStep: number) => {
setCurrentStep(nextStep);
};
const handlePreviousStep = () => {
setCurrentStep((prevStep) => Math.max(0, prevStep - 1));
};
const handleNextStep = () => {
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length - 1));
};
const handleComplete = () => {
dispatch(endOnboarding());
};
const steps = [
<AvatarSelectionStep onNext={handleNextStep} />,
<DisplayNameStep onNext={handleNextStep} />,
<BioStep onNext={handleNextStep} />,
<CoverPhotoSelectionStep onNext={handleNextStep} />,
<SuggestedAccountsStep onNext={handleNextStep} />,
<CompletedStep onComplete={handleComplete} />,
];
const handleKeyUp = ({ key }: KeyboardEvent): void => {
switch (key) {
case 'ArrowLeft':
handlePreviousStep();
break;
case 'ArrowRight':
handleNextStep();
break;
}
};
const handleDotClick = (nextStep: number) => {
setCurrentStep(nextStep);
};
React.useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, []);
return (
<div>
<div className='fixed h-screen w-full bg-gradient-to-tr from-primary-50 via-white to-cyan-50' />
<main className='h-screen flex flex-col'>
<div className='flex flex-col justify-center items-center h-full'>
<ReactSwipeableViews animateHeight index={currentStep} onChangeIndex={handleSwipe}>
{steps.map((step, i) => (
<div key={i} className='py-6 sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
<div
className={classNames({
'transition-opacity ease-linear': true,
'opacity-0 duration-500': currentStep !== i,
'opacity-100 duration-75': currentStep === i,
})}
>
{step}
</div>
</div>
))}
</ReactSwipeableViews>
<HStack space={3} alignItems='center' justifyContent='center' className='relative'>
{steps.map((_, i) => (
<button
key={i}
tabIndex={0}
onClick={() => handleDotClick(i)}
className={classNames({
'w-5 h-5 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
'bg-gray-200 hover:bg-gray-300': i !== currentStep,
'bg-primary-600': i === currentStep,
})}
/>
))}
</HStack>
</div>
</main>
</div>
);
};
export default OnboardingWizard;

View file

@ -0,0 +1,118 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize_image';
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 400 * 400;
const [rawFile] = event.target.files || [] as any;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.avatar as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('avatar', rawFile);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
Choose a profile picture
</Text>
<Text theme='muted' align='center'>
Just have fun with it.
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={10}>
<div className='bg-gray-200 rounded-full relative mx-auto'>
{account && (
<Avatar src={selectedFile || account.avatar} size={175} />
)}
{isSubmitting && (
<div className='absolute inset-0 rounded-full flex justify-center items-center bg-white/80'>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={classNames({
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-full ring-2 ring-white hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/icons/plus.svg')} className='text-white w-5 h-5' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='submit' disabled={isDisabled || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
{isDisabled && (
<Button block theme='link' type='button' onClick={onNext}>Skip for now</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default AvatarSelectionStep;

View file

@ -0,0 +1,94 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
const BioStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const [value, setValue] = React.useState<string>('');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid;
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ note: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
Write a short bio
</Text>
<Text theme='muted' align='center'>
You can always edit this later.
</Text>
</Stack>
</div>
<Stack space={5}>
<div className='sm:pt-10 sm:w-2/3 mx-auto'>
<FormGroup
hintText='Max 500 characters'
labelText='Bio'
errors={errors}
>
<Textarea
onChange={(event) => setValue(event.target.value)}
placeholder='Tell the world a little about yourself...'
value={value}
maxLength={500}
/>
</FormGroup>
</div>
<div className='sm:w-2/3 md:w-1/2 mx-auto'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
<Button block theme='link' type='button' onClick={onNext}>Skip for now</Button>
</Stack>
</div>
</Stack>
</div>
</CardBody>
</Card>
);
};
export default BioStep;

View file

@ -0,0 +1,37 @@
import * as React from 'react';
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';
const CompletedStep = ({ onComplete }: { onComplete: () => void }) => (
<Card variant='rounded' size='xl'>
<CardBody>
<Stack space={2}>
<Icon strokeWidth={1} src={require('@tabler/icons/icons/confetti.svg')} className='w-16 h-16 mx-auto text-primary-600' />
<Text size='2xl' align='center' weight='bold'>
Onboarding complete
</Text>
<Text theme='muted' align='center'>
We are very excited to welcome you to our Truth Seeking
community! Tap the button below to start enjoying
Truth Social.
</Text>
</Stack>
<div className='pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onComplete}
>
View Feed
</Button>
</Stack>
</div>
</CardBody>
</Card>
);
export default CompletedStep;

View file

@ -0,0 +1,142 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import StillImage from 'soapbox/components/still_image';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize_image';
const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 1920 * 1080;
const [rawFile] = event.target.files || [] as any;
console.log('fike', rawFile);
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.header as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('header', file);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
Pick a cover image
</Text>
<Text theme='muted' align='center'>
This will be shown at the top of your profile.
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={10}>
<div className='border border-solid border-gray-200 rounded-lg'>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div
role='button'
className='relative h-24 bg-primary-100 rounded-t-md flex items-center justify-center'
>
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt='Profile Header'
className='absolute inset-0 object-cover rounded-t-md'
/>
)}
{isSubmitting && (
<div
className='absolute inset-0 rounded-t-md flex justify-center items-center bg-white/80'
>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={classNames({
'absolute -top-3 -right-3 p-1 bg-primary-600 rounded-full ring-2 ring-white hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/icons/plus.svg')} className='text-white w-5 h-5' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<div className='flex flex-col px-4 pb-4'>
{account && (
<Avatar src={account.avatar} size={64} className='ring-2 ring-white -mt-8 mb-2' />
)}
<Text weight='bold' size='sm'>{account?.display_name}</Text>
<Text theme='muted' size='sm'>@{account?.username}</Text>
</div>
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='submit' disabled={isDisabled || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
{isDisabled && (
<Button block theme='link' type='button' onClick={onNext}>Skip for now</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default CoverPhotoSelectionStep;

View file

@ -0,0 +1,100 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Input, Stack, Text } from 'soapbox/components/ui';
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const [value, setValue] = React.useState<string>('');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid || value.length > 30;
const hintText = React.useMemo(() => {
const charsLeft = 30 - value.length;
const suffix = charsLeft === 1 ? 'character remaining' : 'characters remaining';
return `${charsLeft} ${suffix}`;
}, [value]);
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ display_name: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
Choose a display name
</Text>
<Text theme='muted' align='center'>
You can always edit this later.
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={5}>
<FormGroup
hintText={hintText}
labelText='Display name'
errors={errors}
>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder='Eg. John Smith'
type='text'
value={value}
maxLength={30}
/>
</FormGroup>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
<Button block theme='link' type='button' onClick={onNext}>Skip for now</Button>
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default DisplayNameStep;

View file

@ -0,0 +1,76 @@
import { Map as ImmutableMap } from 'immutable';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
import { fetchSuggestions } from '../../../actions/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const suggestions = useAppSelector((state) => state.suggestions.get('items'));
const suggestionsToRender = suggestions.slice(0, 5);
React.useEffect(() => {
dispatch(fetchSuggestions());
}, []);
if (suggestionsToRender.isEmpty()) {
return null;
}
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
Suggested accounts
</Text>
<Text theme='muted' align='center'>
Here are a few of the most popular accounts you might like.
</Text>
</Stack>
</div>
<div className='sm:pt-4 sm:pb-10 flex flex-col divide-y divide-solid divide-gray-200'>
{suggestionsToRender.map((suggestion: ImmutableMap<string, any>) => (
<div key={suggestion.get('account')} className='py-2'>
<AccountContainer
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.get('account')}
showProfileHoverCard={false}
/>
</div>
))}
</div>
<div className='sm:w-2/3 md:w-1/2 mx-auto'>
<Stack>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onNext}
>
Done
</Button>
<Button block theme='link' type='button' onClick={onNext}>Skip for now</Button>
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default SuggestedAccountsStep;

View file

@ -6,6 +6,7 @@ import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar';
import { createAccount } from 'soapbox/actions/verification';
import { removeStoredVerification } from 'soapbox/actions/verification';
@ -40,6 +41,7 @@ const Registration = () => {
.then(() => {
setShouldRedirect(true);
removeStoredVerification();
dispatch(startOnboarding());
dispatch(
snackbar.success(
intl.formatMessage({

View file

@ -79,6 +79,7 @@
"@types/react-helmet": "^6.1.5",
"@types/react-motion": "^0.0.32",
"@types/react-router-dom": "^5.3.3",
"@types/react-swipeable-views": "^0.13.1",
"@types/react-toggle": "^4.0.3",
"@types/redux-mock-store": "^1.0.3",
"@types/semver": "^7.3.9",

View file

@ -2229,6 +2229,13 @@
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-swipeable-views@^0.13.1":
version "0.13.1"
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
integrity sha512-Nuvywkv9CkwcUgItOCBszkc/pc8YSdiKV5E1AzOJ/p32Db50LgwhJFi5b1ANPgyWxB0Q5yn69aMURHyGi3MLyg==
dependencies:
"@types/react" "*"
"@types/react-toggle@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/react-toggle/-/react-toggle-4.0.3.tgz#8db98ac8d2c5e8c03c2d3a42027555c1cd2289da"