diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index c1a7db84e..4c5a8914d 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -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 ; - // } + const { needsOnboarding } = this.props; + + if (needsOnboarding) { + return ( + + + + + {themeCss && } + + + + + + + ); + } const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', { 'no-reduce-motion': !this.props.reduceMotion, diff --git a/app/soapbox/features/onboarding/onboarding-wizard.tsx b/app/soapbox/features/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..7ef3cb804 --- /dev/null +++ b/app/soapbox/features/onboarding/onboarding-wizard.tsx @@ -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(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 = [ + , + , + , + , + , + , + ]; + + 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 ( +
+
+ +
+
+ + {steps.map((step, i) => ( +
+
+ {step} +
+
+ ))} +
+ + + {steps.map((_, i) => ( +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx new file mode 100644 index 000000000..3f2fec197 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -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(null); + const [selectedFile, setSelectedFile] = React.useState(); + const [isSubmitting, setSubmitting] = React.useState(false); + const [isDisabled, setDisabled] = React.useState(true); + + const openFilePicker = () => { + fileInput.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + 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 ( + + +
+
+ + + Choose a profile picture + + + + Just have fun with it. + + +
+ +
+ +
+ {account && ( + + )} + + {isSubmitting && ( +
+ +
+ )} + + + + +
+ + + + + {isDisabled && ( + + )} + +
+
+
+
+
+ ); +}; + +export default AvatarSelectionStep; diff --git a/app/soapbox/features/onboarding/steps/bio-step.tsx b/app/soapbox/features/onboarding/steps/bio-step.tsx new file mode 100644 index 000000000..384adc311 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/bio-step.tsx @@ -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(''); + const [isSubmitting, setSubmitting] = React.useState(false); + const [errors, setErrors] = React.useState([]); + + 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 ( + + +
+
+ + + Write a short bio + + + + You can always edit this later. + + +
+ + +
+ +