Merge branch 'next-onboarding' into 'next'
Next: onboarding flow See merge request soapbox-pub/soapbox-fe!1217
This commit is contained in:
commit
074a1a6fce
|
@ -0,0 +1,24 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { createTestStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { ONBOARDING_VERSION, endOnboarding } from '../onboarding';
|
||||
|
||||
describe('endOnboarding()', () => {
|
||||
it('updates the onboardingVersion setting', async() => {
|
||||
const store = createTestStore(rootState);
|
||||
|
||||
// Sanity check:
|
||||
// `onboardingVersion` should be `0` by default
|
||||
const initialVersion = getSettings(store.getState()).get('onboardingVersion');
|
||||
expect(initialVersion).toBe(0);
|
||||
|
||||
await store.dispatch(endOnboarding());
|
||||
|
||||
// After dispatching, `onboardingVersion` is updated
|
||||
const updatedVersion = getSettings(store.getState()).get('onboardingVersion');
|
||||
expect(updatedVersion).toBe(ONBOARDING_VERSION);
|
||||
|
||||
// Sanity check: `updatedVersion` is greater than `initialVersion`
|
||||
expect(updatedVersion > initialVersion).toBe(true);
|
||||
});
|
||||
});
|
|
@ -1,8 +0,0 @@
|
|||
import { changeSetting, saveSettings } from './settings';
|
||||
|
||||
export const INTRODUCTION_VERSION = 20181216044202;
|
||||
|
||||
export const closeOnboarding = () => dispatch => {
|
||||
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
|
||||
dispatch(saveSettings());
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
import { changeSettingImmediate } from 'soapbox/actions/settings';
|
||||
|
||||
/** Repeat the onboading process when we bump the version */
|
||||
export const ONBOARDING_VERSION = 1;
|
||||
|
||||
/** Finish onboarding and store the setting */
|
||||
const endOnboarding = () => (dispatch: React.Dispatch<any>) => {
|
||||
dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION));
|
||||
};
|
||||
|
||||
export {
|
||||
endOnboarding,
|
||||
};
|
|
@ -20,7 +20,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
export const defaultSettings = ImmutableMap({
|
||||
onboarded: false,
|
||||
onboardingVersion: 0,
|
||||
skinTone: 1,
|
||||
reduceMotion: false,
|
||||
underlineLinks: false,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent'
|
||||
type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' | 'link'
|
||||
type ButtonSizes = 'sm' | 'md' | 'lg'
|
||||
|
||||
type IButtonStyles = {
|
||||
|
@ -25,6 +25,7 @@ const useButtonStyles = ({
|
|||
accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2',
|
||||
danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2',
|
||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||
link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import SvgIcon from './svg-icon';
|
||||
|
||||
interface IIcon {
|
||||
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||
className?: string,
|
||||
count?: number,
|
||||
alt?: string,
|
||||
|
|
|
@ -11,7 +11,7 @@ const messages = defineMessages({
|
|||
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
|
||||
});
|
||||
|
||||
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> {
|
||||
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> {
|
||||
autoFocus?: boolean,
|
||||
defaultValue?: string,
|
||||
className?: string,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5
|
||||
type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10
|
||||
|
||||
const spaces = {
|
||||
'0.5': 'space-y-0.5',
|
||||
|
@ -11,6 +11,7 @@ const spaces = {
|
|||
3: 'space-y-3',
|
||||
4: 'space-y-4',
|
||||
5: 'space-y-5',
|
||||
10: 'space-y-10',
|
||||
};
|
||||
|
||||
const justifyContentOptions = {
|
||||
|
|
|
@ -6,6 +6,7 @@ type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
|
|||
type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
type Alignments = 'left' | 'center' | 'right'
|
||||
type TrackingSizes = 'normal' | 'wide'
|
||||
type TransformProperties = 'uppercase' | 'normal'
|
||||
type Families = 'sans' | 'mono'
|
||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
|
||||
|
@ -48,6 +49,11 @@ const trackingSizes = {
|
|||
wide: 'tracking-wide',
|
||||
};
|
||||
|
||||
const transformProperties = {
|
||||
normal: 'normal-case',
|
||||
uppercase: 'uppercase',
|
||||
};
|
||||
|
||||
const families = {
|
||||
sans: 'font-sans',
|
||||
mono: 'font-mono',
|
||||
|
@ -62,6 +68,7 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
|||
tag?: Tags,
|
||||
theme?: Themes,
|
||||
tracking?: TrackingSizes,
|
||||
transform?: TransformProperties,
|
||||
truncate?: boolean,
|
||||
weight?: Weights
|
||||
}
|
||||
|
@ -76,6 +83,7 @@ const Text: React.FC<IText> = React.forwardRef(
|
|||
tag = 'p',
|
||||
theme = 'default',
|
||||
tracking = 'normal',
|
||||
transform = 'normal',
|
||||
truncate = false,
|
||||
weight = 'normal',
|
||||
...filteredProps
|
||||
|
@ -99,6 +107,7 @@ const Text: React.FC<IText> = React.forwardRef(
|
|||
[trackingSizes[tracking]]: true,
|
||||
[families[family]]: true,
|
||||
[alignmentClass]: typeof align !== 'undefined',
|
||||
[transformProperties[transform]]: typeof transform !== 'undefined',
|
||||
}, className)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface ITextarea {
|
||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required'> {
|
||||
autoFocus?: boolean,
|
||||
defaultValue?: string,
|
||||
name?: string,
|
||||
isCodeEditor?: boolean,
|
||||
placeholder?: string,
|
||||
value?: string,
|
||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef(
|
||||
|
|
|
@ -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';
|
||||
|
@ -28,10 +30,9 @@ import { getFeatures } from 'soapbox/utils/features';
|
|||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
|
||||
import { INTRODUCTION_VERSION } from '../actions/onboarding';
|
||||
import { ONBOARDING_VERSION } 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';
|
||||
|
||||
|
@ -71,15 +72,14 @@ 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 needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION;
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
const locale = settings.get('locale');
|
||||
|
||||
const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile');
|
||||
|
||||
return {
|
||||
showIntroduction,
|
||||
me,
|
||||
account,
|
||||
reduceMotion: settings.get('reduceMotion'),
|
||||
|
@ -93,6 +93,7 @@ const mapStateToProps = (state) => {
|
|||
appleAppId: soapboxConfig.get('appleAppId'),
|
||||
themeMode: settings.get('themeMode'),
|
||||
singleUserMode,
|
||||
needsOnboarding,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -100,12 +101,12 @@ const mapStateToProps = (state) => {
|
|||
class SoapboxMount extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
showIntroduction: PropTypes.bool,
|
||||
me: SoapboxPropTypes.me,
|
||||
account: ImmutablePropTypes.record,
|
||||
reduceMotion: PropTypes.bool,
|
||||
underlineLinks: PropTypes.bool,
|
||||
systemFont: PropTypes.bool,
|
||||
needsOnboarding: PropTypes.bool,
|
||||
dyslexicFont: PropTypes.bool,
|
||||
demetricator: PropTypes.bool,
|
||||
locale: PropTypes.string.isRequired,
|
||||
|
@ -154,7 +155,7 @@ class SoapboxMount extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { me, account, themeCss, locale, singleUserMode } = this.props;
|
||||
const { me, account, themeCss, locale, needsOnboarding, singleUserMode } = this.props;
|
||||
if (me === null) return null;
|
||||
if (me && !account) return null;
|
||||
if (!this.state.isLoaded) return null;
|
||||
|
@ -162,6 +163,26 @@ class SoapboxMount extends React.PureComponent {
|
|||
|
||||
const waitlisted = account && !account.getIn(['source', 'approved'], true);
|
||||
|
||||
if (account && !waitlisted && 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>
|
||||
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter basename={FE_SUBDIRECTORY}>
|
||||
<OnboardingWizard />
|
||||
<NotificationsContainer />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', {
|
||||
'no-reduce-motion': !this.props.reduceMotion,
|
||||
'underline-links': this.props.underlineLinks,
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,137 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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';
|
||||
|
||||
/** Default avatar filenames from various backends */
|
||||
const DEFAULT_AVATARS = [
|
||||
'/avatars/original/missing.png', // Mastodon
|
||||
'/images/avi.png', // Pleroma
|
||||
];
|
||||
|
||||
/** Check if the avatar is a default avatar */
|
||||
const isDefaultAvatar = (url: string) => {
|
||||
return DEFAULT_AVATARS.every(avatar => url.endsWith(avatar));
|
||||
};
|
||||
|
||||
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 isDefault = account ? isDefaultAvatar(account.avatar) : false;
|
||||
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.avatar.title' defaultMessage='Choose a profile picture' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='onboarding.avatar.subtitle' defaultMessage='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='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<FormattedMessage id='onboarding.saving' defaultMessage='Saving...' />
|
||||
) : (
|
||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isDisabled && (
|
||||
<Button block theme='link' type='button' onClick={onNext}>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarSelectionStep;
|
|
@ -0,0 +1,103 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
const BioStep = ({ onNext }: { onNext: () => void }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const account = useOwnAccount();
|
||||
const [value, setValue] = React.useState<string>(account?.source.get('note') || '');
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.note.title' defaultMessage='Write a short bio' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='onboarding.note.subtitle' defaultMessage='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 ? (
|
||||
<FormattedMessage id='onboarding.saving' defaultMessage='Saving...' />
|
||||
) : (
|
||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button block theme='link' type='button' onClick={onNext}>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BioStep;
|
|
@ -0,0 +1,39 @@
|
|||
import * as React from 'react';
|
||||
import { FormattedMessage } from'react-intl';
|
||||
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.finished.title' defaultMessage='Onboarding complete' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='onboarding.finished.message'
|
||||
defaultMessage='We are very excited to welcome you to our community! Tap the button below to get started.'
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
<FormattedMessage id='onboarding.view_feed' defaultMessage='View Feed' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default CompletedStep;
|
|
@ -0,0 +1,155 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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';
|
||||
|
||||
/** Default header filenames from various backends */
|
||||
const DEFAULT_HEADERS = [
|
||||
'/headers/original/missing.png', // Mastodon
|
||||
'/images/banner.png', // Pleroma
|
||||
];
|
||||
|
||||
/** Check if the avatar is a default avatar */
|
||||
const isDefaultHeader = (url: string) => {
|
||||
return DEFAULT_HEADERS.every(header => url.endsWith(header));
|
||||
};
|
||||
|
||||
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 isDefault = account ? isDefaultHeader(account.header) : false;
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const maxPixels = 1920 * 1080;
|
||||
const [rawFile] = event.target.files || [] as any;
|
||||
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.header.title' defaultMessage='Pick a cover image' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='onboarding.header.subtitle' defaultMessage='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='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Next'}
|
||||
</Button>
|
||||
|
||||
{isDisabled && (
|
||||
<Button block theme='link' type='button' onClick={onNext}>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverPhotoSelectionStep;
|
|
@ -0,0 +1,105 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const account = useOwnAccount();
|
||||
const [value, setValue] = React.useState<string>(account?.display_name || '');
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.display_name.title' defaultMessage='Choose a display name' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='onboarding.display_name.subtitle' defaultMessage='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}>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplayNameStep;
|
|
@ -0,0 +1,99 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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());
|
||||
}, []);
|
||||
|
||||
const renderSuggestions = () => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmpty = () => {
|
||||
return (
|
||||
<div className='bg-primary-50 dark:bg-slate-700 my-2 rounded-lg text-center p-8'>
|
||||
<Text>
|
||||
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (suggestionsToRender.isEmpty()) {
|
||||
return renderEmpty();
|
||||
} else {
|
||||
return renderSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
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'>
|
||||
<FormattedMessage id='onboarding.suggestions.title' defaultMessage='Suggested accounts' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='onboarding.suggestions.subtitle' defaultMessage='Here are a few of the most popular accounts you might like.' />
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{renderBody()}
|
||||
|
||||
<div className='sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Stack>
|
||||
|
||||
|
||||
<Stack justifyContent='center' space={2}>
|
||||
<Button
|
||||
block
|
||||
theme='primary'
|
||||
onClick={onNext}
|
||||
>
|
||||
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
|
||||
</Button>
|
||||
|
||||
<Button block theme='link' type='button' onClick={onNext}>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestedAccountsStep;
|
|
@ -1,6 +1,7 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
|
||||
|
@ -8,6 +9,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
|
|||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { createAccount } from 'soapbox/actions/verification';
|
||||
import { removeStoredVerification } from 'soapbox/actions/verification';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import { Button, Form, FormGroup, Input } from '../../components/ui';
|
||||
|
||||
|
@ -20,11 +22,11 @@ const Registration = () => {
|
|||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const isLoading = useSelector((state) => state.verification.get('isLoading'));
|
||||
const siteTitle = useSelector((state) => state.instance.title);
|
||||
const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
|
||||
const siteTitle = useAppSelector((state) => state.instance.title);
|
||||
|
||||
const [state, setState] = React.useState(initialState);
|
||||
const [shouldRedirect, setShouldRedirect] = React.useState(false);
|
||||
const [shouldRedirect, setShouldRedirect] = React.useState<boolean>(false);
|
||||
const { username, password } = state;
|
||||
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
|
@ -33,7 +35,7 @@ const Registration = () => {
|
|||
// TODO: handle validation errors from Pepe
|
||||
dispatch(createAccount(username, password))
|
||||
.then(() => dispatch(logIn(intl, username, password)))
|
||||
.then(({ access_token }) => dispatch(verifyCredentials(access_token)))
|
||||
.then(({ access_token }: any) => dispatch(verifyCredentials(access_token)))
|
||||
.then(() => dispatch(fetchInstance()))
|
||||
.then(() => {
|
||||
setShouldRedirect(true);
|
||||
|
@ -47,7 +49,7 @@ const Registration = () => {
|
|||
),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: AxiosError) => {
|
||||
if (error?.response?.status === 422) {
|
||||
dispatch(
|
||||
snackbar.error(
|
|
@ -63,6 +63,23 @@ const customRender = (
|
|||
...options,
|
||||
});
|
||||
|
||||
const mockWindowProperty = (property: any, value: any) => {
|
||||
const { [property]: originalProperty } = window;
|
||||
delete window[property];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, property, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
window[property] = originalProperty;
|
||||
});
|
||||
};
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export {
|
||||
customRender as render,
|
||||
|
@ -70,5 +87,6 @@ export {
|
|||
applyActions,
|
||||
rootState,
|
||||
rootReducer,
|
||||
mockWindowProperty,
|
||||
createTestStore,
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue