Enhance Settings, Things History, User Activities, and User Points views
- Integrated RevenueCat for subscription management in Settings. - Added subscription and user deletion modals in Settings. - Improved analytics display in Things History with update frequency and trend calculations. - Enhanced User Activities timeline with a more structured layout. - Revamped User Points section with a leaderboard and detailed points analytics. - Introduced responsive design elements and improved user experience across various components. - Add permission for Camera to support uploading photo via camera
This commit is contained in:
259
src/components/SubscriptionModal.jsx
Normal file
259
src/components/SubscriptionModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
|
||||
const SubscriptionModal = ({ open, onClose }) => {
|
||||
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { showError } = useNotification()
|
||||
|
||||
const plans = {
|
||||
yearly: {
|
||||
price: '$39.00',
|
||||
period: 'year',
|
||||
total: '$39.00/year',
|
||||
// savings: 'Save $20.88',
|
||||
// popular: true,
|
||||
},
|
||||
// monthly: {
|
||||
// price: '$4.99',
|
||||
// period: 'month',
|
||||
// total: '$4.99/month',
|
||||
// savings: null,
|
||||
// },
|
||||
}
|
||||
|
||||
const features = [
|
||||
'Task notifications and reminders',
|
||||
'Rich text descriptions with images uploads',
|
||||
'Thing-based task triggers',
|
||||
'API tokens for integrations',
|
||||
'Image uploads in descriptions',
|
||||
'Advanced task automation',
|
||||
// 'Unlimited task history',
|
||||
// 'Unlimited things history',
|
||||
]
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Call the backend with the selected plan
|
||||
const response = await GetSubscriptionSession()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create subscription session')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Redirect to Stripe
|
||||
if (data.sessionURL) {
|
||||
window.location.href = data.sessionURL
|
||||
} else {
|
||||
throw new Error('No session URL received')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Subscription error:', error)
|
||||
showError({
|
||||
title: 'Subscription Error',
|
||||
message: 'Failed to start subscription process. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 600,
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
overflow: 'auto',
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography level='h3' sx={{ mb: 1 }}>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
||||
>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSubscribe}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionModal
|
||||
@@ -27,7 +27,6 @@ const LABEL_COLORS = [
|
||||
]
|
||||
|
||||
export const COLORS = {
|
||||
white: '#FFFFFF',
|
||||
salmon: '#ff7961',
|
||||
teal: '#26a69a',
|
||||
skyBlue: '#80d8ff',
|
||||
@@ -52,6 +51,7 @@ export const COLORS = {
|
||||
blush: '#f8bbd0',
|
||||
ash: '#90a4ae',
|
||||
sand: '#d7ccc8',
|
||||
white: '#FFFFFF',
|
||||
}
|
||||
|
||||
export const TASK_COLOR = {
|
||||
|
||||
@@ -596,14 +596,61 @@ const ClearChoreTimer = choreId => {
|
||||
})
|
||||
}
|
||||
|
||||
const CheckUserDeletion = (password) => {
|
||||
return Fetch(`/users/delete/check`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
return Fetch(`/users/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
confirmation,
|
||||
transferOptions,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||
return Fetch(`/backup/create`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
encryption_key: encryptionKey,
|
||||
include_assets: includeAssets,
|
||||
backup_name: backupName,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const RestoreBackup = (encryptionKey, backupData) => {
|
||||
return Fetch(`/backup/restore`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
encryption_key: encryptionKey,
|
||||
backup_data: backupData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
ChangePassword,
|
||||
CheckUserDeletion,
|
||||
ClearChoreTimer,
|
||||
CompleteSubTask,
|
||||
ConfirmMFA,
|
||||
CreateBackup,
|
||||
CreateChore,
|
||||
CreateLabel,
|
||||
CreateLongLiveToken,
|
||||
@@ -615,6 +662,7 @@ export {
|
||||
DeleteLongLiveToken,
|
||||
DeleteThing,
|
||||
DeleteTimeSession,
|
||||
DeleteUser,
|
||||
DisableMFA,
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
@@ -648,6 +696,7 @@ export {
|
||||
RegenerateBackupCodes,
|
||||
ResetChoreTimer,
|
||||
ResetPassword,
|
||||
RestoreBackup,
|
||||
SaveChore,
|
||||
SaveThing,
|
||||
SetupMFA,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||
import { SocialLogin } from '@capgo/capacitor-social-login'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
import AppleIcon from '@mui/icons-material/Apple'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -35,6 +37,7 @@ const LoginView = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
||||
const { data: resource } = useResource()
|
||||
const { showError } = useNotification()
|
||||
const Navigate = useNavigate()
|
||||
@@ -47,6 +50,21 @@ const LoginView = () => {
|
||||
mode: 'online', // replaces grantOfflineAccess
|
||||
},
|
||||
})
|
||||
|
||||
// Check if Apple Sign In is supported (iOS 13+)
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const deviceInfo = await Device.getInfo()
|
||||
if (deviceInfo.platform === 'ios') {
|
||||
const majorVersion = parseInt(deviceInfo.osVersion.split('.')[0])
|
||||
setIsAppleSignInSupported(majorVersion >= 13)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Could not determine device info for Apple Sign In support',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
initializeSocialLogin()
|
||||
}, [])
|
||||
@@ -127,6 +145,12 @@ const LoginView = () => {
|
||||
} else if (data['accessToken']) {
|
||||
// data["accessToken"] is for Google Capacitor
|
||||
return data['accessToken']['token']
|
||||
} else if (data['response'] && data['response']['id_token']) {
|
||||
// Apple Sign In returns id_token in response
|
||||
return data['response']['id_token']
|
||||
} else if (data['id_token']) {
|
||||
// Direct id_token for Apple
|
||||
return data['id_token']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +189,10 @@ const LoginView = () => {
|
||||
})
|
||||
}
|
||||
return response.json().then(() => {
|
||||
const providerName = provider === 'apple' ? 'Apple' : 'Google'
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: "Couldn't log in with Google, please try again",
|
||||
title: `${providerName} Login Failed`,
|
||||
message: `Couldn't log in with ${providerName}, please try again`,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -447,8 +472,50 @@ const LoginView = () => {
|
||||
</div>
|
||||
</Button>
|
||||
</LoginSocialGoogle>
|
||||
|
||||
{/* <Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button> */}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Button
|
||||
@@ -457,16 +524,6 @@ const LoginView = () => {
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
// GoogleAuth.initialize({
|
||||
// clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
// scopes: ['profile', 'email', 'openid'],
|
||||
// grantOfflineAccess: true,
|
||||
// })
|
||||
// GoogleAuth.signIn().then(user => {
|
||||
// console.log('Google user', user)
|
||||
// loggedWithProvider('google', user.authentication)
|
||||
// })
|
||||
|
||||
SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
@@ -481,6 +538,45 @@ const LoginView = () => {
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Apple Sign In Button for Native Platforms */}
|
||||
{isAppleSignInSupported && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -694,23 +694,17 @@ const ChoreView = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
{note !== null && (
|
||||
<Input
|
||||
fullWidth
|
||||
multiline
|
||||
label='Additional Notes'
|
||||
placeholder='Add any additional notes here...'
|
||||
value={note || ''}
|
||||
onChange={e => {
|
||||
if (e.target.value.trim() === '') {
|
||||
setNote(null)
|
||||
return
|
||||
}
|
||||
setNote(e.target.value)
|
||||
}}
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Additional Notes:
|
||||
</Typography>
|
||||
<RichTextEditor
|
||||
value={note || ''}
|
||||
onChange={setNote}
|
||||
entityType={'chore_completion_note'}
|
||||
placeholder='Add a note about the completion...'
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormControl size='sm'>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { Checklist, EventBusy, Group, Timelapse } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Analytics,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
Group,
|
||||
Star,
|
||||
Timelapse,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Container,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { LoadingScreen, SmoothCard } from '../../components/animations'
|
||||
import { LoadingScreen } from '../../components/animations'
|
||||
import {
|
||||
DeleteChoreHistory,
|
||||
GetAllCircleMembers,
|
||||
@@ -102,39 +109,38 @@ const ChoreHistory = () => {
|
||||
subtext: `${histories.length} times`,
|
||||
},
|
||||
{
|
||||
icon: <Timelapse />,
|
||||
text: 'Usually Within',
|
||||
icon: <TrendingUp />,
|
||||
text: 'Average Timing',
|
||||
subtext: moment.duration(averageDelayMoment).isValid()
|
||||
? moment.duration(averageDelayMoment).humanize()
|
||||
: '--',
|
||||
: 'On time',
|
||||
},
|
||||
{
|
||||
icon: <Timelapse />,
|
||||
text: 'Maximum Delay',
|
||||
subtext: moment.duration(maxDelayMoment).isValid()
|
||||
? moment.duration(maxDelayMoment).humanize()
|
||||
: '--',
|
||||
: 'Never late',
|
||||
},
|
||||
{
|
||||
icon: <Avatar />,
|
||||
text: ' Completed Most',
|
||||
icon: <Star />,
|
||||
text: 'Top Performer',
|
||||
subtext: `${
|
||||
performers.find(p => p.userId === Number(userCompletedByMost))
|
||||
?.displayName
|
||||
} `,
|
||||
?.displayName || 'Unknown'
|
||||
}`,
|
||||
},
|
||||
// contributes:
|
||||
{
|
||||
icon: <Group />,
|
||||
text: 'Total Performers',
|
||||
subtext: `${Object.keys(userHistories).length} users`,
|
||||
text: 'Team Members',
|
||||
subtext: `${Object.keys(userHistories).length} active`,
|
||||
},
|
||||
{
|
||||
icon: <Avatar />,
|
||||
text: 'Last Completed',
|
||||
icon: <Analytics />,
|
||||
text: 'Last Completed By',
|
||||
subtext: `${
|
||||
performers.find(p => p.userId === Number(histories[0].completedBy))
|
||||
?.displayName
|
||||
?.displayName || 'Unknown'
|
||||
}`,
|
||||
},
|
||||
]
|
||||
@@ -183,42 +189,67 @@ const ChoreHistory = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Typography level='title-md' mb={1.5}>
|
||||
Summary:
|
||||
</Typography>
|
||||
<Sheet
|
||||
// sx={{
|
||||
// mb: 1,
|
||||
// borderRadius: 'lg',
|
||||
// p: 2,
|
||||
// }}
|
||||
sx={{ borderRadius: 'sm', p: 2 }}
|
||||
variant='outlined'
|
||||
>
|
||||
<Grid container spacing={1}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{/* Statistics Cards Grid */}
|
||||
<Grid container spacing={1} sx={{ mb: 1 }}>
|
||||
{historyInfo.map((info, index) => (
|
||||
<Grid item xs={4} key={index}>
|
||||
{/* divider between the list items: */}
|
||||
|
||||
<ListItem key={index}>
|
||||
<ListItemContent>
|
||||
<Typography level='body-xs' sx={{ fontWeight: 'md' }}>
|
||||
{info.text}
|
||||
</Typography>
|
||||
<Chip color='primary' size='md' startDecorator={info.icon}>
|
||||
{info.subtext ? info.subtext : '--'}
|
||||
</Chip>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<Grid item xs={6} sm={6} key={index}>
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
boxShadow: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
minHeight: 90,
|
||||
height: '100%',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{info.icon}
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
ml: 1,
|
||||
fontWeight: '500',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{info.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
||||
>
|
||||
{info.subtext || '--'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
{/* User History Cards */}
|
||||
<Typography level='title-md' my={1.5}>
|
||||
History:
|
||||
</Typography>
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Completion History
|
||||
</Typography>
|
||||
</Box>
|
||||
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
|
||||
365
src/views/Modals/Inputs/UserDeletionModal.jsx
Normal file
365
src/views/Modals/Inputs/UserDeletionModal.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { data } from 'autoprefixer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import FadeModal from '../../../components/common/FadeModal'
|
||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||
|
||||
function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
const Navigate = useNavigate()
|
||||
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [transferOptions, setTransferOptions] = useState([])
|
||||
const [circlesRequiringTransfer, setCirclesRequiringTransfer] = useState([])
|
||||
const [availableMembers, setAvailableMembers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const resetModal = useCallback(() => {
|
||||
setStep(1)
|
||||
setPassword('')
|
||||
setConfirmation('')
|
||||
setTransferOptions([])
|
||||
setCirclesRequiringTransfer([])
|
||||
setAvailableMembers([])
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(
|
||||
success => {
|
||||
resetModal()
|
||||
onClose(success)
|
||||
},
|
||||
[onClose, resetModal],
|
||||
)
|
||||
|
||||
const checkDeletionRequirements = async () => {
|
||||
if (password.trim() === '') {
|
||||
setError('Please enter your password to continue')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await CheckUserDeletion(password)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
if (data.requiresTransfer && data.circles) {
|
||||
setCirclesRequiringTransfer(data.circles)
|
||||
setAvailableMembers(data.availableMembers || [])
|
||||
setStep(2)
|
||||
} else {
|
||||
setStep(3)
|
||||
}
|
||||
} else {
|
||||
setError(data.error || 'Failed to check deletion requirements')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(data.error || 'Failed to check deletion requirements')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransferSelection = (circleId, newOwnerId, newOwnerName) => {
|
||||
setTransferOptions(prev => {
|
||||
const existing = prev.find(t => t.circleId === circleId)
|
||||
if (existing) {
|
||||
return prev.map(t =>
|
||||
t.circleId === circleId ? { ...t, newOwnerId, newOwnerName } : t,
|
||||
)
|
||||
} else {
|
||||
return [...prev, { circleId, newOwnerId, newOwnerName }]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const proceedToConfirmation = () => {
|
||||
if (circlesRequiringTransfer.length === transferOptions.length) {
|
||||
setStep(3)
|
||||
}
|
||||
}
|
||||
|
||||
const executeUserDeletion = async () => {
|
||||
if (password.trim() === '' || confirmation !== 'DELETE') {
|
||||
setError('Please enter your password and type DELETE to confirm')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await DeleteUser(password, confirmation, transferOptions)
|
||||
const data = await response.json()
|
||||
console.log(response)
|
||||
|
||||
if (response.status === 200) {
|
||||
// Clear authentication tokens
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
Navigate('/login', { replace: true })
|
||||
handleClose(true)
|
||||
// Redirect to login or home page after successful deletion
|
||||
} else {
|
||||
setError(data.message || 'Failed to delete account')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!isOpen) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleClose(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, handleClose])
|
||||
|
||||
const renderWarningStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='danger'>
|
||||
Delete Account
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={2}>
|
||||
<strong>This action cannot be undone.</strong> Deleting your account
|
||||
will permanently remove:
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Your user profile and authentication data
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• All your chores, chore history, and time tracking sessions
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• API tokens, MFA sessions, and password reset tokens
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Storage files and usage data
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Points history and notifications
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Circle memberships and relationships
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<FormLabel>Enter your password to continue</FormLabel>
|
||||
<Input
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder='Enter your password'
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' mb={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={checkDeletionRequirements}
|
||||
loading={loading}
|
||||
disabled={!password}
|
||||
fullWidth
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderTransferStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='warning'>
|
||||
Circle Ownership Transfer Required
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={3}>
|
||||
You own circles that require ownership transfer before deletion. Please
|
||||
select new owners:
|
||||
</Typography>
|
||||
|
||||
{circlesRequiringTransfer.map(circle => (
|
||||
<Card key={circle.id} sx={{ mb: 2, p: 2 }}>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Circle: {circle.name}
|
||||
</Typography>
|
||||
<FormControl>
|
||||
<FormLabel>New Owner</FormLabel>
|
||||
<Select
|
||||
placeholder='Select new owner'
|
||||
value={
|
||||
transferOptions.find(t => t.circleId === circle.id)
|
||||
?.newOwnerId || ''
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
const member = availableMembers.find(m => m.id === value)
|
||||
if (member) {
|
||||
handleTransferSelection(circle.id, value, member.displayName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{availableMembers
|
||||
.filter(member => circle.members.includes(member.id))
|
||||
.map(member => (
|
||||
<Option key={member.id} value={member.id}>
|
||||
{member.displayName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={proceedToConfirmation}
|
||||
disabled={circlesRequiringTransfer.length !== transferOptions.length}
|
||||
fullWidth
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderConfirmationStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='danger'>
|
||||
Final Confirmation
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={3}>
|
||||
Please enter your password and type <strong>DELETE</strong> to confirm
|
||||
account deletion.
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={2}>
|
||||
on successful deletion, you will be logged out and redirected to the
|
||||
login page.
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<Input
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder='Enter your password'
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ mb: 3 }}>
|
||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
||||
<Input
|
||||
value={confirmation}
|
||||
onChange={e => setConfirmation(e.target.value)}
|
||||
placeholder='DELETE'
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' mb={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={executeUserDeletion}
|
||||
loading={loading}
|
||||
disabled={!password || confirmation !== 'DELETE'}
|
||||
fullWidth
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return renderWarningStep()
|
||||
case 2:
|
||||
return renderTransferStep()
|
||||
case 3:
|
||||
return renderConfirmationStep()
|
||||
default:
|
||||
return renderWarningStep()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeModal
|
||||
open={isOpen}
|
||||
onClose={() => handleClose(false)}
|
||||
size='md'
|
||||
unmountDelay={250}
|
||||
>
|
||||
{loading && step === 1 ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight={200}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
renderStep()
|
||||
)}
|
||||
</FadeModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserDeletionModal
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -14,9 +15,11 @@ import {
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Purchases } from '@revenuecat/purchases-capacitor'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import SubscriptionModal from '../../components/SubscriptionModal'
|
||||
import Logo from '../../Logo'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -26,7 +29,6 @@ import {
|
||||
DeleteCircleMember,
|
||||
GetAllCircleMembers,
|
||||
GetCircleMemberRequests,
|
||||
GetSubscriptionSession,
|
||||
GetUserCircle,
|
||||
JoinCircle,
|
||||
LeaveCircle,
|
||||
@@ -38,6 +40,7 @@ import { isPlusAccount } from '../../utils/Helpers'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
|
||||
import APITokenSettings from './APITokenSettings'
|
||||
import MFASettings from './MFASettings'
|
||||
import NotificationSetting from './NotificationSetting'
|
||||
@@ -58,6 +61,8 @@ const Settings = () => {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false)
|
||||
const [subscriptionModal, setSubscriptionModal] = useState(false)
|
||||
const [userDeletionModal, setUserDeletionModal] = useState(false)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
@@ -127,7 +132,7 @@ const Settings = () => {
|
||||
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
|
||||
userProfile?.expiration,
|
||||
).format('MMM DD, YYYY')}.`
|
||||
} else if (userProfile?.subscription === 'canceled') {
|
||||
} else if (userProfile?.subscription === 'cancelled') {
|
||||
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
|
||||
userProfile?.expiration,
|
||||
).format('MMM DD, YYYY')}.`
|
||||
@@ -138,7 +143,7 @@ const Settings = () => {
|
||||
const getSubscriptionStatus = () => {
|
||||
if (userProfile?.subscription === 'active') {
|
||||
return `Plus`
|
||||
} else if (userProfile?.subscription === 'canceled') {
|
||||
} else if (userProfile?.subscription === 'cancelled') {
|
||||
if (moment().isBefore(userProfile?.expiration)) {
|
||||
return `Plus(until ${moment(userProfile?.expiration).format(
|
||||
'MMM DD, YYYY',
|
||||
@@ -594,17 +599,45 @@ const Settings = () => {
|
||||
}}
|
||||
disabled={
|
||||
userProfile?.subscription === 'active' ||
|
||||
moment(userProfile?.expiration).isAfter(moment())
|
||||
(moment(userProfile?.expiration).isAfter(moment()) &&
|
||||
userProfile?.subscription !== 'cancelled')
|
||||
}
|
||||
onClick={() => {
|
||||
GetSubscriptionSession().then(data => {
|
||||
data.json().then(data => {
|
||||
console.log(data)
|
||||
window.location.href = data.sessionURL
|
||||
// open in new window:
|
||||
// window.open(data.sessionURL, '_blank')
|
||||
})
|
||||
})
|
||||
onClick={async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
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']) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'Purchase successful! Please restart the app to access Plus features.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== '1') {
|
||||
// User cancelled
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Purchase failed. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setSubscriptionModal(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
@@ -674,6 +707,23 @@ const Settings = () => {
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography level='title-md' mb={1} color='danger'>
|
||||
Danger Zone
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={2} color='neutral'>
|
||||
Once you delete your account, there is no going back. Please be
|
||||
certain.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => setUserDeletionModal(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
<NotificationSetting />
|
||||
<MFASettings />
|
||||
@@ -693,6 +743,25 @@ const Settings = () => {
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
|
||||
<SubscriptionModal
|
||||
open={subscriptionModal}
|
||||
onClose={() => setSubscriptionModal(false)}
|
||||
/>
|
||||
|
||||
<UserDeletionModal
|
||||
isOpen={userDeletionModal}
|
||||
onClose={success => {
|
||||
setUserDeletionModal(false)
|
||||
if (success) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Account deleted successfully',
|
||||
})
|
||||
}
|
||||
}}
|
||||
userProfile={userProfile}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { EventBusy, Schedule, TrendingUp } from '@mui/icons-material'
|
||||
import {
|
||||
Analytics,
|
||||
BarChart,
|
||||
CallReceived,
|
||||
EventBusy,
|
||||
Schedule,
|
||||
Speed,
|
||||
Timeline,
|
||||
TrendingUp,
|
||||
Update,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Container,
|
||||
Grid,
|
||||
@@ -10,8 +22,10 @@ import {
|
||||
ListDivider,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useTheme } from '@mui/joy/styles'
|
||||
import moment from 'moment'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -22,7 +36,6 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { useTheme } from '@mui/joy/styles'
|
||||
import { useThingHistory } from '../../queries/ThingQueries'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
@@ -41,6 +54,74 @@ const ThingsHistory = () => {
|
||||
// Flatten all pages of history data
|
||||
const thingsHistory = data?.pages.flatMap(page => page.res) || []
|
||||
|
||||
// Calculate analytics data
|
||||
const calculateAnalytics = () => {
|
||||
if (!thingsHistory.length) return []
|
||||
|
||||
// Calculate average update frequency
|
||||
let avgUpdateFrequency = '--'
|
||||
if (thingsHistory.length > 1) {
|
||||
const oldestUpdate = moment(
|
||||
thingsHistory[thingsHistory.length - 1].createdAt,
|
||||
)
|
||||
const newestUpdate = moment(thingsHistory[0].createdAt)
|
||||
const totalDuration = newestUpdate.diff(oldestUpdate, 'hours')
|
||||
const frequency = totalDuration / (thingsHistory.length - 1)
|
||||
avgUpdateFrequency =
|
||||
frequency < 1
|
||||
? `${Math.round(frequency * 60)} minutes`
|
||||
: frequency < 24
|
||||
? `${Math.round(frequency)} hours`
|
||||
: `${Math.round(frequency / 24)} days`
|
||||
}
|
||||
|
||||
const lastUpdated = thingsHistory[0]
|
||||
? moment(thingsHistory[0].updatedAt).fromNow()
|
||||
: '--'
|
||||
|
||||
// Calculate update trend value
|
||||
let updateTrend = '--'
|
||||
if (thingsHistory.length >= 3) {
|
||||
const diffs = thingsHistory
|
||||
.map((h, i, arr) =>
|
||||
i < arr.length - 1
|
||||
? moment(h.createdAt).diff(arr[i + 1].createdAt, 'minutes')
|
||||
: null,
|
||||
)
|
||||
.filter(d => d !== null)
|
||||
const last = diffs[0]
|
||||
const prev = diffs[1]
|
||||
if (last > prev) updateTrend = 'Interval increasing'
|
||||
else if (last < prev) updateTrend = 'Interval decreasing'
|
||||
else updateTrend = 'Interval stable'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <Speed />,
|
||||
text: 'Update Frequency',
|
||||
subtext: `Every ${avgUpdateFrequency}`,
|
||||
},
|
||||
{
|
||||
icon: <Update />,
|
||||
text: 'Last Updated',
|
||||
subtext: lastUpdated,
|
||||
},
|
||||
{
|
||||
icon: <CallReceived />,
|
||||
text: 'Last Value',
|
||||
subtext: thingsHistory[0]?.state ?? '--',
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
text: 'Update Trend',
|
||||
subtext: updateTrend,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const analyticsData = calculateAnalytics()
|
||||
|
||||
const handleLoadMore = () => {
|
||||
fetchNextPage()
|
||||
}
|
||||
@@ -106,71 +187,149 @@ const ThingsHistory = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Typography level='h3' mb={1.5}>
|
||||
History:
|
||||
</Typography>
|
||||
{/* Enhanced Analytics Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<BarChart sx={{ fontSize: '2rem', color: 'primary.500' }} />
|
||||
<Stack>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Things Details
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Quick overview of the thing's history and analytics
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Statistics Cards Grid */}
|
||||
<Grid container spacing={1} sx={{ mb: 1 }}>
|
||||
{analyticsData.map((info, index) => (
|
||||
<Grid xs={6} sm={6} key={index}>
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
boxShadow: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
minHeight: 90,
|
||||
height: '100%',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{info.icon}
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
ml: 1,
|
||||
fontWeight: '500',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{info.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
||||
>
|
||||
{info.subtext || '--'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Chart Section Header */}
|
||||
{thingsHistory.every(history => !isNaN(history.state)) &&
|
||||
thingsHistory.length > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Data Visualization
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* check if all the states are number the show it: */}
|
||||
{thingsHistory.every(history => !isNaN(history.state)) &&
|
||||
thingsHistory.length > 1 && (
|
||||
<>
|
||||
<Typography level='h4' gutterBottom>
|
||||
Chart:
|
||||
</Typography>
|
||||
<Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md', mb: 4 }}>
|
||||
<ResponsiveContainer width='100%' height={200}>
|
||||
<LineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={thingsHistory.toReversed()}
|
||||
>
|
||||
{/* <CartesianGrid strokeDasharray='3 3' /> */}
|
||||
<XAxis
|
||||
dataKey='updatedAt'
|
||||
hide='true'
|
||||
tick='false'
|
||||
tickLine='false'
|
||||
axisLine='false'
|
||||
tickFormatter={tick =>
|
||||
moment(tick).format('ddd MM/DD/yyyy HH:mm:ss')
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
hide='true'
|
||||
dataKey='state'
|
||||
tick='false'
|
||||
tickLine='true'
|
||||
axisLine='false'
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={label =>
|
||||
moment(label).format('ddd MM/DD/yyyy HH:mm:ss')
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md', mb: 2 }}>
|
||||
<ResponsiveContainer width='100%' height={200}>
|
||||
<LineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={thingsHistory.toReversed()}
|
||||
>
|
||||
{/* <CartesianGrid strokeDasharray='3 3' /> */}
|
||||
<XAxis
|
||||
dataKey='updatedAt'
|
||||
hide='true'
|
||||
tick='false'
|
||||
tickLine='false'
|
||||
axisLine='false'
|
||||
tickFormatter={tick =>
|
||||
moment(tick).format('ddd MM/DD/yyyy HH:mm:ss')
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
hide='true'
|
||||
dataKey='state'
|
||||
tick='false'
|
||||
tickLine='true'
|
||||
axisLine='false'
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={label =>
|
||||
moment(label).format('ddd MM/DD/yyyy HH:mm:ss')
|
||||
}
|
||||
/>
|
||||
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='state'
|
||||
stroke={theme.palette.primary[500]}
|
||||
activeDot={{
|
||||
r: 8,
|
||||
fill: theme.palette.primary[600],
|
||||
stroke: theme.palette.primary[300],
|
||||
}}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: theme.palette.primary[500],
|
||||
stroke: theme.palette.primary[300],
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='state'
|
||||
stroke={theme.palette.primary[500]}
|
||||
activeDot={{
|
||||
r: 8,
|
||||
fill: theme.palette.primary[600],
|
||||
stroke: theme.palette.primary[300],
|
||||
}}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: theme.palette.primary[500],
|
||||
stroke: theme.palette.primary[300],
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='h4' gutterBottom>
|
||||
Change log:
|
||||
</Typography>
|
||||
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Change History
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 'sm', p: 1, boxShadow: 'md' }}>
|
||||
<List sx={{ p: 0 }}>
|
||||
{thingsHistory.map((history, index) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CircleIcon from '@mui/icons-material/Circle'
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts'
|
||||
|
||||
import { EventBusy, Group, Toll } from '@mui/icons-material'
|
||||
import { EventBusy, Group, Timeline, Toll } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -94,9 +94,12 @@ const ChoreHistoryTimeline = ({ history }) => {
|
||||
|
||||
return (
|
||||
<Container sx={{ p: 2 }}>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
@@ -785,6 +788,7 @@ const UserActivites = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate activities analytics
|
||||
return (
|
||||
<Container
|
||||
maxWidth='lg'
|
||||
@@ -794,16 +798,6 @@ const UserActivites = () => {
|
||||
px: { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
mb={3}
|
||||
level='h4'
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
Activities Overview
|
||||
</Typography>
|
||||
|
||||
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}
|
||||
<Box
|
||||
sx={{
|
||||
@@ -1040,24 +1034,17 @@ const UserActivites = () => {
|
||||
<Card
|
||||
variant='plain'
|
||||
sx={{
|
||||
// maxHeight: { lg: '90vh' },
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
mr: 10,
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
width: { xs: '100%', lg: '315px' },
|
||||
mr: { xs: 0, lg: 10 },
|
||||
mb: 1,
|
||||
}}
|
||||
// variant='outlined'
|
||||
// sx={{
|
||||
// p: 2,
|
||||
// borderRadius: 12,
|
||||
// backdropFilter: 'blur(10px)',
|
||||
// }}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Main Chart */}
|
||||
|
||||
@@ -7,14 +7,29 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import { CreditCard, Toll } from '@mui/icons-material'
|
||||
import {
|
||||
AccountBalanceWallet,
|
||||
Analytics,
|
||||
CreditCard,
|
||||
EmojiEvents,
|
||||
MilitaryTech,
|
||||
Redeem,
|
||||
Star,
|
||||
SwapHoriz,
|
||||
Timeline,
|
||||
Toll,
|
||||
TrendingUp,
|
||||
WorkspacePremium,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Container,
|
||||
Grid,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -34,6 +49,7 @@ import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
||||
const UserPoints = () => {
|
||||
const [tabValue, setTabValue] = useState(7)
|
||||
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
|
||||
const [leaderboardMode, setLeaderboardMode] = useState('points') // 'points' or 'tasks'
|
||||
|
||||
const {
|
||||
data: circleMembersData,
|
||||
@@ -206,24 +222,371 @@ const UserPoints = () => {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
// Calculate leaderboard data for the current time period
|
||||
const calculateLeaderboard = () => {
|
||||
if (!choresHistoryData || !circleUsers.length) return []
|
||||
|
||||
// Calculate points for each user in the current time period
|
||||
const userPeriodStats = {}
|
||||
|
||||
// Initialize stats for all users
|
||||
circleUsers.forEach(user => {
|
||||
userPeriodStats[user.userId] = {
|
||||
userId: user.userId,
|
||||
displayName: user.displayName,
|
||||
image: user.image,
|
||||
totalPoints: user.points || 0,
|
||||
availablePoints: (user.points || 0) - (user.pointsRedeemed || 0),
|
||||
periodPoints: 0,
|
||||
periodTasks: 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate period-specific stats from history
|
||||
choresHistoryData.forEach(historyEntry => {
|
||||
const userId = historyEntry.completedBy
|
||||
if (userPeriodStats[userId]) {
|
||||
userPeriodStats[userId].periodPoints += historyEntry.points || 0
|
||||
userPeriodStats[userId].periodTasks += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to array and sort by selected mode
|
||||
const sortField =
|
||||
leaderboardMode === 'points' ? 'periodPoints' : 'periodTasks'
|
||||
return Object.values(userPeriodStats)
|
||||
.sort((a, b) => b[sortField] - a[sortField])
|
||||
.map((user, index) => ({
|
||||
...user,
|
||||
rank: index + 1,
|
||||
avgPointsPerTask:
|
||||
user.periodTasks > 0
|
||||
? (user.periodPoints / user.periodTasks).toFixed(1)
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const leaderboardData = calculateLeaderboard()
|
||||
|
||||
// Get trophy icons for top 3
|
||||
const getTrophyIcon = rank => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return <EmojiEvents sx={{ color: '#FFD700', fontSize: '1.2rem' }} /> // Gold
|
||||
case 2:
|
||||
return (
|
||||
<WorkspacePremium sx={{ color: '#C0C0C0', fontSize: '1.2rem' }} />
|
||||
) // Silver
|
||||
case 3:
|
||||
return <MilitaryTech sx={{ color: '#CD7F32', fontSize: '1.2rem' }} /> // Bronze
|
||||
default:
|
||||
return <Star sx={{ color: 'text.secondary', fontSize: '1rem' }} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth='xl'
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
px: { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
mb={3}
|
||||
level='h4'
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
Points Overview
|
||||
</Typography>
|
||||
{/* Enhanced Leaderboard Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack spacing={2}>
|
||||
{/* Title Row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} />
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
{leaderboardMode === 'points' ? 'Points' : 'Tasks'} Leaderboard
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Rankings based on{' '}
|
||||
{leaderboardMode === 'points'
|
||||
? 'points earned'
|
||||
: 'tasks completed'}{' '}
|
||||
during the selected time period
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Filters Row - Responsive */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 2,
|
||||
alignItems: { xs: 'stretch', sm: 'center' },
|
||||
justifyContent: { xs: 'flex-start', sm: 'space-between' },
|
||||
}}
|
||||
>
|
||||
{/* Time Period Filter */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
onChange={(e, tabValue) => {
|
||||
setTabValue(tabValue)
|
||||
handleChoresHistoryLimitChange(tabValue)
|
||||
}}
|
||||
value={tabValue}
|
||||
size='sm'
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
disableUnderline
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'transparent',
|
||||
p: 0.3,
|
||||
gap: 0.3,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7D', value: 7 },
|
||||
{ label: '6M', value: 6 * 30 },
|
||||
{ label: 'All', value: 24 * 30 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
minWidth: 'auto',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
fontSize: 'xs',
|
||||
fontWeight: 500,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.plainColor',
|
||||
backgroundColor: 'primary.softBg',
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
},
|
||||
}}
|
||||
disableIndicator
|
||||
value={tab.value}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Toggle between points and tasks */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: { xs: 'center', sm: 'flex-end' },
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
variant={leaderboardMode === 'points' ? 'solid' : 'outlined'}
|
||||
color='primary'
|
||||
size='sm'
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => setLeaderboardMode('points')}
|
||||
>
|
||||
Points
|
||||
</Chip>
|
||||
<SwapHoriz
|
||||
sx={{ fontSize: '0.875rem', color: 'text.tertiary' }}
|
||||
/>
|
||||
<Chip
|
||||
variant={leaderboardMode === 'tasks' ? 'solid' : 'outlined'}
|
||||
color='primary'
|
||||
size='sm'
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => setLeaderboardMode('tasks')}
|
||||
>
|
||||
Tasks
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Leaderboard Cards */}
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{ borderRadius: 'lg', overflow: 'hidden' }}
|
||||
>
|
||||
<Stack spacing={0}>
|
||||
{leaderboardData.map((user, index) => (
|
||||
<Box key={user.userId}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 2.5,
|
||||
backgroundColor:
|
||||
user.userId === userProfile.id
|
||||
? 'primary.softBg'
|
||||
: 'transparent',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
user.userId === userProfile.id
|
||||
? 'primary.softHoverBg'
|
||||
: 'neutral.softHoverBg',
|
||||
},
|
||||
cursor:
|
||||
user.userId === selectedUser ? 'default' : 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (user.userId !== selectedUser) {
|
||||
setSelectedUser(user.userId)
|
||||
setSelectedHistory(
|
||||
generateWeeklySummary(choresHistoryData, user.userId),
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Rank Badge */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 40,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{getTrophyIcon(user.rank)}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
fontWeight: user.rank <= 3 ? 'bold' : 'normal',
|
||||
color:
|
||||
user.rank <= 3 ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
#{user.rank}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* User Avatar and Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<Avatar
|
||||
src={user.image ? resolvePhotoURL(user.image) : undefined}
|
||||
sx={{ width: 40, height: 40, mr: 2 }}
|
||||
>
|
||||
{user.displayName?.charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
fontWeight:
|
||||
user.userId === userProfile.id ? 'bold' : 'normal',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{user.displayName}
|
||||
{user.userId === userProfile.id && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
You
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
{user.periodTasks} tasks • {user.avgPointsPerTask} avg
|
||||
per task
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Metric Display */}
|
||||
<Stack alignItems='flex-end' spacing={0.5}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
<Toll sx={{ fontSize: '1rem', color: 'success.500' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: 'success.600',
|
||||
}}
|
||||
>
|
||||
{leaderboardMode === 'points'
|
||||
? user.periodPoints
|
||||
: user.periodTasks}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{leaderboardMode === 'points'
|
||||
? `${user.availablePoints} available`
|
||||
: `${user.periodPoints} points`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{user.userId === selectedUser && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
backgroundColor: 'primary.500',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{index < leaderboardData.length - 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
height: 1,
|
||||
backgroundColor: 'divider',
|
||||
mx: 2.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Filters Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Filter & Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Improved Filter Bar */}
|
||||
<Card
|
||||
@@ -397,6 +760,107 @@ const UserPoints = () => {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Points Status Cards */}
|
||||
<Grid container spacing={1} sx={{ mb: 3 }}>
|
||||
{(() => {
|
||||
const selectedUserData = circleUsers.find(
|
||||
user => user.userId === selectedUser,
|
||||
)
|
||||
const totalPoints = selectedUserData?.points || 0
|
||||
const redeemedPoints = selectedUserData?.pointsRedeemed || 0
|
||||
const availablePoints = totalPoints - redeemedPoints
|
||||
|
||||
const periodStats = leaderboardData.find(
|
||||
user => user.userId === selectedUser,
|
||||
)
|
||||
const periodPoints = selectedHistory.reduce(
|
||||
(sum, item) => sum + (item.points || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const pointsCards = [
|
||||
{
|
||||
icon: <AccountBalanceWallet />,
|
||||
title: 'Total',
|
||||
text: `${totalPoints} points`,
|
||||
subtext: 'All time earned',
|
||||
},
|
||||
{
|
||||
icon: <Toll />,
|
||||
title: 'Available',
|
||||
text: `${availablePoints} points`,
|
||||
subtext: 'Ready to redeem',
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
title: 'Period Points',
|
||||
text: `${periodPoints} points`,
|
||||
subtext: `${tabValue === 24 * 30 ? 'All time' : tabValue === 6 * 30 ? 'Last 6 months' : `Last ${tabValue} days`}`,
|
||||
},
|
||||
{
|
||||
icon: <Redeem />,
|
||||
title: 'Redeemed',
|
||||
text: `${redeemedPoints} points`,
|
||||
subtext: 'Previously used',
|
||||
},
|
||||
]
|
||||
|
||||
return pointsCards.map((card, index) => (
|
||||
<Grid item xs={6} sm={6} key={index}>
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
boxShadow: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
minHeight: 90,
|
||||
height: '100%',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{card.icon}
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
ml: 1,
|
||||
fontWeight: '500',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
||||
>
|
||||
{card.text}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
||||
>
|
||||
{card.subtext}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))
|
||||
})()}
|
||||
</Grid>
|
||||
|
||||
{/* Current Filter Summary */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
@@ -430,107 +894,15 @@ const UserPoints = () => {
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{/* Points Cards */}
|
||||
<Box
|
||||
sx={{
|
||||
// resposive width based on parent available space:
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-evenly',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
title: 'Total',
|
||||
value: circleMembersData.res.find(
|
||||
user => user.userId === selectedUser,
|
||||
)?.points,
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: 'Available',
|
||||
value: (function () {
|
||||
const user = circleMembersData.res.find(
|
||||
user => user.userId === selectedUser,
|
||||
)
|
||||
if (!user) return 0
|
||||
return user.points - user.pointsRedeemed
|
||||
})(),
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: 'Redeemed',
|
||||
value: circleMembersData.res.find(
|
||||
user => user.userId === selectedUser,
|
||||
)?.pointsRedeemed,
|
||||
color: 'warning',
|
||||
},
|
||||
].map(card => (
|
||||
<Card
|
||||
key={card.title}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 1,
|
||||
minWidth: 80,
|
||||
width: '100%',
|
||||
}}
|
||||
variant='soft'
|
||||
>
|
||||
<Typography level='body-xs' textAlign='center' mb={-1}>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography level='title-md' textAlign='center'>
|
||||
{card.value}
|
||||
</Typography>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Points History Section */}
|
||||
<Typography level='h4' sx={{ mt: 2, mb: 2 }}>
|
||||
Points History
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
// resposive width based on parent available space:
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'left',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
title: 'Points',
|
||||
value: selectedHistory.reduce((acc, cur) => acc + cur.points, 0),
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: 'Tasks',
|
||||
value: selectedHistory.reduce((acc, cur) => acc + cur.tasks, 0),
|
||||
color: 'primary',
|
||||
},
|
||||
].map(card => (
|
||||
<Card
|
||||
key={card.title}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 1,
|
||||
width: 250,
|
||||
}}
|
||||
variant='soft'
|
||||
>
|
||||
<Typography level='body-xs' textAlign='center' mb={-1}>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography level='title-md' textAlign='center'>
|
||||
{card.value}
|
||||
</Typography>
|
||||
</Card>
|
||||
))}
|
||||
{/* Chart Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Points Trend
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Bar Chart for points overtime */}
|
||||
|
||||
Reference in New Issue
Block a user