mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat: onboarding page (#5277)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user