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
This commit is contained in:
Mohamad Tarbin
2026-08-02 20:53:28 -04:00
committed by GitHub
parent 46fb2d5475
commit d641710a41
17 changed files with 3255 additions and 1193 deletions

View File

@@ -8,6 +8,7 @@ import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { AuthProvider } from './hooks/useAuth.jsx'
import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
import { useResource } from './queries/ResourceQueries'
import './styles/safe-area.css'
@@ -33,6 +34,9 @@ const AppContent = () => {
const { showNotification } = useNotification()
useSyncOnReconnect()
// // First-launch native users see the onboarding flow before anything else.
useOnboardingGate()
// Initialize status bar with theme-aware configuration
useStatusBar()

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -85,6 +85,9 @@ const PageTransition = ({ children }) => {
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing') ||
location.pathname.includes('/onboarding') ||
location.pathname.includes('/get-started') ||
location.pathname.includes('/ready') ||
location.pathname.includes('/auth/')
// Apply transition type as data attribute for CSS

View File

@@ -26,6 +26,11 @@ import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import CircleSetupView from '../views/Onboarding/CircleSetupView'
import GetStartedView from '../views/Onboarding/GetStartedView'
import HeardAboutView from '../views/Onboarding/HeardAboutView'
import OnboardingView from '../views/Onboarding/OnboardingView'
import WorkspaceReadyView from '../views/Onboarding/WorkspaceReadyView'
import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
@@ -182,6 +187,26 @@ const Router = createBrowserRouter([
path: '/signup',
element: <SignupView />,
},
{
path: '/onboarding',
element: <OnboardingView />,
},
{
path: '/get-started',
element: <GetStartedView />,
},
{
path: '/ready',
element: <WorkspaceReadyView />,
},
{
path: '/circle-setup',
element: <CircleSetupView />,
},
{
path: '/heard-about',
element: <HeardAboutView />,
},
{
path: '/auth/:provider',

View File

@@ -59,7 +59,13 @@ export const AuthProvider = ({ children }) => {
if (!response.ok) {
const res = await response.json()
return { success: false, error: res?.error || 'Login failed' }
// `status` is passed back so callers can tell a rejected password from
// an unreachable or broken server without parsing the message.
return {
success: false,
status: response.status,
error: res?.error || 'Login failed',
}
}
const data = await response.json()

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
// Routes a first-run user may legitimately be on without having gone through
// onboarding: the flow itself, deep-link auth callbacks, and the legal pages
// linked from it.
const ALLOWED_PATHS = [
'/onboarding',
'/get-started',
'/login/settings',
'/privacy',
'/terms',
]
const isAllowed = pathname =>
ALLOWED_PATHS.includes(pathname) || pathname.startsWith('/auth/')
/**
* Sends first-launch native users to the onboarding flow. Runs on every
* navigation because an expired session hard-redirects to /login through
* `window.location`, which remounts the app.
*/
const useOnboardingGate = () => {
const navigate = useNavigate()
const { pathname } = useLocation()
useEffect(() => {
if (!isNativeApp() || hasSeenOnboarding()) return
// A signed-in user upgrading from an older build has nothing to onboard to.
if (localStorage.getItem('token')) return
if (isAllowed(pathname)) return
navigate('/onboarding', { replace: true })
}, [pathname, navigate])
}
export default useOnboardingGate

114
src/utils/Onboarding.js Normal file
View File

@@ -0,0 +1,114 @@
import { Capacitor } from '@capacitor/core'
// First-run onboarding is a native-app-only flow. On the web the user already
// chose to visit a URL, so we drop them straight on the auth screens.
const STORAGE_KEY = 'onboardingCompletedAt'
export const isNativeApp = () => {
try {
return Capacitor.isNativePlatform()
} catch {
return false
}
}
export const hasSeenOnboarding = () => {
try {
return Boolean(localStorage.getItem(STORAGE_KEY))
} catch {
// Private-mode / storage-disabled webviews: never trap the user in a loop.
return true
}
}
export const markOnboardingSeen = () => {
try {
localStorage.setItem(STORAGE_KEY, new Date().toISOString())
} catch {
// ignore, worst case the flow is shown once more
}
}
export const resetOnboarding = () => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// ignore
}
}
const ACQUISITION_SOURCE_KEY = 'acquisitionSource'
/**
* Stashes the "where'd you hear about us" answer locally for now. No
* analytics pipeline is wired up yet — this is the single place that'll
* change once there is one, so the survey screen itself never has to.
*/
export const recordAcquisitionSource = source => {
try {
localStorage.setItem(ACQUISITION_SOURCE_KEY, source)
} catch {
// ignore, this is best-effort telemetry
}
}
const PRIVACY_PREFERENCES_KEY = 'privacyPreferences'
/**
* Stashes the self-hosted privacy opt-ins locally, same stub-for-now
* treatment as recordAcquisitionSource: no crash reporter or PostHog is wired
* up yet, so this is just the one place that'll change once there is one.
*/
export const recordPrivacyPreferences = ({ crashReports, analytics }) => {
try {
localStorage.setItem(
PRIVACY_PREFERENCES_KEY,
JSON.stringify({ crashReports, analytics }),
)
} catch {
// ignore, this is best-effort telemetry
}
}
/**
* Asks the OS for notification permission during onboarding and records the
* answer under the same `notificationPreferences` key NotificationAccessSnackbar
* reads, so a user who says yes here is never asked again after signing in.
*
* Only the permission is requested: registering the push token needs a session,
* and there isn't one yet. The snackbar picks that up once the user is in.
*/
export const requestNotificationPermission = async () => {
if (!isNativeApp()) return false
try {
const { LocalNotifications } = await import(
'@capacitor/local-notifications'
)
const { Preferences } = await import('@capacitor/preferences')
const result = await LocalNotifications.requestPermissions()
const granted = result?.display === 'granted'
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify({ optOut: false, granted }),
})
return granted
} catch {
// Permission plugins are missing or the prompt was dismissed: carry on,
// the in-app snackbar can still ask later.
return false
}
}
export const haptic = async (kind = 'light') => {
if (!isNativeApp()) return
try {
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
await Haptics.impact({
style: kind === 'medium' ? ImpactStyle.Medium : ImpactStyle.Light,
})
} catch {
// no haptics on this platform
}
}

View File

@@ -35,7 +35,11 @@ const SignupView = () => {
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
Navigate('/chores')
// The "how did you hear about us" step (/heard-about) is
// temporarily skipped; new accounts go straight to circle setup.
// Re-enable by navigating to '/heard-about' again — that view
// already forwards to '/circle-setup' when done.
Navigate('/circle-setup', { replace: true })
})
} else {
console.log('Login failed', response)
@@ -130,7 +134,7 @@ const SignupView = () => {
title='Create your account'
subtitle='Track chores and tasks together, in one shared place.'
footer={<LegalLinks />}
logoSize={0}
logoSize={0}
>
<Box
component='form'

View File

@@ -17,9 +17,16 @@ const NotificationAccessSnackbar = () => {
useEffect(() => {
// Only run the effect on native platforms
if (Capacitor.isNativePlatform()) {
getNotificationPreferences().then(data => {
getNotificationPreferences().then(async data => {
// if optOut is true then don't show the snackbar
if (data?.optOut === true || data?.granted === true) {
// Onboarding (and the system settings screen) can grant permission
// while no session exists, so the push token still needs registering.
if (data?.granted === true) {
await registerPushNotifications().catch(error =>
console.error('Error registering push notifications:', error),
)
}
return
}
setOpen(true)
@@ -69,7 +76,7 @@ const NotificationAccessSnackbar = () => {
} catch (error) {
console.error('Error setting up notifications:', error)
}
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),

View File

@@ -0,0 +1,325 @@
import {
ContentCopyRounded,
GroupAddRounded,
LinkRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useNotification } from '../../service/NotificationProvider'
import { GetUserCircle, JoinCircle } from '../../utils/Fetcher'
import { haptic } from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
import { CircleVignette } from './OnboardingVignettes'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `circleSetupIn 520ms ${EASE} ${delay}ms both`,
'@keyframes circleSetupIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
/**
* A halo'd icon standing in for the vignette on the join path — that side has
* no living preview to show (there's nothing to illustrate about someone
* else's circle yet), so it borrows the same glow treatment WorkspaceReadyView
* puts behind its logo instead of inventing a third visual language.
*/
const IconHalo = ({ icon }) => (
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
width: 84,
height: 84,
'&::before': {
content: '""',
position: 'absolute',
width: 140,
height: 140,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(28px)',
},
'& > *': { position: 'relative' },
}}
>
<Box
sx={{
width: 72,
height: 72,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
color: 'primary.plainColor',
'& svg': { fontSize: '2rem' },
}}
>
{icon}
</Box>
</Box>
)
/**
* Right after the account (and its solo Circle) is created: the moment to
* either bring the household in or hop into one that already exists. Skipping
* via Continue is always valid — chores work fine solo, this is an offer, not
* a gate.
*/
const CircleSetupView = () => {
const navigate = useNavigate()
const { showNotification } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [mode, setMode] = useState('invite')
const [inviteCode, setInviteCode] = useState(null)
const [joinCode, setJoinCode] = useState('')
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
GetUserCircle()
.then(resp => resp.json())
.then(data => setInviteCode(data.res?.[0]?.invite_code ?? null))
.catch(() => setInviteCode(null))
}, [])
const finish = () => navigate('/ready', { replace: true })
const copyCode = () => {
navigator.clipboard.writeText(inviteCode)
showNotification({ type: 'success', message: 'Code copied to clipboard' })
}
const copyLink = () => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/circle/join?code=${inviteCode}`,
)
showNotification({ type: 'success', message: 'Link copied to clipboard' })
}
const joinCircle = async () => {
if (!joinCode.trim()) return
setIsJoining(true)
haptic()
try {
const resp = await JoinCircle(joinCode.trim())
if (resp.ok) {
showAcknowledgment(
"Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
'Request Sent',
finish,
)
} else {
showNotification({
type: 'error',
message:
resp.status === 409
? 'You are already a member of this circle'
: 'Failed to join circle',
})
}
} finally {
setIsJoining(false)
}
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ mb: 2, ...enter(0) }}>
{mode === 'invite' ? (
<CircleVignette />
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconHalo icon={<GroupAddRounded />} />
</Box>
)}
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
...enter(60),
}}
>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
{mode === 'invite' ? 'Bring your squad in' : 'Join a circle'}
</Typography>
<Typography
level='body-md'
sx={{
color: 'text.secondary',
maxWidth: '32ch',
textWrap: 'pretty',
}}
>
{mode === 'invite'
? 'Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.'
: "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it."}
</Typography>
</Box>
{mode === 'invite' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box
sx={{
...enter(120),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 2,
py: 1.5,
borderRadius: '16px',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography
sx={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '1.375rem',
fontWeight: 700,
letterSpacing: '0.08em',
color: inviteCode ? 'text.primary' : 'text.tertiary',
}}
>
{inviteCode ?? 'Loading…'}
</Typography>
<IconButton
variant='soft'
color='primary'
disabled={!inviteCode}
onClick={copyCode}
aria-label='Copy circle code'
>
<ContentCopyRounded />
</IconButton>
</Box>
<Box sx={{ textAlign: 'center', ...enter(150) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<LinkRounded fontSize='small' />}
disabled={!inviteCode}
onClick={copyLink}
>
Copy invite link instead
</Link>
</Box>
<Box sx={{ mt: 1, ...enter(190) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
<Box sx={{ textAlign: 'center', ...enter(230) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<GroupAddRounded fontSize='small' />}
onClick={() => setMode('join')}
>
Join an existing circle instead
</Link>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ ...enter(120) }}>
<Input
placeholder='Enter code'
value={joinCode}
onChange={e => setJoinCode(e.target.value)}
size='lg'
fullWidth
autoFocus
/>
</Box>
<Box sx={{ ...enter(160) }}>
<Button
size='lg'
fullWidth
loading={isJoining}
disabled={!joinCode.trim()}
onClick={joinCircle}
sx={authButtonSx}
>
Join Circle
</Button>
</Box>
<Box sx={{ textAlign: 'center', ...enter(200) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={() => setMode('invite')}
>
Back
</Link>
</Box>
</Box>
)}
</Box>
<AcknowledgmentModal config={ackModalConfig} />
</Box>
)
}
export default CircleSetupView

View File

@@ -0,0 +1,175 @@
import { DnsOutlined } from '@mui/icons-material'
import { Box, Button, Link, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { haptic, isNativeApp, markOnboardingSeen } from '../../utils/Onboarding'
import { LegalLinks } from '../Authorization/AuthFields'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `getStartedIn 520ms ${EASE} ${delay}ms both`,
'@keyframes getStartedIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
/**
* The fork at the end of onboarding: create an account or sign in. Kept
* deliberately thin — one decision, no form — so the actual auth screens stay
* the only place credentials are handled.
*/
const GetStartedView = () => {
const navigate = useNavigate()
const { data: resource } = useResource()
const signupDisabled = Boolean(resource?.is_user_creation_disabled)
const go = path => {
// Reaching this screen means onboarding is done, even if the user came
// here from a deep link rather than the carousel.
markOnboardingSeen()
haptic()
navigate(path)
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
...enter(0),
}}
>
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
// Same primary wash the onboarding vignettes sit on.
'&::before': {
content: '""',
position: 'absolute',
width: 190,
height: 190,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(30px)',
},
'& > *': { position: 'relative' },
}}
>
<Logo size='96px' />
</Box>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Done
<Box component='span' sx={{ color: 'primary.500' }}>
tick
</Box>
</Typography>
<Typography
level='body-md'
sx={{
color: 'text.secondary',
maxWidth: '30ch',
textWrap: 'pretty',
}}
>
{signupDisabled
? 'Sign in to your account to pick up where you left off.'
: 'Create an account to sync everywhere, or sign in and pick up where you left off.'}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
...enter(90),
}}
>
{!signupDisabled && (
<Button
size='lg'
fullWidth
onClick={() => go('/signup')}
sx={authButtonSx}
>
Create an account
</Button>
)}
<Button
size='lg'
fullWidth
variant={signupDisabled ? 'solid' : 'outlined'}
color={signupDisabled ? 'primary' : 'neutral'}
onClick={() => go('/login')}
sx={authButtonSx}
>
{signupDisabled ? 'Sign in' : 'I already have an account'}
</Button>
</Box>
{isNativeApp() && (
<Box sx={{ mt: 2.5, textAlign: 'center', ...enter(160) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<DnsOutlined fontSize='small' />}
onClick={() => navigate('/login/settings')}
>
Connect to a self-hosted server
</Link>
</Box>
)}
</Box>
<Box sx={{ width: '100%', maxWidth: 420, ...enter(220) }}>
<LegalLinks />
</Box>
</Box>
)
}
export default GetStartedView

View File

@@ -0,0 +1,343 @@
import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
import {
haptic,
recordAcquisitionSource,
recordPrivacyPreferences,
} from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `heardAboutIn 520ms ${EASE} ${delay}ms both`,
'@keyframes heardAboutIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
const SOURCES = [
'App Store / Google Play',
'Reddit or a forum',
'Friend or family',
'YouTube or TikTok',
'Search engine',
'Something else',
]
const OTHER = 'Something else'
const Shell = ({ children }) => (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{children}
</Box>
</Box>
)
/**
* A one-question attribution survey dropped right after account creation,
* while the "why did I click install" is still fresh. Answering is optional
* blocking a brand-new user on a marketing question would cost more than the
* data is worth so Continue is always enabled. Shown only on the official
* donetick.com instance: a self-hosted server has no marketing funnel to
* attribute, so it gets the privacy prompt below instead.
*/
const AcquisitionSurvey = ({ onDone }) => {
const [selected, setSelected] = useState(null)
const [detail, setDetail] = useState('')
const select = source => {
haptic()
setSelected(current => (current === source ? null : source))
}
const finish = () => {
const answer = selected === OTHER ? detail.trim() || null : selected
if (answer) recordAcquisitionSource(answer)
onDone()
}
return (
<>
<Box sx={{ textAlign: 'center', mb: 3, ...enter(0) }}>
<Typography
level='h1'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Where&apos;d you hear about Donetick?
</Typography>
<Typography
level='body-md'
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
>
Helps us know what&apos;s working. Totally optional.
</Typography>
</Box>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...enter(60) }}
>
{SOURCES.map(source => {
const active = selected === source
return (
<Box
key={source}
component='button'
type='button'
onClick={() => select(source)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
px: 2,
py: 1.25,
borderRadius: '14px',
border: '1px solid',
borderColor: active ? 'primary.500' : 'divider',
bgcolor: active ? 'primary.softBg' : 'background.surface',
color: 'text.primary',
font: 'inherit',
fontWeight: 600,
fontSize: '0.9rem',
textAlign: 'left',
cursor: 'pointer',
transition: `border-color 200ms ${EASE}, background-color 200ms ${EASE}`,
}}
>
{source}
<Box
sx={{
flex: '0 0 auto',
width: 22,
height: 22,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
border: '1.5px solid',
borderColor: active
? 'primary.500'
: 'neutral.outlinedBorder',
bgcolor: active ? 'primary.500' : 'transparent',
color: 'common.white',
'& svg': { fontSize: '0.9rem' },
}}
>
{active && <CheckRounded />}
</Box>
</Box>
)
})}
{selected === OTHER && (
<Box sx={{ mt: 0.5, ...enter(0) }}>
<Input
placeholder='Tell us where'
value={detail}
onChange={e => setDetail(e.target.value)}
size='lg'
fullWidth
autoFocus
/>
</Box>
)}
</Box>
<Box sx={{ mt: 3, ...enter(120) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
<Box sx={{ textAlign: 'center', mt: 1.5, ...enter(150) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={finish}
>
Skip
</Link>
</Box>
</>
)
}
const PRIVACY_TOGGLES = [
{
key: 'crashReports',
label: 'Share crash reports',
description:
"When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
},
{
key: 'analytics',
label: 'Share anonymous usage analytics',
description:
'Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you.',
},
]
/**
* The self-hosted counterpart to the attribution survey: since a self-hosted
* server is its own data boundary, what to share back to us is a consent
* question, not a marketing one. Both toggles default off this screen is
* an opt-in, not an opt-out.
*/
const PrivacyPreferences = ({ onDone }) => {
const [crashReports, setCrashReports] = useState(false)
const [analytics, setAnalytics] = useState(false)
const toggle = key => {
haptic()
if (key === 'crashReports') setCrashReports(value => !value)
if (key === 'analytics') setAnalytics(value => !value)
}
const values = { crashReports, analytics }
const finish = () => {
recordPrivacyPreferences(values)
onDone()
}
return (
<>
<Box sx={{ textAlign: 'center', mb: 3, ...enter(0) }}>
<Typography
level='h1'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Help us improve Donetick
</Typography>
<Typography
level='body-md'
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
>
Your server, your data. Both of these are off from this point unless
you turn them on.
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
...enter(60),
}}
>
{PRIVACY_TOGGLES.map(({ key, label, description }) => (
<Box
key={key}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1.5,
px: 2,
py: 1.5,
borderRadius: '14px',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.surface',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0.25, color: 'text.secondary' }}
>
{description}
</Typography>
</Box>
<Switch
checked={values[key]}
onClick={() => toggle(key)}
sx={{ flex: '0 0 auto', mt: 0.25 }}
/>
</Box>
))}
</Box>
<Box sx={{ mt: 3, ...enter(120) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
</>
)
}
const HeardAboutView = () => {
const navigate = useNavigate()
const [isOfficial, setIsOfficial] = useState(null)
useEffect(() => {
let cancelled = false
isOfficialDonetickInstance().then(result => {
if (!cancelled) setIsOfficial(result)
})
return () => {
cancelled = true
}
}, [])
const onDone = () => navigate('/circle-setup', { replace: true })
// Nothing to render mid-check: the read is a local preferences lookup, so
// this resolves before the entrance animation would even be noticed.
if (isOfficial === null) return <Shell />
return (
<Shell>
{isOfficial ? (
<AcquisitionSurvey onDone={onDone} />
) : (
<PrivacyPreferences onDone={onDone} />
)}
</Shell>
)
}
export default HeardAboutView

View File

@@ -0,0 +1,395 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import { Capacitor } from '@capacitor/core'
import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { haptic } from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const READY = [
'Shared chores for the whole house',
'Recurring schedules that survive real life',
'Capture by voice, photo or text',
'Reminders and home-screen widgets',
]
/**
* The beat between signing up and landing in an empty task list: the account is
* made, so say so. Everything listed here is already switched on for a free
* account — the upgrade offer comes after, never in place of it.
*/
const WorkspaceReadyView = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const [busy, setBusy] = useState(false)
const enterApp = () => navigate('/chores', { replace: true })
/**
* Shows the configured RevenueCat offering, then continues into the app
* whatever the outcome — dismissing the paywall is the "continue free" path,
* and a missing offering or a store hiccup must never trap a new user here.
*/
const showPaywall = async () => {
if (!Capacitor.isNativePlatform() || !userProfile?.id) return
const { Purchases } = await import('@revenuecat/purchases-capacitor')
const { RevenueCatUI } = await import('@revenuecat/purchases-capacitor-ui')
await Purchases.configure({
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile.id),
})
const offerings = await Purchases.getOfferings()
if (!offerings?.current) return
await RevenueCatUI.presentPaywall({ offering: offerings.current })
const { customerInfo } = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['Donetick Plus']) {
queryClient.invalidateQueries(['userProfile'])
}
}
const handleContinue = async () => {
setBusy(true)
haptic('medium')
try {
await showPaywall()
} catch (error) {
console.log('Paywall skipped:', error)
} finally {
setBusy(false)
enterApp()
}
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
animation: `readyIn 520ms ${EASE} both`,
'@keyframes readyIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
>
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
'&::before': {
content: '""',
position: 'absolute',
width: 170,
height: 170,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(30px)',
},
'& > *': { position: 'relative' },
}}
>
<Logo size='84px' />
</Box>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
{userProfile?.displayName
? `You're all set, ${userProfile.displayName.split(' ')[0]}`
: "You're all set"}
</Typography>
<Typography
level='body-md'
sx={{ color: 'text.secondary', maxWidth: '30ch' }}
>
Your workspace is ready. Here&apos;s what&apos;s waiting inside.
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{READY.map((item, index) => (
<Box
key={item}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.75,
py: 1.25,
borderRadius: '14px',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
// Each line checks itself off in turn: the small "it's done"
// beat this screen exists for.
animation: `readyTick 460ms ${EASE} ${180 + index * 120}ms both`,
'@keyframes readyTick': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
}}
>
<Box
sx={{
flex: '0 0 auto',
width: 24,
height: 24,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
bgcolor: 'success.500',
color: 'common.white',
'& svg': { fontSize: '1rem' },
}}
>
<CheckRounded />
</Box>
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Box sx={{ width: '100%', maxWidth: 420 }}>
<Button
size='lg'
fullWidth
loading={busy}
onClick={handleContinue}
sx={authButtonSx}
>
Continue
</Button>
</Box>
</Box>
)
}
export default WorkspaceReadyView

View File

@@ -45,7 +45,7 @@ const NavBar = () => {
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
{
to: '/chores',
@@ -128,7 +128,9 @@ const NavBar = () => {
}
}}
title={
searchParams.get('from') === 'calendar' ? t('backToCalendar') : t('back')
searchParams.get('from') === 'calendar'
? t('backToCalendar')
: t('back')
}
>
<ArrowBack />
@@ -145,6 +147,9 @@ const NavBar = () => {
'/password/update',
'/login/settings',
'/welcome',
'/onboarding',
'/get-started',
'/ready',
].includes(location.pathname)
) {
return (