feat: onboarding page (#5277)

This commit is contained in:
DarkSky 2023-12-19 13:54:41 +00:00
parent 31b1b2dade
commit 8ea910a2bb
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
12 changed files with 401 additions and 13 deletions

View File

@ -258,6 +258,10 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
return url;
},
};
nextAuthOptions.pages = {
newUser: '/auth/onboarding',
};
return nextAuthOptions;
},
inject: [Config, PrismaService, MailService, SessionService],

View File

@ -68,6 +68,7 @@
"react-router-dom": "^6.16.0",
"react-virtuoso": "^4.6.2",
"rxjs": "^7.8.1",
"swr": "^2.2.4",
"uuid": "^9.0.1"
},
"devDependencies": {

View File

@ -8,6 +8,7 @@ export * from './confirm-change-email';
export * from './count-down-render';
export * from './modal';
export * from './modal-header';
export * from './onboarding-page';
export * from './password-input';
export * from './set-password-page';
export * from './sign-in-page-container';

View File

@ -0,0 +1,141 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const scrollableContainer = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
padding: '0 200px',
'@media': {
'screen and (max-width: 1024px)': {
padding: '80px 36px',
alignItems: 'center',
},
},
});
export const onboardingContainer = style({
maxWidth: '600px',
padding: '160px 0',
'@media': {
'screen and (max-width: 1024px)': {
padding: '40px 0',
width: '100%',
},
},
});
export const content = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: '36px',
minHeight: '450px',
});
export const question = style({
color: 'var(--affine-text-color)',
fontFamily: 'Inter',
fontSize: 'var(--affine-font-h-1)',
fontStyle: 'normal',
fontWeight: 600,
lineHeight: '36px',
});
export const optionsWrapper = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '16px',
// flexShrink: 0,
flexGrow: 1,
});
export const buttonWrapper = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
gap: '24px',
flexShrink: 0,
});
export const checkBox = style({
alignItems: 'center',
fontSize: '24px',
});
globalStyle(`${checkBox} svg`, {
color: 'var(--affine-brand-color)',
flexShrink: 0,
marginRight: '8px',
});
export const label = style({
fontSize: 'var(--affine-font-base)',
fontWeight: 500,
});
export const input = style({
width: '520px',
'@media': {
'screen and (max-width: 768px)': {
width: '100%',
},
},
});
export const button = style({
fontWeight: 600,
fontSize: 'var(--affine-font-base)',
});
export const openAFFiNEButton = style({
alignSelf: 'flex-start',
});
export const rightCornerButton = style({
position: 'absolute',
top: '24px',
right: '24px',
});
export const thankContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '24px',
});
export const thankTitle = style({
fontSize: 'var(--affine-font-title)',
fontWeight: '700',
lineHeight: '44px',
});
export const thankText = style({
fontSize: 'var(--affine-font-h-6)',
height: '300px',
fontWeight: '600',
lineHeight: '26px',
});
export const linkGroup = style({
display: 'flex',
position: 'absolute',
bottom: '24px',
right: '24px',
fontSize: 'var(--affine-font-xs)',
height: '16px',
gap: '6px',
width: '100%',
justifyContent: 'flex-end',
backgroundColor: 'var(--affine-background-color)',
});
export const link = style({
color: 'var(--affine-text-secondary-color)',
selectors: {
'&:visited': {
color: 'var(--affine-text-secondary-color)',
},
},
});

View File

