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:
@@ -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()
|
||||
|
||||
|
||||
1192
src/assets/logo.svg
1192
src/assets/logo.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 7.7 KiB |
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
38
src/hooks/useOnboardingGate.js
Normal file
38
src/hooks/useOnboardingGate.js
Normal 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
114
src/utils/Onboarding.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
325
src/views/Onboarding/CircleSetupView.jsx
Normal file
325
src/views/Onboarding/CircleSetupView.jsx
Normal 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
|
||||
175
src/views/Onboarding/GetStartedView.jsx
Normal file
175
src/views/Onboarding/GetStartedView.jsx
Normal 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
|
||||
343
src/views/Onboarding/HeardAboutView.jsx
Normal file
343
src/views/Onboarding/HeardAboutView.jsx
Normal 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'd you hear about Donetick?
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
||||
>
|
||||
Helps us know what'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
|
||||
395
src/views/Onboarding/OnboardingView.jsx
Normal file
395
src/views/Onboarding/OnboardingView.jsx
Normal 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
|
||||
1569
src/views/Onboarding/OnboardingVignettes.jsx
Normal file
1569
src/views/Onboarding/OnboardingVignettes.jsx
Normal file
File diff suppressed because it is too large
Load Diff
220
src/views/Onboarding/WorkspaceReadyView.jsx
Normal file
220
src/views/Onboarding/WorkspaceReadyView.jsx
Normal 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's what'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
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user