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({
|
export const defaultSettings = ImmutableMap({
|
||||||
onboarded: false,
|
onboardingVersion: 0,
|
||||||
skinTone: 1,
|
skinTone: 1,
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
underlineLinks: false,
|
underlineLinks: false,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import classNames from 'classnames';
|
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 ButtonSizes = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
type IButtonStyles = {
|
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',
|
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',
|
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',
|
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 = {
|
const sizes = {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
|
|
||||||
import SvgIcon from './svg-icon';
|
import SvgIcon from './svg-icon';
|
||||||
|
|
||||||
interface IIcon {
|
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||||
className?: string,
|
className?: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
alt?: string,
|
alt?: string,
|
||||||
|
|
|
@ -11,7 +11,7 @@ const messages = defineMessages({
|
||||||
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
|
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,
|
autoFocus?: boolean,
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
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 = {
|
const spaces = {
|
||||||
'0.5': 'space-y-0.5',
|
'0.5': 'space-y-0.5',
|
||||||
|
@ -11,6 +11,7 @@ const spaces = {
|
||||||
3: 'space-y-3',
|
3: 'space-y-3',
|
||||||
4: 'space-y-4',
|
4: 'space-y-4',
|
||||||
5: 'space-y-5',
|
5: 'space-y-5',
|
||||||
|
10: 'space-y-10',
|
||||||
};
|
};
|
||||||
|
|
||||||
const justifyContentOptions = {
|
const justifyContentOptions = {
|
||||||
|
|
|
@ -6,6 +6,7 @@ type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
|
||||||
type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||||
type Alignments = 'left' | 'center' | 'right'
|
type Alignments = 'left' | 'center' | 'right'
|
||||||
type TrackingSizes = 'normal' | 'wide'
|
type TrackingSizes = 'normal' | 'wide'
|
||||||
|
type TransformProperties = 'uppercase' | 'normal'
|
||||||
type Families = 'sans' | 'mono'
|
type Families = 'sans' | 'mono'
|
||||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
|
||||||
|
@ -48,6 +49,11 @@ const trackingSizes = {
|
||||||
wide: 'tracking-wide',
|
wide: 'tracking-wide',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const transformProperties = {
|
||||||
|
normal: 'normal-case',
|
||||||
|
uppercase: 'uppercase',
|
||||||
|
};
|
||||||
|
|
||||||
const families = {
|
const families = {
|
||||||
sans: 'font-sans',
|
sans: 'font-sans',
|
||||||
mono: 'font-mono',
|
mono: 'font-mono',
|
||||||
|
@ -62,6 +68,7 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
||||||
tag?: Tags,
|
tag?: Tags,
|
||||||
theme?: Themes,
|
theme?: Themes,
|
||||||
tracking?: TrackingSizes,
|
tracking?: TrackingSizes,
|
||||||
|
transform?: TransformProperties,
|
||||||
truncate?: boolean,
|
truncate?: boolean,
|
||||||
weight?: Weights
|
weight?: Weights
|
||||||
}
|
}
|
||||||
|
@ -76,6 +83,7 @@ const Text: React.FC<IText> = React.forwardRef(
|
||||||
tag = 'p',
|
tag = 'p',
|
||||||
theme = 'default',
|
theme = 'default',
|
||||||
tracking = 'normal',
|
tracking = 'normal',
|
||||||
|
transform = 'normal',
|
||||||
truncate = false,
|
truncate = false,
|
||||||
weight = 'normal',
|
weight = 'normal',
|
||||||
...filteredProps
|
...filteredProps
|
||||||
|
@ -99,6 +107,7 @@ const Text: React.FC<IText> = React.forwardRef(
|
||||||
[trackingSizes[tracking]]: true,
|
[trackingSizes[tracking]]: true,
|
||||||
[families[family]]: true,
|
[families[family]]: true,
|
||||||
[alignmentClass]: typeof align !== 'undefined',
|
[alignmentClass]: typeof align !== 'undefined',
|
||||||
|
[transformProperties[transform]]: typeof transform !== 'undefined',
|
||||||
}, className)}
|
}, className)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface ITextarea {
|
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required'> {
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
isCodeEditor?: boolean,
|
isCodeEditor?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
value?: string,
|
value?: string,
|
||||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Textarea = React.forwardRef(
|
const Textarea = React.forwardRef(
|
||||||
|
|
|
@ -19,7 +19,9 @@ import { FE_SUBDIRECTORY } from 'soapbox/build_config';
|
||||||
import { NODE_ENV } from 'soapbox/build_config';
|
import { NODE_ENV } from 'soapbox/build_config';
|
||||||
import Helmet from 'soapbox/components/helmet';
|
import Helmet from 'soapbox/components/helmet';
|
||||||
import AuthLayout from 'soapbox/features/auth_layout';
|
import AuthLayout from 'soapbox/features/auth_layout';
|
||||||
|
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
|
||||||
import PublicLayout from 'soapbox/features/public_layout';
|
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 WaitlistPage from 'soapbox/features/verification/waitlist_page';
|
||||||
import { createGlobals } from 'soapbox/globals';
|
import { createGlobals } from 'soapbox/globals';
|
||||||
import messages from 'soapbox/locales/messages';
|
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 SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||||
|
|
||||||
import { INTRODUCTION_VERSION } from '../actions/onboarding';
|
import { ONBOARDING_VERSION } from '../actions/onboarding';
|
||||||
import { preload } from '../actions/preload';
|
import { preload } from '../actions/preload';
|
||||||
import ErrorBoundary from '../components/error_boundary';
|
import ErrorBoundary from '../components/error_boundary';
|
||||||
// import Introduction from '../features/introduction';
|
|
||||||
import UI from '../features/ui';
|
import UI from '../features/ui';
|
||||||
import { store } from '../store';
|
import { store } from '../store';
|
||||||
|
|
||||||
|
@ -71,15 +72,14 @@ const makeAccount = makeGetAccount();
|
||||||
const mapStateToProps = (state) => {
|
const mapStateToProps = (state) => {
|
||||||
const me = state.get('me');
|
const me = state.get('me');
|
||||||
const account = makeAccount(state, me);
|
const account = makeAccount(state, me);
|
||||||
const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false;
|
|
||||||
const settings = getSettings(state);
|
const settings = getSettings(state);
|
||||||
|
const needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION;
|
||||||
const soapboxConfig = getSoapboxConfig(state);
|
const soapboxConfig = getSoapboxConfig(state);
|
||||||
const locale = settings.get('locale');
|
const locale = settings.get('locale');
|
||||||
|
|
||||||
const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile');
|
const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showIntroduction,
|
|
||||||
me,
|
me,
|
||||||
account,
|
account,
|
||||||
reduceMotion: settings.get('reduceMotion'),
|
reduceMotion: settings.get('reduceMotion'),
|
||||||
|
@ -93,6 +93,7 @@ const mapStateToProps = (state) => {
|
||||||
appleAppId: soapboxConfig.get('appleAppId'),
|
appleAppId: soapboxConfig.get('appleAppId'),
|
||||||
themeMode: settings.get('themeMode'),
|
themeMode: settings.get('themeMode'),
|
||||||
singleUserMode,
|
singleUserMode,
|
||||||
|
needsOnboarding,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -100,12 +101,12 @@ const mapStateToProps = (state) => {
|
||||||
class SoapboxMount extends React.PureComponent {
|
class SoapboxMount extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
showIntroduction: PropTypes.bool,
|
|
||||||
me: SoapboxPropTypes.me,
|
me: SoapboxPropTypes.me,
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
reduceMotion: PropTypes.bool,
|
reduceMotion: PropTypes.bool,
|
||||||
underlineLinks: PropTypes.bool,
|
underlineLinks: PropTypes.bool,
|
||||||
systemFont: PropTypes.bool,
|
systemFont: PropTypes.bool,
|
||||||
|
needsOnboarding: PropTypes.bool,
|
||||||
dyslexicFont: PropTypes.bool,
|
dyslexicFont: PropTypes.bool,
|
||||||
demetricator: PropTypes.bool,
|
demetricator: PropTypes.bool,
|
||||||
locale: PropTypes.string.isRequired,
|
locale: PropTypes.string.isRequired,
|
||||||
|
@ -154,7 +155,7 @@ class SoapboxMount extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 === null) return null;
|
||||||
if (me && !account) return null;
|
if (me && !account) return null;
|
||||||
if (!this.state.isLoaded) 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);
|
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', {
|
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', {
|
||||||
'no-reduce-motion': !this.props.reduceMotion,
|
'no-reduce-motion': !this.props.reduceMotion,
|
||||||
'underline-links': this.props.underlineLinks,
|
'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 * as React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
|
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
|
||||||
|
@ -8,6 +9,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { createAccount } from 'soapbox/actions/verification';
|
import { createAccount } from 'soapbox/actions/verification';
|
||||||
import { removeStoredVerification } from 'soapbox/actions/verification';
|
import { removeStoredVerification } from 'soapbox/actions/verification';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { Button, Form, FormGroup, Input } from '../../components/ui';
|
import { Button, Form, FormGroup, Input } from '../../components/ui';
|
||||||
|
|
||||||
|
@ -20,11 +22,11 @@ const Registration = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const isLoading = useSelector((state) => state.verification.get('isLoading'));
|
const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
|
||||||
const siteTitle = useSelector((state) => state.instance.title);
|
const siteTitle = useAppSelector((state) => state.instance.title);
|
||||||
|
|
||||||
const [state, setState] = React.useState(initialState);
|
const [state, setState] = React.useState(initialState);
|
||||||
const [shouldRedirect, setShouldRedirect] = React.useState(false);
|
const [shouldRedirect, setShouldRedirect] = React.useState<boolean>(false);
|
||||||
const { username, password } = state;
|
const { username, password } = state;
|
||||||
|
|
||||||
const handleSubmit = React.useCallback((event) => {
|
const handleSubmit = React.useCallback((event) => {
|
||||||
|
@ -33,7 +35,7 @@ const Registration = () => {
|
||||||
// TODO: handle validation errors from Pepe
|
// TODO: handle validation errors from Pepe
|
||||||
dispatch(createAccount(username, password))
|
dispatch(createAccount(username, password))
|
||||||
.then(() => dispatch(logIn(intl, 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(() => dispatch(fetchInstance()))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setShouldRedirect(true);
|
setShouldRedirect(true);
|
||||||
|
@ -47,7 +49,7 @@ const Registration = () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error: AxiosError) => {
|
||||||
if (error?.response?.status === 422) {
|
if (error?.response?.status === 422) {
|
||||||
dispatch(
|
dispatch(
|
||||||
snackbar.error(
|
snackbar.error(
|
|
@ -63,6 +63,23 @@ const customRender = (
|
||||||
...options,
|
...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 * from '@testing-library/react';
|
||||||
export {
|
export {
|
||||||
customRender as render,
|
customRender as render,
|
||||||
|
@ -70,5 +87,6 @@ export {
|
||||||
applyActions,
|
applyActions,
|
||||||
rootState,
|
rootState,
|
||||||
rootReducer,
|
rootReducer,
|
||||||
|
mockWindowProperty,
|
||||||
createTestStore,
|
createTestStore,
|
||||||
};
|
};
|
||||||
|
|
|
@ -79,6 +79,7 @@
|
||||||
"@types/react-helmet": "^6.1.5",
|
"@types/react-helmet": "^6.1.5",
|
||||||
"@types/react-motion": "^0.0.32",
|
"@types/react-motion": "^0.0.32",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"@types/react-swipeable-views": "^0.13.1",
|
||||||
"@types/react-toggle": "^4.0.3",
|
"@types/react-toggle": "^4.0.3",
|
||||||
"@types/redux-mock-store": "^1.0.3",
|
"@types/redux-mock-store": "^1.0.3",
|
||||||
"@types/semver": "^7.3.9",
|
"@types/semver": "^7.3.9",
|
||||||
|
|
|
@ -2229,6 +2229,13 @@
|
||||||
"@types/history" "^4.7.11"
|
"@types/history" "^4.7.11"
|
||||||
"@types/react" "*"
|
"@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":
|
"@types/react-toggle@^4.0.3":
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-toggle/-/react-toggle-4.0.3.tgz#8db98ac8d2c5e8c03c2d3a42027555c1cd2289da"
|
resolved "https://registry.yarnpkg.com/@types/react-toggle/-/react-toggle-4.0.3.tgz#8db98ac8d2c5e8c03c2d3a42027555c1cd2289da"
|
||||||
|
|
Loading…
Reference in New Issue