334 lines
9.9 KiB
JavaScript
334 lines
9.9 KiB
JavaScript
import {
|
|
ContentCopyRounded,
|
|
GroupAddRounded,
|
|
LinkRounded,
|
|
} from '@mui/icons-material'
|
|
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'
|
|
|
|
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
|
|
|
const enter = (delay = 0) => ({
|
|
animation: `circleSetupIn 520ms ${EASE} ${delay}ms both`,
|
|
'@keyframes circleSetupIn': {
|
|
from: { opacity: 0, transform: 'translateY(12px)' },
|
|
to: { opacity: 1, transform: 'none' },
|
|
},
|
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
|
})
|
|
|
|
/**
|
|
* A halo'd icon standing in for the vignette on the join path — that side has
|
|
* no living preview to show (there's nothing to illustrate about someone
|
|
* else's circle yet), so it borrows the same glow treatment WorkspaceReadyView
|
|
* puts behind its logo instead of inventing a third visual language.
|
|
*/
|
|
const IconHalo = ({ icon }) => (
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
width: 84,
|
|
height: 84,
|
|
'&::before': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
width: 140,
|
|
height: 140,
|
|
borderRadius: '50%',
|
|
bgcolor: 'primary.softBg',
|
|
opacity: 0.6,
|
|
filter: 'blur(28px)',
|
|
},
|
|
'& > *': { position: 'relative' },
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: '50%',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
bgcolor: 'background.surface',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
color: 'primary.plainColor',
|
|
'& svg': { fontSize: '2rem' },
|
|
}}
|
|
>
|
|
{icon}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
|
|
/**
|
|
* Right after the account (and its solo Circle) is created: the moment to
|
|
* either bring the household in or hop into one that already exists. Skipping
|
|
* via Continue is always valid — chores work fine solo, this is an offer, not
|
|
* a gate.
|
|
*/
|
|
const CircleSetupView = () => {
|
|
const navigate = useNavigate()
|
|
const { showNotification } = useNotification()
|
|
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
|
const pendingInvite = getPendingInvite()
|
|
|
|
const [mode, setMode] = useState(pendingInvite ? 'join' : 'invite')
|
|
const [inviteCode, setInviteCode] = useState(null)
|
|
const [joinCode, setJoinCode] = useState(pendingInvite ?? '')
|
|
const [isJoining, setIsJoining] = useState(false)
|
|
|
|
useEffect(() => {
|
|
GetUserCircle()
|
|
.then(resp => resp.json())
|
|
.then(data => setInviteCode(data.res?.[0]?.invite_code ?? null))
|
|
.catch(() => setInviteCode(null))
|
|
}, [])
|
|
|
|
const finish = () => navigate('/ready', { replace: true })
|
|
|
|
const copyCode = () => {
|
|
navigator.clipboard.writeText(inviteCode)
|
|
showNotification({ type: 'success', message: 'Code copied to clipboard' })
|
|
}
|
|
|
|
const copyLink = () => {
|
|
navigator.clipboard.writeText(
|
|
`${window.location.protocol}//${window.location.host}/circle/join?code=${inviteCode}`,
|
|
)
|
|
showNotification({ type: 'success', message: 'Link copied to clipboard' })
|
|
}
|
|
|
|
const joinCircle = async () => {
|
|
if (!joinCode.trim()) return
|
|
setIsJoining(true)
|
|
haptic()
|
|
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',
|
|
finish,
|
|
)
|
|
} else {
|
|
showNotification({
|
|
type: 'error',
|
|
message:
|
|
resp.status === 409
|
|
? 'You are already a member of this circle'
|
|
: 'Failed to join circle',
|
|
})
|
|
}
|
|
} finally {
|
|
setIsJoining(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
component='main'
|
|
sx={{
|
|
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
px: 3,
|
|
pb: 3,
|
|
bgcolor: 'background.body',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
maxWidth: 420,
|
|
my: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Box sx={{ mb: 2, ...enter(0) }}>
|
|
{mode === 'invite' ? (
|
|
<CircleVignette />
|
|
) : (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
|
<IconHalo icon={<GroupAddRounded />} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
textAlign: 'center',
|
|
gap: 1.5,
|
|
mb: 4,
|
|
...enter(60),
|
|
}}
|
|
>
|
|
<Typography
|
|
level='h1'
|
|
sx={{
|
|
fontSize: '2rem',
|
|
fontWeight: 700,
|
|
letterSpacing: '-0.02em',
|
|
textWrap: 'balance',
|
|
}}
|
|
>
|
|
{mode === 'invite' ? 'Bring your squad in' : 'Join a circle'}
|
|
</Typography>
|
|
<Typography
|
|
level='body-md'
|
|
sx={{
|
|
color: 'text.secondary',
|
|
maxWidth: '32ch',
|
|
textWrap: 'pretty',
|
|
}}
|
|
>
|
|
{mode === 'invite'
|
|
? 'Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.'
|
|
: "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it."}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{mode === 'invite' ? (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<Box
|
|
sx={{
|
|
...enter(120),
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 1.5,
|
|
px: 2,
|
|
py: 1.5,
|
|
borderRadius: '16px',
|
|
bgcolor: 'background.surface',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
fontFamily:
|
|
'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
fontSize: '1.375rem',
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
color: inviteCode ? 'text.primary' : 'text.tertiary',
|
|
}}
|
|
>
|
|
{inviteCode ?? 'Loading…'}
|
|
</Typography>
|
|
<IconButton
|
|
variant='soft'
|
|
color='primary'
|
|
disabled={!inviteCode}
|
|
onClick={copyCode}
|
|
aria-label='Copy circle code'
|
|
>
|
|
<ContentCopyRounded />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box sx={{ textAlign: 'center', ...enter(150) }}>
|
|
<Link
|
|
component='button'
|
|
type='button'
|
|
level='body-sm'
|
|
color='neutral'
|
|
underline='hover'
|
|
startDecorator={<LinkRounded fontSize='small' />}
|
|
disabled={!inviteCode}
|
|
onClick={copyLink}
|
|
>
|
|
Copy invite link instead
|
|
</Link>
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 1, ...enter(190) }}>
|
|
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
|
Continue
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box sx={{ textAlign: 'center', ...enter(230) }}>
|
|
<Link
|
|
component='button'
|
|
type='button'
|
|
level='body-sm'
|
|
color='neutral'
|
|
underline='hover'
|
|
startDecorator={<GroupAddRounded fontSize='small' />}
|
|
onClick={() => setMode('join')}
|
|
>
|
|
Join an existing circle instead
|
|
</Link>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<Box sx={{ ...enter(120) }}>
|
|
<Input
|
|
placeholder='Enter code'
|
|
value={joinCode}
|
|
onChange={e => setJoinCode(e.target.value)}
|
|
size='lg'
|
|
fullWidth
|
|
autoFocus
|
|
/>
|
|
</Box>
|
|
<Box sx={{ ...enter(160) }}>
|
|
<Button
|
|
size='lg'
|
|
fullWidth
|
|
loading={isJoining}
|
|
disabled={!joinCode.trim()}
|
|
onClick={joinCircle}
|
|
sx={authButtonSx}
|
|
>
|
|
Join Circle
|
|
</Button>
|
|
</Box>
|
|
<Box sx={{ textAlign: 'center', ...enter(200) }}>
|
|
<Link
|
|
component='button'
|
|
type='button'
|
|
level='body-sm'
|
|
color='neutral'
|
|
underline='hover'
|
|
onClick={() => {
|
|
clearPendingInvite()
|
|
setJoinCode('')
|
|
setMode('invite')
|
|
}}
|
|
>
|
|
Back
|
|
</Link>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<AcknowledgmentModal config={ackModalConfig} />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export default CircleSetupView
|