@ -0,0 +1,210 @@
import { fetchWithTraceReport } from '@affine/graphql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { Button } from '../../ui/button';
import { Checkbox } from '../../ui/checkbox';
import { Divider } from '../../ui/divider';
import Input from '../../ui/input';
import { ScrollableContainer } from '../../ui/scrollbar';
import * as styles from './onboarding-page.css';
import type { User } from './type';
type QuestionOption = {
type: 'checkbox' | 'input';
label: string;
value: string;
};
type Question = {
id?: string;
question: string;
options?: QuestionOption[];
};
type QuestionnaireAnswer = {
form: string;
ask: string;
answer: string[];
};
export const ScrollableLayout = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<ScrollableContainer className={styles.scrollableContainer}>
<div className={styles.onboardingContainer}>{children}</div>
<div className={styles.linkGroup}>
<a
className={styles.link}
href="https://affine.pro/terms"
target="_blank"
rel="noreferrer"
>
Terms of Conditions
</a>
<Divider orientation="vertical" />
<a
className={styles.link}
href="https://affine.pro/privacy"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
</div>
</ScrollableContainer>
);
};
export const OnboardingPage = ({
user,
onOpenAffine,
}: {
user: User;
onOpenAffine: () => void;
}) => {
const [questionIdx, setQuestionIdx] = useState(0);
const { data: questions } = useSWR<Question[]>(
'/api/worker/questionnaire',
url => fetchWithTraceReport(url).then(r => r.json()),
{ suspense: true, revalidateOnFocus: false }
);
const [options, setOptions] = useState(new Set<string>());
const [inputs, setInputs] = useState<Record<string, string>>({});
const question = useMemo(
() => questions?.[questionIdx],
[questionIdx, questions]
);
if (!questions) {
return null;
}
if (question) {
return (
<ScrollableLayout>
<div className={styles.content}>
<h1 className={styles.question}>{question.question}</h1>
<div className={styles.optionsWrapper}>
{question.options &&
question.options.length > 0 &&
question.options.map((option, optionIndex) => {
if (option.type === 'checkbox') {
return (
<Checkbox
key={optionIndex}
name={option.value}
className={styles.checkBox}
labelClassName={styles.label}
checked={options.has(option.value)}
onChange={e => {
setOptions(set => {
if (e.target.checked) {
set.add(option.value);
} else {
set.delete(option.value);
}
return new Set(set);
});
}}
label={option.label}
/>
);
} else if (option.type === 'input') {
return (
<Input
key={optionIndex}
className={styles.input}
type="text"
size="large"
placeholder={option.label}
value={inputs[option.value] || ''}
onChange={value =>
setInputs(prev => ({ ...prev, [option.value]: value }))
}
/>
);
}
return null;
})}
</div>
<div className={styles.buttonWrapper}>
<Button
className={clsx(styles.button, {
[styles.rightCornerButton]: questionIdx !== 0,
})}
size="extraLarge"
onClick={() => setQuestionIdx(questions.length)}
>
Skip
</Button>
<Button
className={styles.button}
type="primary"
size="extraLarge"
itemType="submit"
onClick={() => {
if (question.id && user?.id) {
const answer: QuestionnaireAnswer = {
form: user.id,
ask: question.id,
answer: [
...Array.from(options),
...Object.entries(inputs).map(
([key, value]) => `${key}:${value}`
),
],
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
fetchWithTraceReport('/api/worker/questionnaire', {
method: 'POST',
body: JSON.stringify(answer),
}).finally(() => {
setOptions(new Set());
setInputs({});
setQuestionIdx(questionIdx + 1);
});
} else {
setQuestionIdx(questionIdx + 1);
}
}}
iconPosition="end"
icon={<ArrowRightSmallIcon />}
>
{questionIdx === 0 ? 'start' : 'Next'}
</Button>
</div>
</div>
</ScrollableLayout>
);
}
return (
<ScrollableLayout>
<div className={styles.thankContainer}>
<h1 className={styles.thankTitle}>Thank you!</h1>
<p className={styles.thankText}>
We will continue to enhance our products based on your feedback. Thank
you once again for your supports.
</p>
<Button
className={clsx(styles.button, styles.openAFFiNEButton)}
type="primary"
size="extraLarge"
onClick={onOpenAffine}
iconPosition="end"
icon={<ArrowRightSmallIcon />}
>
Get Started
</Button>
</div>
</ScrollableLayout>
);
};

View File

@ -17,6 +17,10 @@ export type CheckboxProps = Omit<
disabled?: boolean;
indeterminate?: boolean;
animation?: boolean;
name?: string;
label?: string;
inputClassName?: string;
labelClassName?: string;
};
export const Checkbox = ({
@ -25,6 +29,11 @@ export const Checkbox = ({
indeterminate: indeterminate,
disabled,
animation,
name,
label,
inputClassName,
labelClassName,
className,
...otherProps
}: CheckboxProps) => {
const inputRef = useRef<HTMLInputElement>(null);
@ -56,7 +65,7 @@ export const Checkbox = ({
return (
<div
className={clsx(styles.root, disabled && styles.disabled)}
className={clsx(styles.root, className, disabled && styles.disabled)}
role="checkbox"
{...otherProps}
>
@ -64,12 +73,19 @@ export const Checkbox = ({
<input
ref={inputRef}
data-testid="affine-checkbox"
className={clsx(styles.input)}
className={clsx(styles.input, inputClassName)}
type="checkbox"
value={checked ? 'on' : 'off'}
id={name}
name={name}
checked={checked}
onChange={handleChange}
/>
{label ? (
<label htmlFor={name} className={clsx(labelClassName)}>
{label}
</label>
) : null}
</div>
);
};

View File

@ -10,7 +10,7 @@ const unchecked = (
fillRule="evenodd"
clipRule="evenodd"
d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6Z"
fill="var(--affine-icon-color)"
fill="currentColor"
/>
</svg>
);
@ -44,7 +44,7 @@ const indeterminate = (
fillRule="evenodd"
clipRule="evenodd"
d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM8.54 11.25C8.12579 11.25 7.79 11.5858 7.79 12C7.79 12.4142 8.12579 12.75 8.54 12.75H15.54C15.9542 12.75 16.29 12.4142 16.29 12C16.29 11.5858 15.9542 11.25 15.54 11.25H8.54Z"
fill="var(--affine-icon-color)"
fill="currentColor"
/>
</svg>
);

View File

@ -1,15 +1,17 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
alignItems: 'center',
position: 'relative',
':hover': {
opacity: 0.8,
},
':active': {
opacity: 0.9,
},
});
globalStyle(`${root}:hover svg`, {
opacity: 0.8,
});
globalStyle(`${root}:active svg`, {
opacity: 0.9,
});
export const disabled = style({
@ -23,6 +25,9 @@ export const input = style({
width: '1em',
height: '1em',
inset: 0,
top: '50%',
transform: 'translateY(-50%)',
cursor: 'pointer',
fontSize: 'inherit',
});

View File

@ -35,7 +35,6 @@ globalStyle(`${scrollableViewport} > div`, {
export const scrollableContainer = style({
height: '100%',
marginBottom: '4px',
});
export const scrollbar = style({

View File

@ -389,6 +389,12 @@ export const createConfiguration: (
watch: true,
},
proxy: {
'/api/worker/': {
target: 'https://affine-worker.toeverything.workers.dev',
pathRewrite: { '^/api/worker/': '/api/' },
changeOrigin: true,
secure: false,
},
'/api': 'http://localhost:3010',
'/socket.io': {
target: 'http://localhost:3010',

View File

@ -2,6 +2,7 @@ import {
ChangeEmailPage,
ChangePasswordPage,
ConfirmChangeEmail,
OnboardingPage,
SetPasswordPage,
SignInSuccessPage,
SignUpPage,
@ -31,6 +32,7 @@ import { useCurrentUser } from '../hooks/affine/use-current-user';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
const authTypeSchema = z.enum([
'onboarding',
'setPassword',
'signIn',
'changePassword',
@ -93,6 +95,8 @@ export const AuthPage = (): ReactElement | null => {
}, [jumpToIndex]);
switch (authType) {
case 'onboarding':
return <OnboardingPage user={user} onOpenAffine={onOpenAffine} />;
case 'signUp': {
return (
<SignUpPage

View File

@ -296,6 +296,7 @@ __metadata:
rxjs: "npm:^7.8.1"
storybook: "npm:^7.5.3"
storybook-dark-mode: "npm:^3.0.1"
swr: "npm:^2.2.4"
typescript: "npm:^5.3.2"
uuid: "npm:^9.0.1"
vite: "npm:^5.0.6"
@ -34310,7 +34311,7 @@ __metadata:
languageName: node
linkType: hard
"swr@npm:2.2.4":
"swr@npm:2.2.4, swr@npm:^2.2.4":
version: 2.2.4
resolution: "swr@npm:2.2.4"
dependencies: