mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-27 22:53:20 +03:00
feat: onboarding page (#5277)
This commit is contained in:
parent
31b1b2dade
commit
8ea910a2bb
@ -258,6 +258,10 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.pages = {
|
||||
newUser: '/auth/onboarding',
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService, SessionService],
|
||||
|
@ -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": {
|
||||
|
@ -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';
|
||||
|
@ -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)',
|
||||
},
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -35,7 +35,6 @@ globalStyle(`${scrollableViewport} > div`, {
|
||||
|
||||
export const scrollableContainer = style({
|
||||
height: '100%',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user