From 5f0c5cdc5d8f346fe6c2a94772b599b6fdd91267 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 9 Aug 2026 01:21:53 -0400 Subject: [PATCH] Implement onboarding flow and circle invite handling with new utilities --- src/App.jsx | 6 +- src/CapacitorListener.js | 9 +- src/hooks/useOnboardingGate.js | 21 ++-- src/hooks/useSyncOnReconnect.js | 7 +- src/utils/PendingInvite.js | 12 ++- src/views/Onboarding/CircleSetupView.jsx | 14 ++- src/views/Settings/CircleSettings.jsx | 128 +++++++++++------------ 7 files changed, 115 insertions(+), 82 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index ec3684a..56a7f48 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 (
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index 282c777..1957455 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -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 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 diff --git a/src/hooks/useOnboardingGate.js b/src/hooks/useOnboardingGate.js index d420d14..7f9d6e8 100644 --- a/src/hooks/useOnboardingGate.js +++ b/src/hooks/useOnboardingGate.js @@ -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 diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js index d09f20d..b946b6d 100644 --- a/src/hooks/useSyncOnReconnect.js +++ b/src/hooks/useSyncOnReconnect.js @@ -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 diff --git a/src/utils/PendingInvite.js b/src/utils/PendingInvite.js index b5d0059..34cd270 100644 --- a/src/utils/PendingInvite.js +++ b/src/utils/PendingInvite.js @@ -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) } } diff --git a/src/views/Onboarding/CircleSetupView.jsx b/src/views/Onboarding/CircleSetupView.jsx index 96784c8..b16d1b0 100644 --- a/src/views/Onboarding/CircleSetupView.jsx +++ b/src/views/Onboarding/CircleSetupView.jsx @@ -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 diff --git a/src/views/Settings/CircleSettings.jsx b/src/views/Settings/CircleSettings.jsx index 03b27d1..370514e 100644 --- a/src/views/Settings/CircleSettings.jsx +++ b/src/views/Settings/CircleSettings.jsx @@ -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. - + + {userCircles[0]?.userRole === 'member' ? `You part of ${userCircles[0]?.name} ` : `You circle code is:`} - + - - - - {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && ( - )} - + + {userCircles.length > 0 && + userCircles[0]?.userRole === 'member' && ( + + )} + + Circle Members {circleMembers.map(member => (