Improve circle joining experiance

This commit is contained in:
Mo Tarbin
2026-08-08 13:40:21 -04:00
parent 83a5760959
commit 1ddf2539e0
9 changed files with 286 additions and 163 deletions

View File

@@ -1,5 +1,6 @@
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
@@ -11,6 +12,9 @@ const ALLOWED_PATHS = [
'/login/settings',
'/privacy',
'/terms',
// An invite link is a legitimate first launch: the join view explains itself
// and routes to sign-in, so onboarding must not swallow the code.
'/circle/join',
]
const isAllowed = pathname =>

View File

@@ -96,6 +96,9 @@ export function useSyncOnReconnect() {
// the same tick as the deep link, before the route changes, so this has
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
// No session, nothing to sync — and a 401 here would force a logout that
// hard-navigates signed-out visitors (invite links) away to /login.
if (!localStorage.getItem('token')) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {

View File

@@ -29,6 +29,7 @@ export const useAllUsers = () => {
export const useCircleMembers = () => {
const queryClient = useQueryClient()
const token = localStorage.getItem('token')
const { data, error, isLoading } = useQuery({
queryKey: ['allCircleMembers'],
@@ -46,6 +47,10 @@ export const useCircleMembers = () => {
return { res: [] }
}
},
// NavBar's avatar mounts this on every route, including the signed-out
// ones. Without the gate the 401 tips ApiClient into a forced logout that
// hard-navigates to /login — which is what used to eat circle invites.
enabled: !!token,
})
const handleRefetch = () => {

View File

@@ -155,6 +155,21 @@ class ApiClient {
return
}
// An expired session on an invite link would otherwise drop the code on the
// way to /login. Stash it first so sign-in returns to the join.
try {
const { pathname, search } = window.location
if (pathname === '/circle/join') {
const code = new URLSearchParams(search).get('code')
if (code) {
const { setPendingInvite } = await import('./PendingInvite')
setPendingInvite(code)
}
}
} catch (e) {
console.error('Error preserving pending invite on logout', e)
}
await clearAllTokens()
try {
await offlineDB.clearAll()

View File

@@ -0,0 +1,40 @@
import Cookies from 'js-cookie'
// A circle invite link is often the very first thing a new user opens, so the
// code has to survive the trip through login/signup (including OAuth, which
// leaves and re-enters the app) and be replayed once a session exists.
const INVITE_KEY = 'pending_circle_invite'
const REDIRECT_COOKIE = 'ca_redirect'
// `auto=1` tells the join view this visit is the return leg of an auth
// round-trip, so it can submit the request instead of asking a second time.
export const joinCirclePath = code =>
`/circle/join?code=${encodeURIComponent(code)}&auto=1`
export const setPendingInvite = code => {
if (!code) return
localStorage.setItem(INVITE_KEY, code)
// 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 })
}
export const getPendingInvite = () => {
try {
return localStorage.getItem(INVITE_KEY)
} catch {
return null
}
}
export const clearPendingInvite = () => {
try {
localStorage.removeItem(INVITE_KEY)
} catch {
// ignore
}
const redirect = Cookies.get(REDIRECT_COOKIE)
if (redirect && redirect.startsWith('/circle/join')) {
Cookies.remove(REDIRECT_COOKIE)
}
}

View File

@@ -12,12 +12,14 @@ import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { getPendingInvite } from '../../utils/PendingInvite'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import {
@@ -138,7 +140,15 @@ const LoginView = () => {
}, [])
useEffect(() => {
if (isAuthenticated && user) {
Navigate('/chores')
// An already-signed-in visitor who lands here from a deep link (a circle
// invite, for example) still has to end up where they were headed.
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl && redirectUrl !== '/') {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
}
}, [isAuthenticated, user, Navigate])
const handleSubmit = async e => {
@@ -416,9 +426,11 @@ const LoginView = () => {
<AuthShell
title={userProfile ? 'Welcome back' : 'Sign in'}
subtitle={
userProfile
? 'Pick up right where you left off.'
: 'Sign in to your account to continue.'
getPendingInvite()
? 'Sign in and well send your circle join request right after.'
: userProfile
? 'Pick up right where you left off.'
: 'Sign in to your account to continue.'
}
logoSize={0}
footer={<LegalLinks />}

View File

@@ -2,8 +2,11 @@ import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
import { signUp } from '../../utils/Fetcher'
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
import {
AuthPasswordField,
AuthSubmitButton,
@@ -25,28 +28,39 @@ const SignupView = () => {
const [displayNameError, setDisplayNameError] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
response.json().then(res => {
localStorage.setItem('token', res.token)
localStorage.setItem('token_expiry', res.expire)
const { login: authLogin } = useAuth()
// Sign-in goes through the auth context, not a bare fetch: it stores the
// refresh token and updates the provider's own state, so the rest of the app
// sees the new session without a reload.
const handleLogin = async (username, password) => {
const result = await authLogin({ username, password })
if (!result.success) {
showError({
title: 'Almost there',
message:
'Your account was created, but signing in failed. Please sign in.',
})
Navigate('/login')
return
}
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
// 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)
// Someone who signed up from a circle invite is joining an existing
// circle, so sending them through "name your circle" is both a dead
// end for the invite and the wrong question.
const pendingInvite = getPendingInvite()
if (pendingInvite) {
Navigate(joinCirclePath(pendingInvite), { replace: true })
return
}
// Navigate('/login')
}
})
// 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 })
}
const handleSignUpValidation = () => {
// Reset errors before validation
@@ -132,7 +146,11 @@ const SignupView = () => {
return (
<AuthShell
title='Create your account'
subtitle='Track chores and tasks together, in one shared place.'
subtitle={
getPendingInvite()
? 'Create an account and well send your circle join request right after.'
: 'Track chores and tasks together, in one shared place.'
}
footer={<LegalLinks />}
logoSize={0}
>

View File

@@ -1,162 +1,185 @@
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
import { Button } from '@mui/joy'
import { useState } from 'react'
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { JoinCircle } from '../../utils/Fetcher'
import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
import AuthShell from '../Authorization/AuthShell'
import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
const JoinCircleView = () => {
const { data: userProfile } = useUserProfile()
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
// Read the token rather than useAuth(): the provider's copy only updates
// through its own login(), so signup and the OAuth callback — which save
// tokens directly — would still look signed out here. The query hooks read
// storage the same way.
const isAuthenticated = !!localStorage.getItem('token')
const { showError } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [isJoining, setIsJoining] = useState(false)
let [searchParams, setSearchParams] = useSearchParams()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const code = searchParams.get('code')
// `auto=1` is on the link we send the user back to after they authenticate,
// and only there — someone who opens an invite while already signed in gets
// asked, not auto-joined.
const isReturningFromAuth = searchParams.get('auto') === '1'
const autoJoinAttempted = useRef(false)
return (
<Container
component='main'
maxWidth='xs'
const submitJoin = useCallback(() => {
setIsJoining(true)
JoinCircle(code)
.then(resp => {
clearPendingInvite()
if (resp.ok) {
showAcknowledgment(
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
'Join Request Sent!',
() => navigate('/chores'),
'Got it',
'success',
)
} else {
setIsJoining(false)
if (resp.status === 409) {
showError('You are already a member of this circle')
} else {
showError('Failed to join circle')
}
navigate('/chores')
}
})
.catch(() => {
setIsJoining(false)
clearPendingInvite()
showError('Failed to join circle, please try again')
})
}, [code, navigate, showAcknowledgment, showError])
// make content center in the middle of the page:
>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
// Coming back from login/signup the user already said yes by opening the
// link, so send the request instead of asking a second time. This step used
// to be missing entirely: the login page was a dead end.
useEffect(() => {
if (autoJoinAttempted.current) return
if (!code || !isReturningFromAuth) return
if (!isAuthenticated || !userProfile) return
autoJoinAttempted.current = true
submitJoin()
}, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
// Park the code so it survives the round-trip, including OAuth flows that
// leave the app entirely.
const goToAuth = destination => {
setPendingInvite(code)
navigate(destination)
}
const inviteCodeField = (
<Input
value={code || ''}
readOnly
size='lg'
slotProps={{ input: { style: { textAlign: 'center', fontWeight: 600 } } }}
/>
)
let title = "You've been invited to a circle"
let subtitle = null
let body = null
if (!code) {
title = 'Invite link is incomplete'
subtitle =
"This link doesn't include an invite code. Ask whoever invited you to send the link again."
body = (
<Button
fullWidth
size='lg'
sx={authButtonSx}
onClick={() => navigate('/chores')}
>
<Sheet
component='form'
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
Go to Donetick
</Button>
)
// A token that no longer resolves to a profile is as good as signed out —
// better to offer sign-in than to spin forever.
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
subtitle =
'You need a Donetick account to join. Sign in or create one — well send your join request as soon as youre in.'
body = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{inviteCodeField}
<Button
fullWidth
size='lg'
sx={authButtonSx}
onClick={() => goToAuth('/login')}
>
Sign in
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='neutral'
sx={authButtonSx}
onClick={() => goToAuth('/signup')}
>
Create an account
</Button>
</Box>
)
} else if (isProfileLoading || isJoining) {
title = 'Joining circle'
subtitle = 'Sending your request…'
body = (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress />
</Box>
)
} else {
subtitle = `Hi ${
userProfile?.displayName || userProfile?.username
}, joining gives you access to this circle's tasks and members.`
body = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography
level='body-sm'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
A circle admin approves your request before you get access.
</Typography>
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
Join circle
</Button>
<Button
fullWidth
size='lg'
variant='plain'
color='neutral'
sx={authButtonSx}
onClick={() => {
clearPendingInvite()
navigate('/chores')
}}
>
<Logo />
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
{code && userProfile && (
<>
<Typography level='body-md' alignSelf={'center'}>
Hi {userProfile?.displayName}, you have been invited to join the
circle{' '}
</Typography>
<Input
fullWidth
placeholder='Enter code'
value={code}
disabled={!!code}
size='lg'
sx={{
width: '220px',
mb: 1,
}}
/>
<Typography level='body-md' alignSelf={'center'}>
Joining will give you access to the circle's chores and members.
</Typography>
<Typography level='body-md' alignSelf={'center'}>
You can leave the circle later from you Settings page.
</Typography>
<Button
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
disabled={isJoining}
onClick={() => {
setIsJoining(true)
JoinCircle(code).then(resp => {
if (resp.ok) {
showAcknowledgment(
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
'Join Request Sent!',
() => navigate('/'),
'Got it',
'success',
)
} else {
setIsJoining(false)
if (resp.status === 409) {
showError('You are already a member of this circle')
} else {
showError('Failed to join circle')
}
navigate('/')
}
})
}}
>
{isJoining ? 'Joining...' : 'Join Circle'}
</Button>
<Button
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/chores')
}}
>
Cancel
</Button>
</>
)}
{!code ||
(!userProfile && (
<>
<Typography level='body-md' alignSelf={'center'}>
You need to be logged in to join a circle
</Typography>
<Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
Login or sign up to continue
</Typography>
<Button
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
navigate('/login')
}}
>
Login
</Button>
</>
))}
</Sheet>
Cancel
</Button>
</Box>
)
}
return (
<AuthShell title={title} subtitle={subtitle} showLogo>
{body}
<AcknowledgmentModal config={ackModalConfig} />
</Container>
</AuthShell>
)
}

View File

@@ -150,6 +150,9 @@ const NavBar = () => {
'/onboarding',
'/get-started',
'/ready',
// Reached from an invite link, often signed out: it owns its own shell
// and must not mount the avatar's authenticated queries.
'/circle/join',
].includes(location.pathname)
) {
return (