Onboarding flow modal Closes #1692 See merge request soapbox-pub/soapbox!3091environments/review-main-yi2y9f/deployments/4765
commit
b61f2e40ed
@ -1,119 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { patchMe } from 'soapbox/actions/me';
|
|
||||||
import { BigCard } from 'soapbox/components/big-card';
|
|
||||||
import { Avatar, Button, Icon, Spinner, Stack } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
import { isDefaultAvatar } from 'soapbox/utils/accounts';
|
|
||||||
import resizeImage from 'soapbox/utils/resize-image';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
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?.item(0);
|
|
||||||
|
|
||||||
if (!rawFile) return;
|
|
||||||
|
|
||||||
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) {
|
|
||||||
toast.error((error.response.data as any).error.replace('Validation failed: ', ''));
|
|
||||||
} else {
|
|
||||||
toast.error(messages.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BigCard
|
|
||||||
title={<FormattedMessage id='onboarding.avatar.title' defaultMessage='Choose a profile picture' />}
|
|
||||||
subtitle={<FormattedMessage id='onboarding.avatar.subtitle' defaultMessage='Just have fun with it.' />}
|
|
||||||
>
|
|
||||||
<Stack space={10}>
|
|
||||||
<div className='relative mx-auto rounded-full bg-gray-200'>
|
|
||||||
{account && (
|
|
||||||
<Avatar src={selectedFile || account.avatar} size={175} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSubmitting && (
|
|
||||||
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-white/80 dark:bg-primary-900/80'>
|
|
||||||
<Spinner withText={false} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={openFilePicker}
|
|
||||||
type='button'
|
|
||||||
className={clsx({
|
|
||||||
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-full ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
|
|
||||||
'opacity-50 pointer-events-none': isSubmitting,
|
|
||||||
})}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<Icon src={require('@tabler/icons/outline/plus.svg')} className='h-5 w-5 text-white' />
|
|
||||||
</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='tertiary' type='button' onClick={onNext}>
|
|
||||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</BigCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AvatarSelectionStep;
|
|
@ -1,93 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { patchMe } from 'soapbox/actions/me';
|
|
||||||
import { BigCard } from 'soapbox/components/big-card';
|
|
||||||
import { Button, FormGroup, Stack, Textarea } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
|
|
||||||
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const BioStep = ({ onNext }: { onNext: () => void }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { account } = useOwnAccount();
|
|
||||||
const [value, setValue] = React.useState<string>(account?.source?.note ?? '');
|
|
||||||
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
|
|
||||||
const [errors, setErrors] = React.useState<string[]>([]);
|
|
||||||
|
|
||||||
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 as any).error.replace('Validation failed: ', '')]);
|
|
||||||
} else {
|
|
||||||
toast.error(messages.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BigCard
|
|
||||||
title={<FormattedMessage id='onboarding.note.title' defaultMessage='Write a short bio' />}
|
|
||||||
subtitle={<FormattedMessage id='onboarding.note.subtitle' defaultMessage='You can always edit this later.' />}
|
|
||||||
>
|
|
||||||
<Stack space={5}>
|
|
||||||
<div>
|
|
||||||
<FormGroup
|
|
||||||
hintText={<FormattedMessage id='onboarding.bio.hint' defaultMessage='Max 500 characters' />}
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
|
|
||||||
errors={errors}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
onChange={(event) => setValue(event.target.value)}
|
|
||||||
placeholder={intl.formatMessage(messages.bioPlaceholder)}
|
|
||||||
value={value}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Stack justifyContent='center' space={2}>
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
theme='primary'
|
|
||||||
type='submit'
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button block theme='tertiary' type='button' onClick={onNext}>
|
|
||||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</BigCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BioStep;
|
|
@ -1,39 +0,0 @@
|
|||||||
import 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/outline/confetti.svg')} className='mx-auto h-16 w-16 text-primary-600 dark:text-primary-400' />
|
|
||||||
|
|
||||||
<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='mx-auto pt-10 sm:w-2/3 md:w-1/2'>
|
|
||||||
<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;
|
|
@ -1,101 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { patchMe } from 'soapbox/actions/me';
|
|
||||||
import { BigCard } from 'soapbox/components/big-card';
|
|
||||||
import { Button, FormGroup, Input, Stack } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
usernamePlaceholder: { id: 'onboarding.display_name.placeholder', defaultMessage: 'Eg. John Smith' },
|
|
||||||
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
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 as any).error.replace('Validation failed: ', '')]);
|
|
||||||
} else {
|
|
||||||
toast.error(messages.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BigCard
|
|
||||||
title={<FormattedMessage id='onboarding.display_name.title' defaultMessage='Choose a display name' />}
|
|
||||||
subtitle={<FormattedMessage id='onboarding.display_name.subtitle' defaultMessage='You can always edit this later.' />}
|
|
||||||
>
|
|
||||||
<Stack space={5}>
|
|
||||||
<FormGroup
|
|
||||||
hintText={hintText}
|
|
||||||
labelText={<FormattedMessage id='onboarding.display_name.label' defaultMessage='Display name' />}
|
|
||||||
errors={errors}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
onChange={(event) => setValue(event.target.value)}
|
|
||||||
placeholder={intl.formatMessage(messages.usernamePlaceholder)}
|
|
||||||
type='text'
|
|
||||||
value={value}
|
|
||||||
maxLength={30}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<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='tertiary' type='button' onClick={onNext}>
|
|
||||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</BigCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DisplayNameStep;
|
|
@ -1,88 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Account from 'soapbox/components/account';
|
|
||||||
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import { useInstance, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
const FediverseStep = ({ onNext }: { onNext: () => void }) => {
|
|
||||||
const { account } = useOwnAccount();
|
|
||||||
const instance = useInstance();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card variant='rounded' size='xl'>
|
|
||||||
<CardBody>
|
|
||||||
<Stack space={2}>
|
|
||||||
<Icon strokeWidth={1} src={require('@tabler/icons/outline/affiliate.svg')} className='mx-auto h-16 w-16 text-primary-600 dark:text-primary-400' />
|
|
||||||
|
|
||||||
<Text size='2xl' weight='bold'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='onboarding.fediverse.title'
|
|
||||||
defaultMessage='{siteTitle} is just one part of the Fediverse'
|
|
||||||
values={{
|
|
||||||
siteTitle: instance.title,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Stack space={4}>
|
|
||||||
<div className='border-b border-solid border-gray-200 pb-2 sm:pb-5 dark:border-gray-800'>
|
|
||||||
<Stack space={4}>
|
|
||||||
<Text theme='muted'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='onboarding.fediverse.message'
|
|
||||||
defaultMessage='The Fediverse is a social network made up of thousands of diverse and independently-run social media sites (aka "servers"). You can follow users — and like, repost, and reply to posts — from most other Fediverse servers, because they can communicate with {siteTitle}.'
|
|
||||||
values={{
|
|
||||||
siteTitle: instance.title,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text theme='muted'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='onboarding.fediverse.trailer'
|
|
||||||
defaultMessage='Because it is distributed and anyone can run their own server, the Fediverse is resilient and open. If you choose to join another server or set up your own, you can interact with the same people and continue on the same social graph.'
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{account && (
|
|
||||||
<div className='rounded-lg bg-primary-50 p-4 text-center dark:bg-gray-800'>
|
|
||||||
<Account account={account} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text theme='muted'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='onboarding.fediverse.its_you'
|
|
||||||
defaultMessage='This is you! Other people can follow you from other servers by using your full @-handle.'
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text theme='muted'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='onboarding.fediverse.other_instances'
|
|
||||||
defaultMessage='When browsing your timeline, pay attention to the full username after the second @ symbol to know which server a post is from.'
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<div className='mx-auto pt-10 sm:w-2/3 md:w-1/2'>
|
|
||||||
<Stack justifyContent='center' space={2}>
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
theme='primary'
|
|
||||||
onClick={onNext}
|
|
||||||
>
|
|
||||||
<FormattedMessage id='onboarding.fediverse.next' defaultMessage='Next' />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FediverseStep;
|
|
@ -0,0 +1,134 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { patchMe } from 'soapbox/actions/me';
|
||||||
|
import { Button, Stack, Text, Avatar, Icon, Spinner } from 'soapbox/components/ui';
|
||||||
|
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
|
||||||
|
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
import { isDefaultAvatar } from 'soapbox/utils/accounts';
|
||||||
|
import resizeImage from 'soapbox/utils/resize-image';
|
||||||
|
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const closeIcon = require('@tabler/icons/outline/x.svg');
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
interface IAvatarSelectionModal {
|
||||||
|
onClose?(): void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarSelectionModal: React.FC<IAvatarSelectionModal> = ({ onClose, onNext }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
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?.item(0);
|
||||||
|
|
||||||
|
if (!rawFile) return;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
toast.error((error.response.data as any).error.replace('Validation failed: ', ''));
|
||||||
|
} else {
|
||||||
|
toast.error(messages.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(console.error);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
|
||||||
|
|
||||||
|
<div className='relative w-full'>
|
||||||
|
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
|
||||||
|
<Stack space={2} justifyContent='center' alignItems='center' className='border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
|
||||||
|
<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='relative mx-auto rounded-full bg-gray-200'>
|
||||||
|
{account && (
|
||||||
|
<Avatar src={selectedFile || account.avatar} size={175} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-white/80 dark:bg-primary-900/80'>
|
||||||
|
<Spinner withText={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={openFilePicker}
|
||||||
|
type='button'
|
||||||
|
className={clsx({
|
||||||
|
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-full ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
|
||||||
|
'opacity-50 pointer-events-none': isSubmitting,
|
||||||
|
})}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/outline/plus.svg')} className='h-5 w-5 text-white' />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack justifyContent='center' space={2} className='w-2/3'>
|
||||||
|
<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='tertiary' type='button' onClick={onNext}>
|
||||||
|
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default AvatarSelectionModal;
|
@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { patchMe } from 'soapbox/actions/me';
|
||||||
|
import { Button, Text, FormGroup, Stack, Textarea } from 'soapbox/components/ui';
|
||||||
|
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
|
||||||
|
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
|
||||||
|
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeIcon = require('@tabler/icons/outline/x.svg');
|
||||||
|
|
||||||
|
interface IBioStep {
|
||||||
|
onClose(): void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BioStep: React.FC<IBioStep> = ({ onClose, onNext }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { account } = useOwnAccount();
|
||||||
|
const [value, setValue] = React.useState<string>(account?.source?.note ?? '');
|
||||||
|
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
|
||||||
|
const [errors, setErrors] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
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 as any).error.replace('Validation failed: ', '')]);
|
||||||
|
} else {
|
||||||
|
toast.error(messages.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
|
||||||
|
|
||||||
|
<div className='relative w-full'>
|
||||||
|
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
|
||||||
|
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className='mx-auto w-2/3'>
|
||||||
|
<FormGroup
|
||||||
|
hintText={<FormattedMessage id='onboarding.bio.hint' defaultMessage='Max 500 characters' />}
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
|
||||||
|
errors={errors}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={intl.formatMessage(messages.bioPlaceholder)}
|
||||||
|
value={value}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack justifyContent='center' space={2} className='w-2/3'>
|
||||||
|
<Button block theme='primary' type='button' onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button block theme='tertiary' type='button' onClick={onNext}>
|
||||||
|
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BioStep;
|
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Button, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
|
||||||
|
|
||||||
|
const closeIcon = require('@tabler/icons/outline/x.svg');
|
||||||
|
|
||||||
|
interface ICompletedModal {
|
||||||
|
onClose?(): void;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompletedModal: React.FC<ICompletedModal> = ({ onClose, onComplete }) => {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
|
||||||
|
|
||||||
|
<div className='relative w-full'>
|
||||||
|
<IconButton src={closeIcon} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' onClick={onClose} />
|
||||||
|
<Stack space={2} justifyContent='center' alignItems='center' className=''>
|
||||||
|
<Icon strokeWidth={1} src={require('@tabler/icons/outline/confetti.svg')} className='mx-auto h-16 w-16 text-primary-600 dark:text-primary-400' />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Stack justifyContent='center' alignItems='center' className='w-full'>
|
||||||
|
<div className='w-2/3' />
|
||||||
|
|
||||||
|
<Stack justifyContent='center' space={2} className='w-2/3'>
|
||||||
|
<Button block theme='primary' onClick={onComplete}>
|
||||||
|
<FormattedMessage id='onboarding.view_feed' defaultMessage='View Feed' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default CompletedModal;
|
@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { patchMe } from 'soapbox/actions/me';
|
||||||
|
import { Button, Stack, Text, FormGroup, Input } from 'soapbox/components/ui';
|
||||||
|
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
|
||||||
|
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const closeIcon = require('@tabler/icons/outline/x.svg');
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
usernamePlaceholder: { id: 'onboarding.display_name.placeholder', defaultMessage: 'Eg. John Smith' },
|
||||||
|
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IDisplayNameStep {
|
||||||
|
onClose?(): void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DisplayNameStep: React.FC<IDisplayNameStep> = ({ onClose, onNext }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
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 as any).error.replace('Validation failed: ', '')]);
|
||||||
|
} else {
|
||||||
|
toast.error(messages.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
|
||||||
|
|
||||||
|
<div className='relative w-full'>
|
||||||
|
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
|
||||||
|
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Stack space={5} justifyContent='center' alignItems='center' className='w-full'>
|
||||||
|
<div className='w-2/3'>
|
||||||
|
<FormGroup
|
||||||
|
hintText={hintText}
|
||||||
|
labelText={<FormattedMessage id='onboarding.display_name.label' defaultMessage='Display name' />}
|
||||||
|
errors={errors}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={intl.formatMessage(messages.usernamePlaceholder)}
|
||||||
|
type='text'
|
||||||
|
value={value}
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack justifyContent='center' space={2} className='w-2/3'>
|
||||||
|
<Button block theme='primary' type='button' onClick={handleSubmit} disabled={isDisabled || isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button block theme='tertiary' type='button' onClick={onNext}>
|
||||||
|
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default DisplayNameStep;
|
Loading…
Reference in new issue