Files
donetick/src/views/Onboarding/OnboardingView.jsx
Mohamad Tarbin d641710a41 Reshape onboarding v1.3 (#185)
* Initialize Onboarding Flow

* Shorten Onboarding

* Add Setup circle  and what are the current way to add tasks to the onboarding.
update donetick logo

* improve wording

* Skip onboarding flow for now

* Enable onboarding flow
2026-08-02 20:53:28 -04:00

396 lines
12 KiB
JavaScript

import {
ArrowForwardRounded,
NotificationsActiveRounded,
} from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import {
haptic,
markOnboardingSeen,
requestNotificationPermission,
} from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
import {
CaptureVignette,
ProblemVignette,
RemindersVignette,
ScheduleVignette,
TakesTurnsVignette,
} from './OnboardingVignettes'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const SWIPE_THRESHOLD = 56
/**
* Five messages, in the order that sells: the problem first, then the two
* things Donetick does better than a reminders app, then the reasons to come
* back. Every title is an outcome — nobody buys "reschedule from completion
* date", they buy never arguing about trash day again.
*/
const SLIDES = [
{
key: 'problem',
title: 'Stop forgetting the little things',
body: "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
Visual: ProblemVignette,
},
{
key: 'capture',
title: 'Add it before you forget it',
body: "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
Visual: CaptureVignette,
},
// {
// key: 'schedule',
// title: 'It comes back exactly when it should',
// body: "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
// Visual: ScheduleVignette,
// },
{
key: 'circle',
title: 'Everyone gets a turn, No more arguing',
body: 'Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.',
Visual: TakesTurnsVignette,
},
{
key: 'reminders',
title: 'A nudge right when it matters & Widgets!',
body: "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too.",
Visual: RemindersVignette,
permission: true,
},
]
const Dots = ({ count, activeIndex, onSelect }) => (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
{Array.from({ length: count }, (_, index) => {
const active = index === activeIndex
return (
<Box
key={index}
component='button'
type='button'
aria-label={`Go to step ${index + 1}`}
aria-current={active ? 'step' : undefined}
onClick={() => onSelect(index)}
sx={{
border: 'none',
p: 0,
cursor: 'pointer',
height: 6,
width: active ? 22 : 6,
borderRadius: '999px',
bgcolor: active ? 'primary.500' : 'neutral.softBg',
transition: `width 280ms ${EASE}, background-color 280ms ease`,
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.500',
outlineOffset: '3px',
},
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
/>
)
})}
</Box>
)
const OnboardingView = () => {
const navigate = useNavigate()
const [activeIndex, setActiveIndex] = useState(0)
const [drag, setDrag] = useState(0)
const [asking, setAsking] = useState(false)
const pointerRef = useRef(null)
const viewportRef = useRef(null)
const isLast = activeIndex === SLIDES.length - 1
const asksPermission = Boolean(SLIDES[activeIndex].permission)
const finish = useCallback(() => {
markOnboardingSeen()
navigate('/get-started', { replace: true })
}, [navigate])
const goTo = useCallback(index => {
setActiveIndex(current => {
const next = Math.min(Math.max(index, 0), SLIDES.length - 1)
if (next !== current) haptic()
return next
})
}, [])
const enableNotifications = async () => {
setAsking(true)
try {
await requestNotificationPermission()
} finally {
setAsking(false)
}
haptic('medium')
finish()
}
const handleNext = () => {
if (isLast) {
haptic('medium')
finish()
return
}
goTo(activeIndex + 1)
}
// Arrow keys for keyboard/desktop parity with swiping.
useEffect(() => {
const onKeyDown = event => {
if (event.key === 'ArrowRight') goTo(activeIndex + 1)
if (event.key === 'ArrowLeft') goTo(activeIndex - 1)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [activeIndex, goTo])
const onPointerDown = event => {
if (event.pointerType === 'mouse' && event.button !== 0) return
pointerRef.current = { id: event.pointerId, startX: event.clientX }
}
const onPointerMove = event => {
const pointer = pointerRef.current
if (!pointer || pointer.id !== event.pointerId) return
const delta = event.clientX - pointer.startX
const atEdge = (activeIndex === 0 && delta > 0) || (isLast && delta < 0)
// Rubber-band past the first and last slide instead of hard-stopping.
setDrag(atEdge ? delta * 0.35 : delta)
}
const endDrag = event => {
const pointer = pointerRef.current
if (!pointer || pointer.id !== event.pointerId) return
const delta = event.clientX - pointer.startX
pointerRef.current = null
setDrag(0)
if (delta <= -SWIPE_THRESHOLD) goTo(activeIndex + 1)
else if (delta >= SWIPE_THRESHOLD) goTo(activeIndex - 1)
}
const width = viewportRef.current?.offsetWidth || 1
const dragPercent = (drag / width) * 100
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.body',
overflow: 'hidden',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Logo size='26px' />
<Typography
level='title-md'
sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}
>
Done
<Box component='span' sx={{ color: 'primary.500' }}>
tick
</Box>
</Typography>
</Box>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={finish}
sx={{ fontWeight: 600, borderRadius: '999px' }}
>
Skip
</Button>
</Box>
<Box
ref={viewportRef}
role='group'
aria-roledescription='carousel'
aria-label='What you can do with Donetick'
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
overflow: 'hidden',
touchAction: 'pan-y',
userSelect: 'none',
}}
>
<Box
sx={{
flex: 1,
display: 'flex',
// Without an explicit min-width the 300%-wide track would size to
// its content and push the slides past the viewport.
minWidth: 0,
minHeight: 0,
transform: `translate3d(calc(${-activeIndex * 100}% + ${dragPercent}%), 0, 0)`,
transition: pointerRef.current ? 'none' : `transform 360ms ${EASE}`,
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
{SLIDES.map((slide, index) => {
const active = index === activeIndex
const { Visual } = slide
return (
<Box
key={slide.key}
role='group'
aria-roledescription='slide'
aria-label={`${index + 1} of ${SLIDES.length}`}
aria-hidden={!active}
sx={{
flex: '0 0 100%',
minWidth: 0,
display: 'flex',
flexDirection: 'column',
// Visual and copy travel together as one centred group;
// pinning the copy to the bottom leaves them disconnected.
justifyContent: 'center',
gap: 4,
px: 3,
py: 2,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Remounting on activation replays the vignette's
entrance instead of it playing off-screen once. */}
<Visual key={active ? 'active' : 'idle'} />
</Box>
<Box
key={active ? 'copy-active' : 'copy-idle'}
sx={{
textAlign: 'center',
'@media (prefers-reduced-motion: reduce)': {
'& > *': { animation: 'none' },
},
}}
>
<Typography
level='h2'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
animation: `slideCopyIn 460ms ${EASE} 60ms both`,
'@keyframes slideCopyIn': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'none' },
},
}}
>
{slide.title}
</Typography>
<Typography
level='body-md'
sx={{
mt: 1,
mx: 'auto',
maxWidth: '34ch',
color: 'text.secondary',
textWrap: 'pretty',
animation: `slideCopyIn 460ms ${EASE} 140ms both`,
}}
>
{slide.body}
</Typography>
</Box>
</Box>
)
})}
</Box>
</Box>
<Box
sx={{
px: 3,
pt: 1,
pb: 3,
display: 'flex',
flexDirection: 'column',
gap: 2.5,
}}
>
<Dots count={SLIDES.length} activeIndex={activeIndex} onSelect={goTo} />
{asksPermission ? (
// The permission ask gets its own pair of choices: a system prompt
// is a decision, not a "Next".
<>
<Button
size='lg'
fullWidth
loading={asking}
onClick={enableNotifications}
startDecorator={<NotificationsActiveRounded />}
sx={authButtonSx}
>
Turn on reminders
</Button>
<Button
variant='plain'
color='neutral'
size='lg'
fullWidth
disabled={asking}
// Deliberately not recorded as an opt-out: the in-app prompt can
// still ask once the user has tasks that would benefit from it.
onClick={finish}
sx={{ ...authButtonSx, mt: -1.5 }}
>
Not now
</Button>
</>
) : (
<Button
size='lg'
fullWidth
onClick={handleNext}
endDecorator={!isLast ? <ArrowForwardRounded /> : null}
sx={authButtonSx}
>
{isLast ? 'Get started' : 'Next'}
</Button>
)}
</Box>
</Box>
)
}
export default OnboardingView