Add Onboarding components
This commit is contained in:
parent
c8c715ee4b
commit
98c77006ce
11 changed files with 711 additions and 10 deletions
|
@ -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,
|
||||
|
|
111
app/soapbox/features/onboarding/onboarding-wizard.tsx
Normal file
111
app/soapbox/features/onboarding/onboarding-wizard.tsx
Normal 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;
|
118
app/soapbox/features/onboarding/steps/avatar-selection-step.tsx
Normal file
118
app/soapbox/features/onboarding/steps/avatar-selection-step.tsx
Normal 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;
|
94
app/soapbox/features/onboarding/steps/bio-step.tsx
Normal file
94
app/soapbox/features/onboarding/steps/bio-step.tsx
Normal 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;
|
37
app/soapbox/features/onboarding/steps/completed-step.tsx
Normal file
37
app/soapbox/features/onboarding/steps/completed-step.tsx
Normal 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;
|
|
@ -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;
|
100
app/soapbox/features/onboarding/steps/display-name-step.tsx
Normal file
100
app/soapbox/features/onboarding/steps/display-name-step.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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({
|
||||
|
|
|
@ -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 a new issue