diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index c35dddf..ca0c512 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,15 +9,19 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-community-sqlite') implementation project(':capacitor-app') implementation project(':capacitor-device') implementation project(':capacitor-local-notifications') + implementation project(':capacitor-network') implementation project(':capacitor-preferences') implementation project(':capacitor-push-notifications') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-social-login') + implementation project(':revenuecat-purchases-capacitor') + implementation project(':revenuecat-purchases-capacitor-ui') implementation project(':capacitor-plugin-safe-area') - + implementation "com.android.billingclient:billing:7.1.1" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ad421fd..8dc3eb2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ + + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index f4f93ae..6b1f767 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,6 +2,9 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-community-sqlite' +project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android') + include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') @@ -11,6 +14,9 @@ project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/d include ':capacitor-local-notifications' project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') +include ':capacitor-network' +project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') + include ':capacitor-preferences' project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') @@ -23,5 +29,11 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit include ':capgo-capacitor-social-login' project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android') +include ':revenuecat-purchases-capacitor' +project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android') + +include ':revenuecat-purchases-capacitor-ui' +project(':revenuecat-purchases-capacitor-ui').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor-ui/android') + include ':capacitor-plugin-safe-area' project(':capacitor-plugin-safe-area').projectDir = new File('../node_modules/capacitor-plugin-safe-area/android') diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 0000000..766028d --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + DoneTick + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + + NSCameraUsageDescription + This app needs access to camera to take photos for your profile picture. + NSPhotoLibraryUsageDescription + This app needs access to photo library to select photos for your profile picture. + NSPhotoLibraryAddUsageDescription + This app needs access to save photos to your photo library. + + \ No newline at end of file diff --git a/ios/App/Podfile b/ios/App/Podfile index 67db58d..57c664f 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -1,6 +1,6 @@ require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' -platform :ios, '13.0' +platform :ios, '15.0' use_frameworks! # workaround to avoid Xcode caching of Pods that requires @@ -11,14 +11,19 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device' pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' + pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences' pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapgoCapacitorSocialLogin', :path => '../../node_modules/@capgo/capacitor-social-login' + pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/@revenuecat/purchases-capacitor' + pod 'RevenuecatPurchasesCapacitorUi', :path => '../../node_modules/@revenuecat/purchases-capacitor-ui' pod 'CapacitorPluginSafeArea', :path => '../../node_modules/capacitor-plugin-safe-area' + pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' end target 'App' do diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 5ddce35..55ad0e8 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -10,15 +10,15 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - Capacitor (7.2.0): + - Capacitor (7.4.2): - CapacitorCordova - CapacitorApp (7.0.1): - Capacitor - - CapacitorCommunitySqlite (7.0.0): + - CapacitorCommunitySqlite (7.0.1): - Capacitor - SQLCipher - ZIPFoundation - - CapacitorCordova (7.2.0) + - CapacitorCordova (7.4.2) - CapacitorDevice (7.0.1): - Capacitor - CapacitorLocalNotifications (7.0.1): @@ -39,6 +39,8 @@ PODS: - FBSDKCoreKit (= 17.4.0) - FBSDKLoginKit (= 17.4.0) - GoogleSignIn (~> 8.0.0) + - CordovaPlugins (7.4.2): + - CapacitorCordova - FBAEMKit (17.4.0): - FBSDKCoreKit_Basics (= 17.4.0) - FBSDKCoreKit (17.4.0): @@ -66,6 +68,20 @@ PODS: - GTMSessionFetcher/Core (< 4.0, >= 3.3) - GTMSessionFetcher/Core (3.5.0) - PromisesObjC (2.4.0) + - PurchasesHybridCommon (16.1.0): + - RevenueCat (= 5.33.1) + - PurchasesHybridCommonUI (16.1.0): + - PurchasesHybridCommon (= 16.1.0) + - RevenueCatUI (= 5.33.1) + - RevenueCat (5.33.1) + - RevenuecatPurchasesCapacitor (11.1.0): + - Capacitor + - PurchasesHybridCommon (= 16.1.0) + - RevenuecatPurchasesCapacitorUi (11.1.0): + - Capacitor + - PurchasesHybridCommonUI (= 16.1.0) + - RevenueCatUI (5.33.1): + - RevenueCat (= 5.33.1) - SQLCipher (4.7.0): - SQLCipher/standard (= 4.7.0) - SQLCipher/common (4.7.0) @@ -76,6 +92,7 @@ PODS: DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)" + - "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorDevice (from `../../node_modules/@capacitor/device`)" - "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)" @@ -85,6 +102,9 @@ DEPENDENCIES: - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)" + - CordovaPlugins (from `../capacitor-cordova-ios-plugins`) + - "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)" + - "RevenuecatPurchasesCapacitorUi (from `../../node_modules/@revenuecat/purchases-capacitor-ui`)" SPEC REPOS: trunk: @@ -100,6 +120,10 @@ SPEC REPOS: - GTMAppAuth - GTMSessionFetcher - PromisesObjC + - PurchasesHybridCommon + - PurchasesHybridCommonUI + - RevenueCat + - RevenueCatUI - SQLCipher - ZIPFoundation @@ -108,6 +132,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/ios" CapacitorApp: :path: "../../node_modules/@capacitor/app" + CapacitorCommunitySqlite: + :path: "../../node_modules/@capacitor-community/sqlite" CapacitorCordova: :path: "../../node_modules/@capacitor/ios" CapacitorDevice: @@ -126,15 +152,21 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/status-bar" CapgoCapacitorSocialLogin: :path: "../../node_modules/@capgo/capacitor-social-login" + CordovaPlugins: + :path: "../capacitor-cordova-ios-plugins" + RevenuecatPurchasesCapacitor: + :path: "../../node_modules/@revenuecat/purchases-capacitor" + RevenuecatPurchasesCapacitorUi: + :path: "../../node_modules/@revenuecat/purchases-capacitor-ui" SPEC CHECKSUMS: Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7 + Capacitor: 9d9e481b79ffaeacaf7a85d6a11adec32bd33b59 CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9 - CapacitorCommunitySqlite: b11e556cf5d149f9e0b6fbbb47959a6cd718c3d0 - CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f + CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b + CapacitorCordova: 5e58d04631bc5094894ac106e2bf1da18a9e6151 CapacitorDevice: c6f6d587dd310527f8a48bf09c4e7b4a4cf14329 CapacitorLocalNotifications: c2212755b33d2513b8bb325096ce3a64d4039ae3 CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8 @@ -143,6 +175,7 @@ SPEC CHECKSUMS: CapacitorPushNotifications: 6a2794788c583dd89215f1805ca4bced1b13dbdf CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f CapgoCapacitorSocialLogin: 0daa087f32775c656fbfba09b8eae7c089b85ce2 + CordovaPlugins: b2618e17f8dd580d2354710b7b52b0af9d6f03f2 FBAEMKit: 58cb5f302cdd715a56d4c1d0dfdd2e423ac1421a FBSDKCoreKit: 94d7461d0cecf441b1ba7c41acfff41daa8ccd41 FBSDKCoreKit_Basics: 151b43db8b834d3f0e02f95d36a44ffd36265e45 @@ -152,9 +185,15 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PurchasesHybridCommon: acbd336f8248da5599893ecf9ac32c561a2fd446 + PurchasesHybridCommonUI: cf20e6ac6f148385bdb6a15db38c4c0c7377f480 + RevenueCat: b0ed01125b05a45b8264a2951ad68acb61942038 + RevenuecatPurchasesCapacitor: 554b5d4711cac8df01feed6333b075296b5d9082 + RevenuecatPurchasesCapacitorUi: 7863f7e39e940d937925b085a67369feecb048e4 + RevenueCatUI: e6fad6a8bec67d910da78cd171328f1104afea77 SQLCipher: ba9d0076041ed767c5bd3d3f77098318d04a403c ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 54ec5ec72c1c5e3e8d03bd96cc1ec0deaf577d9e +PODFILE CHECKSUM: 8a9fb4ff57aa677972b999357aa5abe97d1f2b30 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/package.json b/package.json index 094d40a..d308fca 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,17 @@ "@mui/joy": "^5.0.0-beta.20", "@mui/material": "^5.15.2", "@openreplay/tracker": "^14.0.4", + "@revenuecat/purchases-capacitor": "^11.1.0", + "@revenuecat/purchases-capacitor-ui": "^11.1.0", + "@rollup/rollup-darwin-arm64": "^4.46.1", "@swc/core": "^1.12.5", + "@swc/core-darwin-arm64": "^1.13.3", "@tanstack/react-query": "^5.17.0", "aos": "^2.3.4", "browser-image-compression": "^2.0.2", "capacitor-plugin-safe-area": "^4.0.0", "chrono-node": "^2.7.7", + "cordova-plugin-purchase": "^13.12.1", "dotenv": "^16.4.5", "esm": "^3.2.25", "event-source-polyfill": "^1.0.31", diff --git a/src/components/SubscriptionModal.jsx b/src/components/SubscriptionModal.jsx new file mode 100644 index 0000000..43bb2d3 --- /dev/null +++ b/src/components/SubscriptionModal.jsx @@ -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 ( + + + + {/* Header */} + + + Upgrade to Plus + + + + {/* Features List */} + + + What's included: + + + {features.map((feature, index) => ( + + + {feature} + + ))} + + + + + {/* Plan Selection */} + + {Object.entries(plans).map(([key, plan]) => ( + 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', + }} + > + + setSelectedPlan(key)} + value={key} + name='subscription-plan' + color='primary' + sx={{ mr: 1 }} + /> + + {key.charAt(0).toUpperCase() + key.slice(1)} + + + {plan.price} + + {' '} + / {plan.period} + + + + + {plan.popular && ( + } + sx={{ + fontWeight: 600, + fontSize: 12, + px: 1, + py: 0.1, + boxShadow: 2, + mt: 0.8, + }} + > + Most Popular + + )} + {plan.savings && ( + + {plan.savings} + + )} + + + ))} + + + {/* Action Buttons */} + + + + + + {/* Footer */} + + Cancel anytime. No hidden fees. Secure payment powered by Stripe. + + + + + ) +} + +export default SubscriptionModal diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx index 987d830..4e93d73 100644 --- a/src/utils/Colors.jsx +++ b/src/utils/Colors.jsx @@ -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 = { diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index ca4d8eb..149ba7e 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -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, diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 36e1655..da5f8ff 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -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 = () => { + + {/* */} )} + {Capacitor.isNativePlatform() && ( + + {/* Apple Sign In Button for Native Platforms */} + {isAppleSignInSupported && ( + + )} )} diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 7a7f89c..abe83a2 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -694,23 +694,17 @@ const ChoreView = () => { /> {note !== null && ( - { - if (e.target.value.trim() === '') { - setNote(null) - return - } - setNote(e.target.value) - }} - sx={{ - mb: 1, - }} - /> + + + Additional Notes: + + + )} diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 3259a30..2bc3dbf 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -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: , - text: 'Usually Within', + icon: , + text: 'Average Timing', subtext: moment.duration(averageDelayMoment).isValid() ? moment.duration(averageDelayMoment).humanize() - : '--', + : 'On time', }, { icon: , text: 'Maximum Delay', subtext: moment.duration(maxDelayMoment).isValid() ? moment.duration(maxDelayMoment).humanize() - : '--', + : 'Never late', }, { - icon: , - text: ' Completed Most', + icon: , + text: 'Top Performer', subtext: `${ performers.find(p => p.userId === Number(userCompletedByMost)) - ?.displayName - } `, + ?.displayName || 'Unknown' + }`, }, - // contributes: { icon: , - text: 'Total Performers', - subtext: `${Object.keys(userHistories).length} users`, + text: 'Team Members', + subtext: `${Object.keys(userHistories).length} active`, }, { - icon: , - text: 'Last Completed', + icon: , + text: 'Last Completed By', subtext: `${ performers.find(p => p.userId === Number(histories[0].completedBy)) - ?.displayName + ?.displayName || 'Unknown' }`, }, ] @@ -183,42 +189,67 @@ const ChoreHistory = () => { return ( - - Summary: - - - + {/* Enhanced Header Section */} + + {/* Statistics Cards Grid */} + {historyInfo.map((info, index) => ( - - {/* divider between the list items: */} - - - - - {info.text} - - - {info.subtext ? info.subtext : '--'} - - - + + + + + {info.icon} + + {info.text} + + + + + {info.subtext || '--'} + + + + ))} - + - {/* User History Cards */} - - History: - + {/* History Section Header */} + + + + Completion History + + {/* Chore History List (Updated Style) */} diff --git a/src/views/Modals/Inputs/UserDeletionModal.jsx b/src/views/Modals/Inputs/UserDeletionModal.jsx new file mode 100644 index 0000000..b48e14f --- /dev/null +++ b/src/views/Modals/Inputs/UserDeletionModal.jsx @@ -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 = () => ( + <> + + Delete Account + + + + This action cannot be undone. Deleting your account + will permanently remove: + + + + + • Your user profile and authentication data + + + • All your chores, chore history, and time tracking sessions + + + • API tokens, MFA sessions, and password reset tokens + + + • Storage files and usage data + + + • Points history and notifications + + + • Circle memberships and relationships + + + + + Enter your password to continue + setPassword(e.target.value)} + placeholder='Enter your password' + /> + + + {error && ( + + {error} + + )} + + + + + + + ) + + const renderTransferStep = () => ( + <> + + Circle Ownership Transfer Required + + + + You own circles that require ownership transfer before deletion. Please + select new owners: + + + {circlesRequiringTransfer.map(circle => ( + + + Circle: {circle.name} + + + New Owner + + + + ))} + + + + + + + ) + + const renderConfirmationStep = () => ( + <> + + Final Confirmation + + + + Please enter your password and type DELETE to confirm + account deletion. + + + on successful deletion, you will be logged out and redirected to the + login page. + + + + Password + setPassword(e.target.value)} + placeholder='Enter your password' + /> + + + + Type "DELETE" to confirm + setConfirmation(e.target.value)} + placeholder='DELETE' + /> + + + {error && ( + + {error} + + )} + + + + + + + ) + + const renderStep = () => { + switch (step) { + case 1: + return renderWarningStep() + case 2: + return renderTransferStep() + case 3: + return renderConfirmationStep() + default: + return renderWarningStep() + } + } + + return ( + handleClose(false)} + size='md' + unmountDelay={250} + > + {loading && step === 1 ? ( + + + + ) : ( + renderStep() + )} + + ) +} + +export default UserDeletionModal diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx index e41cb9c..fd37852 100644 --- a/src/views/Settings/Settings.jsx +++ b/src/views/Settings/Settings.jsx @@ -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} )} + + + + Danger Zone + + + Once you delete your account, there is no going back. Please be + certain. + + + @@ -693,6 +743,25 @@ const Settings = () => { {confirmModalConfig?.isOpen && ( )} + + setSubscriptionModal(false)} + /> + + { + setUserDeletionModal(false) + if (success) { + showNotification({ + type: 'success', + message: 'Account deleted successfully', + }) + } + }} + userProfile={userProfile} + /> ) } diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx index 12fb591..dfdf562 100644 --- a/src/views/Things/ThingsHistory.jsx +++ b/src/views/Things/ThingsHistory.jsx @@ -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: , + text: 'Update Frequency', + subtext: `Every ${avgUpdateFrequency}`, + }, + { + icon: , + text: 'Last Updated', + subtext: lastUpdated, + }, + { + icon: , + text: 'Last Value', + subtext: thingsHistory[0]?.state ?? '--', + }, + { + icon: , + text: 'Update Trend', + subtext: updateTrend, + }, + ] + } + + const analyticsData = calculateAnalytics() + const handleLoadMore = () => { fetchNextPage() } @@ -106,71 +187,149 @@ const ThingsHistory = () => { return ( - - History: - + {/* Enhanced Analytics Header Section */} + + + + + + Things Details + + + Quick overview of the thing's history and analytics + + + + + {/* Statistics Cards Grid */} + + {analyticsData.map((info, index) => ( + + + + + {info.icon} + + {info.text} + + + + + {info.subtext || '--'} + + + + + + ))} + + + + {/* Chart Section Header */} + {thingsHistory.every(history => !isNaN(history.state)) && + thingsHistory.length > 1 && ( + + + + Data Visualization + + + )} {/* check if all the states are number the show it: */} {thingsHistory.every(history => !isNaN(history.state)) && thingsHistory.length > 1 && ( - <> - - Chart: - + + + + {/* */} + + moment(tick).format('ddd MM/DD/yyyy HH:mm:ss') + } + /> + + + moment(label).format('ddd MM/DD/yyyy HH:mm:ss') + } + /> - - - - {/* */} - - moment(tick).format('ddd MM/DD/yyyy HH:mm:ss') - } - /> - - - moment(label).format('ddd MM/DD/yyyy HH:mm:ss') - } - /> - - - - - - + + + + )} - - Change log: - + + {/* History Section Header */} + + + + Change History + + {thingsHistory.map((history, index) => ( diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index 0d4d16c..90788cf 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -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 ( - - Activities Timeline - + + + + Activities Timeline + + {Object.entries(groupedHistory).map(([date, items]) => ( @@ -785,6 +788,7 @@ const UserActivites = () => { ) } + // Calculate activities analytics return ( { px: { xs: 2, sm: 3 }, }} > - - Activities Overview - - {/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */} { {/* Main Chart */} diff --git a/src/views/User/UserPoints.jsx b/src/views/User/UserPoints.jsx index cab716d..ba092ff 100644 --- a/src/views/User/UserPoints.jsx +++ b/src/views/User/UserPoints.jsx @@ -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 } + // 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 // Gold + case 2: + return ( + + ) // Silver + case 3: + return // Bronze + default: + return + } + } + return ( - - Points Overview - + {/* Enhanced Leaderboard Header Section */} + + + {/* Title Row */} + + + + + {leaderboardMode === 'points' ? 'Points' : 'Tasks'} Leaderboard + + + Rankings based on{' '} + {leaderboardMode === 'points' + ? 'points earned' + : 'tasks completed'}{' '} + during the selected time period + + + + + {/* Filters Row - Responsive */} + + {/* Time Period Filter */} + + { + setTabValue(tabValue) + handleChoresHistoryLimitChange(tabValue) + }} + value={tabValue} + size='sm' + sx={{ + borderRadius: 6, + backgroundColor: 'background.surface', + border: '1px solid', + borderColor: 'divider', + }} + > + + {[ + { label: '7D', value: 7 }, + { label: '6M', value: 6 * 30 }, + { label: 'All', value: 24 * 30 }, + ].map((tab, index) => ( + + {tab.label} + + ))} + + + + + {/* Toggle between points and tasks */} + + setLeaderboardMode('points')} + > + Points + + + setLeaderboardMode('tasks')} + > + Tasks + + + + + + {/* Leaderboard Cards */} + + + {leaderboardData.map((user, index) => ( + + { + if (user.userId !== selectedUser) { + setSelectedUser(user.userId) + setSelectedHistory( + generateWeeklySummary(choresHistoryData, user.userId), + ) + } + }} + > + {/* Rank Badge */} + + {getTrophyIcon(user.rank)} + + #{user.rank} + + + + {/* User Avatar and Info */} + + + {user.displayName?.charAt(0)} + + + + {user.displayName} + {user.userId === userProfile.id && ( + + You + + )} + + + {user.periodTasks} tasks • {user.avgPointsPerTask} avg + per task + + + + + {/* Metric Display */} + + + + + {leaderboardMode === 'points' + ? user.periodPoints + : user.periodTasks} + + + + {leaderboardMode === 'points' + ? `${user.availablePoints} available` + : `${user.periodPoints} points`} + + + + {/* Selection Indicator */} + {user.userId === selectedUser && ( + + )} + + {index < leaderboardData.length - 1 && ( + + )} + + ))} + + + + + {/* Filters Section Header */} + + + + Filter & Analysis + + {/* Improved Filter Bar */} { + {/* Points Status Cards */} + + {(() => { + 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: , + title: 'Total', + text: `${totalPoints} points`, + subtext: 'All time earned', + }, + { + icon: , + title: 'Available', + text: `${availablePoints} points`, + subtext: 'Ready to redeem', + }, + { + icon: , + title: 'Period Points', + text: `${periodPoints} points`, + subtext: `${tabValue === 24 * 30 ? 'All time' : tabValue === 6 * 30 ? 'Last 6 months' : `Last ${tabValue} days`}`, + }, + { + icon: , + title: 'Redeemed', + text: `${redeemedPoints} points`, + subtext: 'Previously used', + }, + ] + + return pointsCards.map((card, index) => ( + + + + + {card.icon} + + {card.title} + + + + + {card.text} + + + {card.subtext} + + + + + + )) + })()} + + {/* Current Filter Summary */} @@ -430,107 +894,15 @@ const UserPoints = () => { gap: 3, }} > - {/* Points Cards */} - - {[ - { - 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.title} - - - {card.value} - - - ))} - - - {/* Points History Section */} - - Points History - - - - {[ - { - 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.title} - - - {card.value} - - - ))} + {/* Chart Section Header */} + + + + Points Trend + {/* Bar Chart for points overtime */}