Implement onboarding flow and circle invite handling with new utilities
This commit is contained in:
@@ -42,8 +42,8 @@ const AppContent = () => {
|
||||
recordRoute(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
// // First-launch native users see the onboarding flow before anything else.
|
||||
useOnboardingGate()
|
||||
// First-launch native users see the onboarding flow before anything else.
|
||||
const isRedirectingToOnboarding = useOnboardingGate()
|
||||
|
||||
// Initialize status bar with theme-aware configuration
|
||||
useStatusBar()
|
||||
@@ -95,6 +95,8 @@ const AppContent = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [needRefresh])
|
||||
|
||||
if (isRedirectingToOnboarding) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImpersonateUserProvider>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { focusManager } from '@tanstack/react-query'
|
||||
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
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.
|
||||
// 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')
|
||||
|
||||
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')) {
|
||||
// Widget "+" / quick-capture buttons: land on the chore list with the
|
||||
// quick-add modal open (MyChores watches for the add_task param and
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||
import { setPendingInvite } from '../utils/PendingInvite'
|
||||
|
||||
// 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
|
||||
@@ -27,16 +28,24 @@ const isAllowed = pathname =>
|
||||
*/
|
||||
const useOnboardingGate = () => {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { pathname, search } = useLocation()
|
||||
const isRedirecting =
|
||||
isNativeApp() &&
|
||||
!hasSeenOnboarding() &&
|
||||
!localStorage.getItem('token') &&
|
||||
!isAllowed(pathname)
|
||||
|
||||
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
|
||||
if (!isRedirecting) return
|
||||
|
||||
if (pathname === '/circle/join') {
|
||||
setPendingInvite(new URLSearchParams(search).get('code'))
|
||||
}
|
||||
|
||||
navigate('/onboarding', { replace: true })
|
||||
}, [pathname, navigate])
|
||||
}, [isRedirecting, pathname, search, navigate])
|
||||
|
||||
return isRedirecting
|
||||
}
|
||||
|
||||
export default useOnboardingGate
|
||||
|
||||
@@ -2,9 +2,10 @@ import { App as capacitorApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
@@ -91,6 +92,10 @@ export function useSyncOnReconnect() {
|
||||
|
||||
const runSync = async () => {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -13,7 +13,12 @@ export const joinCirclePath = code =>
|
||||
|
||||
export const setPendingInvite = code => {
|
||||
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
|
||||
// consumes `ca_redirect`, so reusing it is all the routing this needs.
|
||||
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
|
||||
@@ -31,10 +36,11 @@ export const clearPendingInvite = () => {
|
||||
try {
|
||||
localStorage.removeItem(INVITE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
// Ignore unavailable storage during cleanup.
|
||||
}
|
||||
|
||||
const redirect = Cookies.get(REDIRECT_COOKIE)
|
||||
if (redirect && redirect.startsWith('/circle/join')) {
|
||||
if (redirect?.startsWith('/circle/join')) {
|
||||
Cookies.remove(REDIRECT_COOKIE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
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 { clearPendingInvite, getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
import { CircleVignette } from './OnboardingVignettes'
|
||||
@@ -81,10 +83,11 @@ const CircleSetupView = () => {
|
||||
const navigate = useNavigate()
|
||||
const { showNotification } = useNotification()
|
||||
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 [joinCode, setJoinCode] = useState('')
|
||||
const [joinCode, setJoinCode] = useState(pendingInvite ?? '')
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +118,7 @@ const CircleSetupView = () => {
|
||||
try {
|
||||
const resp = await JoinCircle(joinCode.trim())
|
||||
if (resp.ok) {
|
||||
clearPendingInvite()
|
||||
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',
|
||||
@@ -308,7 +312,11 @@ const CircleSetupView = () => {
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
onClick={() => setMode('invite')}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
setJoinCode('')
|
||||
setMode('invite')
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Share } from '@capacitor/share'
|
||||
import { Delete, IosShare, Refresh } from '@mui/icons-material'
|
||||
import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -161,89 +161,85 @@ const CircleSettings = () => {
|
||||
link below. You'll receive a notification below when someone requests
|
||||
to join your Circle.
|
||||
</Typography>
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
<Box>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
{userCircles[0]?.userRole === 'member'
|
||||
? `You part of ${userCircles[0]?.name} `
|
||||
: `You circle code is:`}
|
||||
|
||||
</Typography>
|
||||
<Input
|
||||
value={inviteCode}
|
||||
disabled
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
width: { xs: '100%', sm: '220px' },
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
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
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
sx={{ ml: 1 }}
|
||||
variant='soft'
|
||||
startDecorator={<CopyAll />}
|
||||
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',
|
||||
)
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
Copy Code
|
||||
</Button>
|
||||
)}
|
||||
</Typography>
|
||||
<Button
|
||||
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>
|
||||
{circleMembers.map(member => (
|
||||
|
||||
Reference in New Issue
Block a user