Implement onboarding flow and circle invite handling with new utilities

This commit is contained in:
Mo Tarbin
2026-08-09 01:21:53 -04:00
parent 5b7594ac64
commit 5f0c5cdc5d
7 changed files with 115 additions and 82 deletions

View File

@@ -42,8 +42,8 @@ const AppContent = () => {
recordRoute(location.pathname) recordRoute(location.pathname)
}, [location.pathname]) }, [location.pathname])
// // First-launch native users see the onboarding flow before anything else. // First-launch native users see the onboarding flow before anything else.
useOnboardingGate() const isRedirectingToOnboarding = useOnboardingGate()
// Initialize status bar with theme-aware configuration // Initialize status bar with theme-aware configuration
useStatusBar() useStatusBar()
@@ -95,6 +95,8 @@ const AppContent = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [needRefresh]) }, [needRefresh])
if (isRedirectingToOnboarding) return null
return ( return (
<div> <div>
<ImpersonateUserProvider> <ImpersonateUserProvider>

View File

@@ -9,6 +9,8 @@ import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher' import { RegisterDeviceToken } from './utils/Fetcher'
import { beginOAuthExchange } from './utils/OAuthExchangeState' import { beginOAuthExchange } from './utils/OAuthExchangeState'
import { hasSeenOnboarding } from './utils/Onboarding'
import { setPendingInvite } from './utils/PendingInvite'
// React Router navigate(), injected by <App /> once the router is mounted. // React Router navigate(), injected by <App /> once the router is mounted.
// Using client-side navigation (instead of window.location.href) avoids a full // Using client-side navigation (instead of window.location.href) avoids a full
@@ -78,7 +80,12 @@ const handleUrlOpen = (url, isColdStart = false) => {
(parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join') (parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join')
if (isCircleInvite) { if (isCircleInvite) {
routerNavigate(`/circle/join${parsedUrl.search}`) setPendingInvite(parsedUrl.searchParams.get('code'))
const needsOnboarding =
!hasSeenOnboarding() && !localStorage.getItem('token')
routerNavigate(
needsOnboarding ? '/onboarding' : `/circle/join${parsedUrl.search}`,
)
} else if (url.startsWith('donetick://chores/add')) { } else if (url.startsWith('donetick://chores/add')) {
// Widget "+" / quick-capture buttons: land on the chore list with the // Widget "+" / quick-capture buttons: land on the chore list with the
// quick-add modal open (MyChores watches for the add_task param and // quick-add modal open (MyChores watches for the add_task param and

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding' import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
import { setPendingInvite } from '../utils/PendingInvite'
// Routes a first-run user may legitimately be on without having gone through // 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 // onboarding: the flow itself, deep-link auth callbacks, and the legal pages
@@ -27,16 +28,24 @@ const isAllowed = pathname =>
*/ */
const useOnboardingGate = () => { const useOnboardingGate = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { pathname } = useLocation() const { pathname, search } = useLocation()
const isRedirecting =
isNativeApp() &&
!hasSeenOnboarding() &&
!localStorage.getItem('token') &&
!isAllowed(pathname)
useEffect(() => { useEffect(() => {
if (!isNativeApp() || hasSeenOnboarding()) return if (!isRedirecting) return
// A signed-in user upgrading from an older build has nothing to onboard to.
if (localStorage.getItem('token')) return if (pathname === '/circle/join') {
if (isAllowed(pathname)) return setPendingInvite(new URLSearchParams(search).get('code'))
}
navigate('/onboarding', { replace: true }) navigate('/onboarding', { replace: true })
}, [pathname, navigate]) }, [isRedirecting, pathname, search, navigate])
return isRedirecting
} }
export default useOnboardingGate export default useOnboardingGate

View File

@@ -2,9 +2,10 @@ import { App as capacitorApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue' import { commandQueue } from '../utils/CommandQueue'
import { offlineDB } from '../utils/OfflineDB'
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState' import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
import { offlineDB } from '../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine' import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager' import { networkManager } from './NetworkManager'
@@ -91,6 +92,10 @@ export function useSyncOnReconnect() {
const runSync = async () => { const runSync = async () => {
if (!isOfflineFeatureEnabled()) return if (!isOfflineFeatureEnabled()) return
// Public routes (onboarding, login, signup) have no session to sync.
// Calling /sync/changes here returns 401 and the global auth handler
// hard-navigates to /login, which reloads the WebView mid-onboarding.
if (!localStorage.getItem('token')) return
// Skip while the OAuth code exchange is in flight — there's no session // Skip while the OAuth code exchange is in flight — there's no session
// yet, so a sync here just 401s. Note the app-resume listener fires in // yet, so a sync here just 401s. Note the app-resume listener fires in
// the same tick as the deep link, before the route changes, so this has // the same tick as the deep link, before the route changes, so this has

View File

@@ -13,7 +13,12 @@ export const joinCirclePath = code =>
export const setPendingInvite = code => { export const setPendingInvite = code => {
if (!code) return if (!code) return
localStorage.setItem(INVITE_KEY, code)
try {
localStorage.setItem(INVITE_KEY, code)
} catch {
// The redirect cookie still preserves the invite through authentication.
}
// Every post-auth landing point (password login, OAuth callback, MFA) already // Every post-auth landing point (password login, OAuth callback, MFA) already
// consumes `ca_redirect`, so reusing it is all the routing this needs. // consumes `ca_redirect`, so reusing it is all the routing this needs.
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 }) Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
@@ -31,10 +36,11 @@ export const clearPendingInvite = () => {
try { try {
localStorage.removeItem(INVITE_KEY) localStorage.removeItem(INVITE_KEY)
} catch { } catch {
// ignore // Ignore unavailable storage during cleanup.
} }
const redirect = Cookies.get(REDIRECT_COOKIE) const redirect = Cookies.get(REDIRECT_COOKIE)
if (redirect && redirect.startsWith('/circle/join')) { if (redirect?.startsWith('/circle/join')) {
Cookies.remove(REDIRECT_COOKIE) Cookies.remove(REDIRECT_COOKIE)
} }
} }

View File

@@ -6,10 +6,12 @@ import {
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy' import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal' import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { GetUserCircle, JoinCircle } from '../../utils/Fetcher' import { GetUserCircle, JoinCircle } from '../../utils/Fetcher'
import { haptic } from '../../utils/Onboarding' import { haptic } from '../../utils/Onboarding'
import { clearPendingInvite, getPendingInvite } from '../../utils/PendingInvite'
import { authButtonSx } from '../Authorization/authStyles' import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal' import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
import { CircleVignette } from './OnboardingVignettes' import { CircleVignette } from './OnboardingVignettes'
@@ -81,10 +83,11 @@ const CircleSetupView = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { showNotification } = useNotification() const { showNotification } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal() const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const pendingInvite = getPendingInvite()
const [mode, setMode] = useState('invite') const [mode, setMode] = useState(pendingInvite ? 'join' : 'invite')
const [inviteCode, setInviteCode] = useState(null) const [inviteCode, setInviteCode] = useState(null)
const [joinCode, setJoinCode] = useState('') const [joinCode, setJoinCode] = useState(pendingInvite ?? '')
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
useEffect(() => { useEffect(() => {
@@ -115,6 +118,7 @@ const CircleSetupView = () => {
try { try {
const resp = await JoinCircle(joinCode.trim()) const resp = await JoinCircle(joinCode.trim())
if (resp.ok) { if (resp.ok) {
clearPendingInvite()
showAcknowledgment( 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.", "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', 'Request Sent',
@@ -308,7 +312,11 @@ const CircleSetupView = () => {
level='body-sm' level='body-sm'
color='neutral' color='neutral'
underline='hover' underline='hover'
onClick={() => setMode('invite')} onClick={() => {
clearPendingInvite()
setJoinCode('')
setMode('invite')
}}
> >
Back Back
</Link> </Link>

View File

@@ -1,5 +1,5 @@
import { Share } from '@capacitor/share' import { Share } from '@capacitor/share'
import { Delete, IosShare, Refresh } from '@mui/icons-material' import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@@ -161,89 +161,85 @@ const CircleSettings = () => {
link below. You'll receive a notification below when someone requests link below. You'll receive a notification below when someone requests
to join your Circle. to join your Circle.
</Typography> </Typography>
<Typography level='title-sm' mb={-1}> <Box>
<Typography level='title-sm' sx={{ mb: 1 }}>
{userCircles[0]?.userRole === 'member' {userCircles[0]?.userRole === 'member'
? `You part of ${userCircles[0]?.name} ` ? `You part of ${userCircles[0]?.name} `
: `You circle code is:`} : `You circle code is:`}
</Typography>
<Input <Input
value={inviteCode} value={inviteCode}
disabled disabled
size='lg' size='lg'
sx={{ sx={{
width: '220px', width: { xs: '100%', sm: '220px' },
mb: 1, mb: 1,
}} }}
/> />
<Button <Box
variant='soft' sx={{
onClick={() => { display: 'flex',
navigator.clipboard.writeText(userCircles[0]?.invite_code) flexWrap: 'wrap',
showNotification({ alignItems: 'center',
type: 'success', gap: 1,
message: 'Code copied to clipboard',
})
}} }}
> >
Copy Code
</Button>
<Button
variant='soft'
disabled={!inviteLink}
startDecorator={<IosShare />}
sx={{ ml: 1 }}
onClick={shareInvite}
>
Share Invite
</Button>
<Button
variant='soft'
disabled={!inviteLink}
sx={{ ml: 1 }}
onClick={() => {
navigator.clipboard.writeText(inviteLink)
showNotification({
type: 'success',
message: 'Link copied to clipboard',
})
}}
>
Copy Link
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button <Button
color='danger' variant='soft'
variant='outlined' startDecorator={<CopyAll />}
sx={{ ml: 1 }}
onClick={() => { onClick={() => {
showConfirmation( navigator.clipboard.writeText(userCircles[0]?.invite_code)
'Are you sure you want to leave your circle?', showNotification({
'Leave Circle', type: 'success',
() => { message: 'Code copied to clipboard',
LeaveCircle(userCircles[0]?.id).then(resp => { })
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
})
}
})
},
'Leave',
'Cancel',
'danger',
)
}} }}
> >
Leave Circle Copy Code
</Button> </Button>
)} <Button
</Typography> variant='soft'
disabled={!inviteLink}
startDecorator={<IosShare />}
onClick={shareInvite}
>
Share Invite
</Button>
{userCircles.length > 0 &&
userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
onClick={() => {
showConfirmation(
'Are you sure you want to leave your circle?',
'Leave Circle',
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
})
}
})
},
'Leave',
'Cancel',
'danger',
)
}}
>
Leave Circle
</Button>
)}
</Box>
</Box>
<Typography level='title-md'>Circle Members</Typography> <Typography level='title-md'>Circle Members</Typography>
{circleMembers.map(member => ( {circleMembers.map(member => (