Implement onboarding flow and circle invite handling with new utilities
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
Reference in New Issue
Block a user