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 }) => ( {Array.from({ length: count }, (_, index) => { const active = index === activeIndex return ( 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' }, }} /> ) })} ) 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 ( Done tick {SLIDES.map((slide, index) => { const active = index === activeIndex const { Visual } = slide return ( {/* Remounting on activation replays the vignette's entrance instead of it playing off-screen once. */} *': { animation: 'none' }, }, }} > {slide.title} {slide.body} ) })} {asksPermission ? ( // The permission ask gets its own pair of choices: a system prompt // is a decision, not a "Next". <> ) : ( )} ) } export default OnboardingView