feat: Implement User Switcher and add subscription cancellation modal

This commit is contained in:
Mo Tarbin
2025-09-02 01:50:06 -04:00
parent a7b33c48b3
commit 6403d135ea
8 changed files with 366 additions and 77 deletions

View File

@@ -1,9 +1,9 @@
export const DEFAULT_SIDEPANEL_CONFIG = [
{
id: 'welcome',
name: 'Welcome Card',
description: 'Shows greeting and quick stats',
iconName: 'WavingHand',
id: 'welcome', // legacy name, now represents User Switcher
name: 'User Switcher',
description: 'Allows admins/managers to view tasks as different users',
iconName: 'SupervisorAccount',
enabled: true,
order: 0,
},
@@ -62,4 +62,4 @@ export const saveSidepanelConfig = config => {
export const resetSidepanelConfig = () => {
saveSidepanelConfig(DEFAULT_SIDEPANEL_CONFIG)
return DEFAULT_SIDEPANEL_CONFIG
}
}

View File

@@ -150,7 +150,7 @@ const LoginView = () => {
// Apple Sign In returns id_token in response
return data['response']['id_token']
} else if (data['id_token']) {
// Direct id_token for Apple
// Direct id_token for Apple (fallback)
return data['id_token']
}
}
@@ -259,7 +259,7 @@ const LoginView = () => {
if (Capacitor.isNativePlatform()) {
// For mobile devices, use a custom URL scheme for the redirect
const redirectUri = 'donetick://auth/oauth2'
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
@@ -274,7 +274,7 @@ const LoginView = () => {
try {
// Open OAuth flow in system browser
await Browser.open({ url: authUrl })
// Note: The OAuth callback will be handled by deep link handling
// You'll need to implement deep link handling to catch the redirect
// and extract the authorization code
@@ -294,7 +294,7 @@ const LoginView = () => {
scope: 'openid profile email',
state: state,
})
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
}
@@ -639,6 +639,29 @@ const LoginView = () => {
>
Create new account
</Button>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy-policy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>

View File

@@ -268,6 +268,27 @@ const SignupView = () => {
>
Login
</Button>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy-policy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>
</Container>

View File

@@ -125,7 +125,15 @@ const MyChores = () => {
choresData?.res
) {
setPerformers(membersData.res)
const sortedChores = choresData.res.sort(ChoreSorter)
let sortedChores = choresData.res.sort(ChoreSorter)
// Filter chores based on impersonated user
if (impersonatedUser) {
sortedChores = sortedChores.filter(chore =>
chore.assignedTo === impersonatedUser.userId
)
}
setChores(sortedChores)
setFilteredChores(sortedChores)
const sections = ChoresGrouper(
@@ -161,6 +169,7 @@ const MyChores = () => {
choresData,
membersData,
userProfile,
impersonatedUser,
])
useEffect(() => {
@@ -445,7 +454,17 @@ const MyChores = () => {
if (searchTerm?.length > 0 || searchFilter !== 'All') {
return filteredChores
}
return chores.filter(ChoreFilters(userProfile)[selectedChoreFilter])
let choresToFilter = chores
// Filter by impersonated user first if set
if (impersonatedUser) {
choresToFilter = choresToFilter.filter(chore =>
chore.assignedTo === impersonatedUser.userId
)
}
return choresToFilter.filter(ChoreFilters(userProfile)[selectedChoreFilter])
}
// Helper function to get chores for a specific date
@@ -460,8 +479,15 @@ const MyChores = () => {
}
const updateChores = newChore => {
const newChores = chores
newChores.push(newChore)
let newChores = [...chores, newChore]
// Filter chores based on impersonated user
if (impersonatedUser) {
newChores = newChores.filter(chore =>
chore.assignedTo === impersonatedUser.userId
)
}
setChores(newChores)
setFilteredChores(newChores)
setChoreSections(
@@ -1587,42 +1613,44 @@ const MyChores = () => {
Current Filter: {searchFilter}
</Chip>
)}
{filteredChores.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EditCalendar
{filteredChores.length === 0 &&
// only if not in calendar view:
viewMode !== 'calendar' && (
<Box
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
viewMode !== 'calendar' &&
filteredChores.map(chore =>

View File

@@ -7,10 +7,10 @@ import { getSidepanelConfig } from '../../utils/SidepanelConfig'
import CalendarView from '../components/CalendarView'
import ActivitiesCard from './ActivitesCard'
import TasksByAssigneeCard from './TasksByAssigneeCard'
import WelcomeCard from './WelcomeCard'
import UserSwitcher from './UserSwitcher'
const Sidepanel = ({ chores }) => {
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
const [sidepanelConfig, setSidepanelConfig] = useState([])
const {
@@ -54,7 +54,7 @@ const Sidepanel = ({ chores }) => {
switch (cardConfig.id) {
case 'welcome':
return <WelcomeCard key='welcome' chores={chores} />
return <UserSwitcher key='welcome' chores={chores} />
case 'assignees':
return <TasksByAssigneeCard key='assignees' chores={chores} />
case 'calendar':

View File

@@ -1,11 +1,11 @@
import { Person } from '@mui/icons-material'
import { SupervisorAccount } from '@mui/icons-material'
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import UserModal from '../Modals/Inputs/UserModal'
const WelcomeCard = () => {
const UserSwitcher = () => {
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
const [isAdmin, setIsAdmin] = useState(false)
const { data: userProfile } = useUserProfile()
@@ -57,13 +57,16 @@ const WelcomeCard = () => {
gap: 1,
}}
>
<Person color='' />
<Typography level='title-md'>Current User</Typography>
<SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Typography level='title-md' sx={{ mb: 0.5 }}>
Who&apos;s checking in?
Switch to user view
</Typography>
<Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}>
Tasks will be filtered to show only assignments for selected user
</Typography>
</Box>
<Button
@@ -72,7 +75,7 @@ const WelcomeCard = () => {
onClick={() => setIsModalOpen(true)}
size='sm'
>
Select User
Choose User
</Button>
<UserModal
isOpen={isModalOpen}
@@ -122,8 +125,8 @@ const WelcomeCard = () => {
gap: 1,
}}
>
<Person color='' />
<Typography level='title-md'>Current User</Typography>
<SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography>
</Box>
</Box>
@@ -187,4 +190,4 @@ const WelcomeCard = () => {
</Sheet>
)
}
export default WelcomeCard
export default UserSwitcher

View File

@@ -0,0 +1,119 @@
import { Box, Button, Typography } from '@mui/joy'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
<Typography level='h4' sx={{ mb: 2 }}>
Cancel Subscription
</Typography>
<Box sx={{ p: 2 }}>
<Typography level='body-md' mb={3}>
To cancel your subscription, please follow the instructions for your
platform (you should cancel through the same platform you used to
subscribe).
</Typography>
<Box mb={3}>
<Typography level='title-md' mb={2} color='primary'>
For iOS (iPhone/iPad):
</Typography>
<Typography level='body-sm' mb={1}>
1. Open the <strong>Settings</strong> app on your device
</Typography>
<Typography level='body-sm' mb={1}>
2. Tap your name at the top of the screen
</Typography>
<Typography level='body-sm' mb={1}>
3. Tap <strong>Subscriptions</strong>
</Typography>
<Typography level='body-sm' mb={1}>
4. Find and tap <strong>Donetick</strong>
</Typography>
<Typography level='body-sm' mb={2}>
5. Tap <strong>Cancel Subscription</strong>
</Typography>
<Typography level='body-sm' mb={2} color='warning'>
<strong>Note:</strong> If you subscribed through iOS and are using
the web/desktop version, you must cancel through iOS Settings as
described above.
</Typography>
</Box>
<Box mb={3}>
<Typography level='title-md' mb={2} color='primary'>
For Android:
</Typography>
<Typography level='body-sm' mb={1}>
1. Open the <strong>Google Play Store</strong> app
</Typography>
<Typography level='body-sm' mb={1}>
2. Tap the profile icon in the top right
</Typography>
<Typography level='body-sm' mb={1}>
3. Tap <strong>Payments & subscriptions</strong>
</Typography>
<Typography level='body-sm' mb={1}>
4. Tap <strong>Subscriptions</strong>
</Typography>
<Typography level='body-sm' mb={1}>
5. Find and tap <strong>Donetick</strong>
</Typography>
<Typography level='body-sm' mb={2}>
6. Tap <strong>Cancel subscription</strong>
</Typography>
<Typography level='body-sm' mb={2} color='warning'>
<strong>Note:</strong> If you subscribed through Google Play and are
using the web/desktop version, you must cancel through Google Play
as described above.
</Typography>
</Box>
<Box mb={3}>
<Typography level='title-md' mb={2} color='primary'>
For Web/Desktop Subscriptions:
</Typography>
<Typography level='body-sm' mb={2}>
If you originally subscribed through our website or desktop app, you
can cancel your subscription by going to the Account Settings
section on our website. using a web browser
</Typography>
<Typography level='body-sm' mb={2} color='warning'>
<strong>Important:</strong> You must cancel your subscription
through the same platform where you originally subscribed. If you
subscribed through the iOS App Store or Google Play Store (even if
you're now using the web/desktop version), you must cancel through
that original platform using the instructions above.
</Typography>
</Box>
<Typography level='body-sm' mb={3} color='neutral'>
Your subscription will remain active until the end of your current
billing period.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button size='lg' onClick={onClose} variant='outlined' fullWidth>
I'll cancel from my app store
</Button>
<Button
size='lg'
onClick={() => onClose('desktop')}
variant='solid'
color='danger'
fullWidth
>
I subscribed via desktop - Cancel now
</Button>
<Button size='lg' onClick={onClose} fullWidth>
Dismiss
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default NativeCancelSubscriptionModal

View File

@@ -17,6 +17,7 @@ import {
Typography,
} from '@mui/joy'
import { Purchases } from '@revenuecat/purchases-capacitor'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -41,6 +42,7 @@ import {
import { isPlusAccount } from '../../utils/Helpers'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NativeCancelSubscriptionModal from '../Modals/Inputs/NativeCancelSubscriptionModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import APITokenSettings from './APITokenSettings'
@@ -53,6 +55,7 @@ import ThemeToggle from './ThemeToggle'
const Settings = () => {
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
@@ -69,6 +72,7 @@ const Settings = () => {
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [subscriptionModal, setSubscriptionModal] = useState(false)
const [userDeletionModal, setUserDeletionModal] = useState(false)
const [nativeCancelModal, setNativeCancelModal] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
@@ -128,6 +132,17 @@ const Settings = () => {
setCircleMembers(data.res ? data.res : [])
})
}, [])
useEffect(() => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
appUserID: String(userProfile?.id),
})
}
}
configurePurchases()
}, [userProfile])
// useEffect when circleMembers and userprofile:
useEffect(() => {
@@ -679,30 +694,95 @@ const Settings = () => {
const { RevenueCatUI } = await import(
'@revenuecat/purchases-capacitor-ui'
)
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
appUserID: String(userProfile?.id),
})
const offering = await Purchases.getOfferings()
await RevenueCatUI.presentPaywall({
offering: offering.current,
})
// Check if user now has entitlement after paywall interaction
const customerInfo = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['plus']) {
const { customerInfo } = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['Donetick Plus']) {
queryClient.invalidateQueries(['userProfile'])
queryClient.refetchQueries(['userProfile'])
showNotification({
type: 'success',
message:
'Purchase successful! Please restart the app to access Plus features.',
})
// invalidate user profile to get new subscription status:
}
} catch (error) {
if (error.code !== '1') {
// User cancelled
console.log('Purchase error:', error)
// Handle different error types
if (error.code === '1') {
// User cancelled - don't show error
return
} else if (error.code === '2') {
// Store problem
showNotification({
type: 'error',
message: 'Purchase failed. Please try again.',
message:
'Store connection issue. Please check your network and try again.',
})
} else if (error.code === '3') {
// Purchase not allowed
showNotification({
type: 'error',
message:
'Purchases are not allowed on this device. Please check your device restrictions.',
})
} else if (error.code === '4') {
// Product not available
showNotification({
type: 'error',
message:
'This subscription is not available. Please try again later.',
})
} else if (error.code === '5') {
// Receipt already in use
showNotification({
type: 'error',
message:
'This purchase has already been processed. If you believe this is an error, please contact support.',
})
} else if (error.code === '6') {
// Missing receipt file
showNotification({
type: 'error',
message:
'Purchase receipt missing. Please try purchasing again.',
})
} else if (error.code === '7') {
// Network error
showNotification({
type: 'error',
message:
'Network error. Please check your connection and try again.',
})
} else if (error.code === '8') {
// Invalid receipt
showNotification({
type: 'error',
message:
'Invalid purchase receipt. Please contact support if this persists.',
})
} else if (error.code === '9') {
// Payment pending
showNotification({
type: 'warning',
message:
'Payment is pending approval. You will receive access once approved.',
})
} else {
// Generic error
// log on what part of the code the error happened
console.error('Unexpected purchase error:', error)
console.error('Error occurred in purchase flow')
showNotification({
type: 'error',
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
})
}
}
@@ -724,15 +804,7 @@ const Settings = () => {
variant='outlined'
color='danger'
onClick={() => {
CancelSubscription().then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Subscription cancelled',
})
window.location.reload()
}
})
setNativeCancelModal(true)
}}
>
Cancel
@@ -844,6 +916,29 @@ const Settings = () => {
}}
userProfile={userProfile}
/>
<NativeCancelSubscriptionModal
isOpen={nativeCancelModal}
onClose={action => {
setNativeCancelModal(false)
if (action === 'desktop') {
CancelSubscription().then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Subscription cancelled',
})
window.location.reload()
} else {
showNotification({
type: 'error',
message: 'Failed to cancel subscription',
})
}
})
}
}}
/>
</Container>
)
}