19 Commits

Author SHA1 Message Date
Mo Tarbin
c832e6af6c Merge branch 'in-app-review' of https://github.com/Donetick/frontend into in-app-review
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-02 16:29:06 -04:00
Mo Tarbin
fc7f46618f Add support for in app feedback 2026-08-02 16:27:24 -04:00
Mohamad Tarbin
b2949b2dd9 Enhance project management UI and handle bulk changes (#186)
* enable hold to select, remove once x1 chip, unify reschedule modal

* project title still show fromwhen login from different account
it's was cache in local storage so makdikngsure we do clean up

* Fix: show the button for creating task on the modal instead of the panel

* Bulk moving project, Move the button in scanner to the modal

* - Handle bulk change for projects
- allow no assignee being selected
- choreactionmenu become modal on mobile
2026-08-02 16:14:52 -04:00
Scott Anderson
9478f7d0f5 eslint@9, prettier (#105)
* Update build.yml to allow pushes to any branch

Remove branch restriction for push events, which prevents forked projects from running CI checks prior to creating a PR.

* eslint, prettier config only
2026-08-02 02:43:54 -04:00
Mohamad Tarbin
2e0ff5a10c Merge pull request #172 from scottanderson/patch-1
Update build.yml to allow pushes to any branch
2026-08-02 02:40:26 -04:00
Mohamad Tarbin
85826d8068 Merge pull request #141 from everysingletear/fix/available-languages
fix: sync AVAILABLE_LANGUAGES with shipped locales (add zh-CN; drop duplicate ja)
2026-07-31 01:55:40 -04:00
Mohamad Tarbin
9b0af72d74 Merge pull request #182 from donetick/issue-758
allow create task assigned to anyone
2026-07-30 20:30:48 -04:00
Mo Tarbin
eb088a259a allow create task assigned to anyone 2026-07-30 20:29:17 -04:00
Scott Anderson
dcd157274a Update build.yml to allow pushes to any branch
Remove branch restriction for push events, which prevents forked projects from running CI checks prior to creating a PR.
2026-07-30 16:54:09 -04:00
Mohamad Tarbin
19ed0ba3ab Merge pull request #180 from donetick/reshape-onboarding-v1
Reshape onboarding v1
2026-07-30 02:21:25 -04:00
Mo Tarbin
b8fe2833b6 allow numbers in username 2026-07-30 02:02:08 -04:00
Mo Tarbin
c39bbe5984 Remove Logo from sign up and login flow 2026-07-30 02:02:08 -04:00
Mo Tarbin
9950d8ff6c Remove Logo from sign up and login flow 2026-07-30 02:02:08 -04:00
Mo Tarbin
99b467488f revamp login and signup screens 2026-07-30 02:02:08 -04:00
Mohamad Tarbin
8dfaa4d8c6 Merge pull request #179 from donetick/revamp-modals
Revamp modals
2026-07-30 01:34:26 -04:00
Mo Tarbin
000e4d0d61 update Button Radius 2026-07-30 01:32:21 -04:00
Mo Tarbin
0132408eec Unify modals and buttons style and layout 2026-07-29 10:38:20 -04:00
Mo Tarbin
b2d4d90c9c Add support for in app feedback 2026-07-29 02:30:34 -04:00
everysingletear
f5da57d7f7 fix: sync AVAILABLE_LANGUAGES with shipped locales (add zh-CN; drop duplicate ja)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:58:48 +08:00
103 changed files with 8286 additions and 6492 deletions

View File

@@ -1,36 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:prettier/recommended',
'plugin:tailwindcss/recommended',
],
ignorePatterns: [
'dist',
'.eslintrc.cjs',
'tailwind.config.js',
'postcss.config.js',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: [
'react-refresh',
'simple-import-sort',
'sort-destructure-keys',
'sort-keys-fix',
'prettier',
'tailwindcss',
],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/prop-types': 'off',
},
}

View File

@@ -2,7 +2,6 @@ name: Build validation
on:
push:
branches: [main]
pull_request:
jobs:
@@ -19,3 +18,4 @@ jobs:
- name: Install dependencies
run: npm i
- run: npm run build
- run: npm run lint:ci

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
LICENSE.md
dist/

View File

@@ -1,13 +0,0 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"endOfLine": "auto",
"jsxBracketSameLine": false,
"printWidth": 80,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-in-app-review')
implementation project(':capacitor-community-speech-recognition')
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app')

View File

@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-in-app-review'
project(':capacitor-community-in-app-review').projectDir = new File('../node_modules/@capacitor-community/in-app-review/android')
include ':capacitor-community-speech-recognition'
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')

187
eslint.config.mjs Normal file
View File

@@ -0,0 +1,187 @@
import js from '@eslint/js'
import stylistic from '@stylistic/eslint-plugin'
import queryPlugin from '@tanstack/eslint-plugin-query'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import eslintConfigPrettier from 'eslint-config-prettier/flat'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import sortDestructureKeys from 'eslint-plugin-sort-destructure-keys'
import tailwind from 'eslint-plugin-tailwindcss'
import globals from 'globals'
export default [
{
ignores: ['dist'],
},
js.configs.recommended,
react.configs.flat.recommended,
{
...react.configs.flat['jsx-runtime'],
settings: {
react: {
version: 'detect',
},
},
},
reactHooks.configs.flat.recommended,
reactRefresh.configs.recommended,
{
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/exports': 'error',
'simple-import-sort/imports': 'error',
},
},
{
plugins: {
'sort-destructure-keys': sortDestructureKeys,
},
rules: {
'sort-destructure-keys/sort-destructure-keys': 'error',
},
},
...tailwind.configs['flat/recommended'],
...queryPlugin.configs['flat/recommended'],
eslintConfigPrettier, // Disable any rules that conflict with prettier
eslintPluginPrettierRecommended,
{
files: ['*.{js,ts}'],
languageOptions: {
globals: globals.node,
},
},
{
files: ['*.config.{mjs,ts}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
eqeqeq: 'error',
indent: ['error', 2],
'space-infix-ops': ['error', { int32Hint: false }],
},
},
{
files: ['src/**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
globals: globals.browser,
sourceType: 'module',
},
plugins: {
'@stylistic': stylistic,
},
rules: {
'@stylistic/brace-style': 'error',
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/keyword-spacing': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/semi': ['error', 'never'],
'@stylistic/space-in-parens': 'error',
'@stylistic/space-infix-ops': ['error', { int32Hint: false }],
'@stylistic/type-annotation-spacing': [
'error',
{
after: true,
before: true,
overrides: {
colon: {
before: false,
},
},
},
],
eqeqeq: 'warn',
'max-depth': ['error', { max: 5 }],
'max-len': ['warn', { code: 120 }],
'max-lines': [
'error',
{ max: 2000, skipBlankLines: true, skipComments: true },
],
'max-lines-per-function': ['warn', { max: 500 }],
'no-constant-condition': 'warn',
'no-loss-of-precision': 'error',
'no-undef': 'warn',
'no-unused-vars': 'off', // @typescript-eslint/no-unused-vars
'space-infix-ops': 'off', // @stylistic/space-infix-ops
},
},
{
files: ['*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: 'tsconfig.json',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/no-duplicate-enum-values': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-mixed-enums': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-type-alias': [
'error',
{
allowAliases: 'always',
allowCallbacks: 'always',
allowConditionalTypes: 'never',
allowConstructors: 'never',
allowGenerics: 'always',
allowLiterals: 'always',
allowMappedTypes: 'never',
allowTupleTypes: 'always',
},
],
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'error',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-literal-enum-member': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
},
},
{
// Reduce existing errors to warnings
plugins: {
'@tanstack/query': queryPlugin,
},
rules: {
'@tanstack/query/exhaustive-deps': 'warn',
'no-case-declarations': 'warn',
'no-empty': 'warn',
'no-redeclare': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/static-components': 'warn',
'react-refresh/only-export-components': 'warn',
'react/jsx-no-undef': 'warn',
'react/no-unescaped-entities': 'warn',
'react/prop-types': 'warn',
},
},
]

View File

@@ -11,6 +11,7 @@ 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 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'

View File

@@ -18,6 +18,8 @@ PODS:
- Capacitor
- CapacitorBrowser (8.0.3):
- Capacitor
- CapacitorCommunityInAppReview (8.0.0):
- Capacitor
- CapacitorCommunitySpeechRecognition (7.0.1):
- Capacitor
- CapacitorCommunitySqlite (8.1.0):
@@ -143,6 +145,7 @@ DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/app"
CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser"
CapacitorCommunityInAppReview:
:path: "../../node_modules/@capacitor-community/in-app-review"
CapacitorCommunitySpeechRecognition:
:path: "../../node_modules/@capacitor-community/speech-recognition"
CapacitorCommunitySqlite:
@@ -239,6 +244,7 @@ SPEC CHECKSUMS:
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
COCOAPODS: 1.16.2

4068
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,9 @@
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint && prettier -c .",
"lint:ci": "true # TODO: eslint -f gha && prettier -c .",
"lint:fix": "eslint --fix && prettier -w .",
"preview": "vite preview",
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
"setup-apple-silicon": "npm install --no-optional && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
@@ -39,6 +41,7 @@
"postinstall": "patch-package"
},
"dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",
@@ -71,8 +74,11 @@
"@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0",
"@swc/core": "^1.12.5",
"@tanstack/react-query": "^5.17.0",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"aos": "^2.3.4",
"browser-image-compression": "^2.0.2",
"caniuse-lite": "^1.0.30001769",
@@ -116,20 +122,20 @@
"autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.9.19",
"capacitor-set-version": "^2.2.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-sort-destructure-keys": "^1.5.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-tailwindcss": "^3.13.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-formatter-gha": "^2.0.1",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-sort-destructure-keys": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"husky": "^8.0.3",
"patch-package": "^8.0.1",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.5.10",
"tailwindcss": "^3.4.0",
"typescript": "^5.8.0",

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
tailwindcss: {},
},
}

13
prettier.config.mjs Normal file
View File

@@ -0,0 +1,13 @@
export default {
arrowParens: 'avoid',
bracketSpacing: true,
endOfLine: 'auto',
jsxBracketSameLine: false,
jsxSingleQuote: true,
plugins: ['prettier-plugin-tailwindcss'],
printWidth: 80,
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
}

View File

@@ -29,5 +29,51 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Developer Settings",
"description": "View technical information about authentication tokens, SSE connections, and debug data."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -1,4 +1,3 @@
/* eslint-env node */
export const API_URL =
import.meta.env.VITE_APP_API_URL === 'AUTO'
? `${window.location.hostname}/api`

View File

@@ -1,8 +1,8 @@
import LogoSVG from '@/assets/logo.svg'
const Logo = () => {
const Logo = ({ size = '128px' }) => {
return (
<div className='logo'>
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
<img src={LogoSVG} alt='logo' width={size} height={size} />
</div>
)
}

View File

@@ -1,16 +1,8 @@
import { Check, Star } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
import { useState } from 'react'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
}
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&apos;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>
))}
<AppModal
open={open}
onClose={onClose}
title='Upgrade to Plus'
description='Unlock reminders, rich task details, and advanced automation.'
size='lg'
closeOnBackdrop={!isLoading}
closeOnEscape={!isLoading}
footer={
<ModalActions
stackOnMobile
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
primary={{
label: 'Subscribe',
onClick: handleSubscribe,
loading: isLoading,
}}
/>
}
>
{/* Features List */}
<Box sx={{ mb: 2 }}>
<Typography level='title-lg' sx={{ mb: 2 }}>
What&apos;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>
<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>
</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
component='label'
htmlFor={`subscription-plan-${key}`}
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
id={`subscription-plan-${key}`}
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>
{/* Footer */}
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</AppModal>
)
}

View File

@@ -0,0 +1,230 @@
import { Close } from '@mui/icons-material'
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import useMediaQuery from '@mui/material/useMediaQuery'
import { forwardRef, useId } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const WIDTH_BY_SIZE = {
sm: 400,
md: 520,
lg: 680,
xl: 840,
}
/**
* The app's modal primitive. It owns modal layout, spacing, accessibility,
* responsive presentation, and motion so feature components only own content.
*/
const AppModal = forwardRef(
(
{
open,
onClose,
children,
title,
description,
footer,
size = 'md',
fullWidth = true,
isMobile: isMobileProp,
mobilePresentation = 'sheet',
role = 'dialog',
showCloseButton = true,
showHandle = false,
closeOnBackdrop = true,
closeOnEscape = true,
backdropBlur = true,
maxHeight = '90dvh',
contentSx,
footerSx,
sx,
unmountDelay = 180,
...modalProps
},
ref,
) => {
const generatedId = useId()
const detectedMobile = useMediaQuery('(max-width:768px)')
const isMobile = isMobileProp ?? detectedMobile
const titleId = title ? `${generatedId}-title` : undefined
const descriptionId = description ? `${generatedId}-description` : undefined
const isSheet = isMobile && mobilePresentation === 'sheet'
const isFullscreen = isMobile && mobilePresentation === 'fullscreen'
const handleClose = (event, reason) => {
if (reason === 'backdropClick' && !closeOnBackdrop) return
if (reason === 'escapeKeyDown' && !closeOnEscape) return
onClose?.(event, reason)
}
return (
<Modal
open={open}
onClose={handleClose}
aria-labelledby={titleId}
aria-describedby={descriptionId}
keepMounted
sx={{
zIndex: Z_INDEX.MODAL_BACKDROP,
display: 'flex',
alignItems: isSheet ? 'flex-end' : 'center',
justifyContent: 'center',
p: isMobile ? 0 : 2,
'& .MuiModal-backdrop': {
backgroundColor: 'rgba(8, 15, 24, 0.52)',
backdropFilter: backdropBlur ? 'blur(4px)' : 'none',
},
}}
{...modalProps}
>
<Sheet
ref={ref}
role={role}
aria-modal='true'
aria-labelledby={titleId}
aria-describedby={descriptionId}
variant='outlined'
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
display: 'flex',
flexDirection: 'column',
width: isFullscreen
? '100%'
: isSheet
? '100%'
: fullWidth
? `min(calc(100vw - 32px), ${WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md}px)`
: 'auto',
maxWidth: isMobile
? 'none'
: WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md,
height: isFullscreen ? '100dvh' : 'auto',
maxHeight: isFullscreen ? '100dvh' : maxHeight,
overflow: 'hidden',
borderRadius: isFullscreen ? 0 : isSheet ? '16px 16px 0 0' : '16px',
borderBottom: isSheet ? 0 : undefined,
boxShadow: 'lg',
outline: 0,
pb: isMobile ? 'env(safe-area-inset-bottom)' : 0,
animation: open
? `${isSheet ? 'appSheetEnter' : 'appModalEnter'} ${Math.min(unmountDelay, 300)}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
'@keyframes appModalEnter': {
from: { opacity: 0, transform: 'translateY(8px) scale(0.99)' },
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
},
'@keyframes appSheetEnter': {
from: { transform: 'translateY(24px)' },
to: { transform: 'translateY(0)' },
},
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
...sx,
}}
>
{isSheet && showHandle && (
<Box
aria-hidden='true'
sx={{ display: 'flex', justifyContent: 'center', pt: 1.25 }}
>
<Box
sx={{
width: 36,
height: 4,
borderRadius: 999,
bgcolor: 'neutral.300',
}}
/>
</Box>
)}
{(title || description || showCloseButton) && (
<Box
component='header'
sx={{
position: 'relative',
flexShrink: 0,
px: { xs: 2, sm: 3 },
pt: isSheet && showHandle ? 1.25 : { xs: 2, sm: 2.5 },
pb: description ? 1.5 : 2,
pr: showCloseButton ? { xs: 7, sm: 8 } : { xs: 2, sm: 3 },
}}
>
{title && (
<Typography
id={titleId}
level='title-lg'
sx={{ fontWeight: 650 }}
>
{title}
</Typography>
)}
{description && (
<Typography
id={descriptionId}
level='body-sm'
sx={{ color: 'text.tertiary', mt: 0.5 }}
>
{description}
</Typography>
)}
{showCloseButton && (
<IconButton
aria-label='Close dialog'
variant='plain'
color='neutral'
onClick={handleClose}
sx={{
position: 'absolute',
top: isSheet && showHandle ? 6 : 12,
right: { xs: 10, sm: 16 },
borderRadius: '50%',
}}
>
<Close />
</IconButton>
)}
</Box>
)}
<Box
sx={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overscrollBehavior: 'contain',
px: { xs: 2, sm: 3 },
pb: { xs: 2.5, sm: 3 },
...contentSx,
}}
>
{children}
</Box>
{footer && (
<>
<Divider />
<Box
component='footer'
sx={{
flexShrink: 0,
px: { xs: 2, sm: 3 },
py: 2,
bgcolor: 'background.surface',
...footerSx,
}}
>
{footer}
</Box>
</>
)}
</Sheet>
</Modal>
)
},
)
AppModal.displayName = 'AppModal'
export default AppModal

View File

@@ -1,254 +0,0 @@
import { Close } from '@mui/icons-material'
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import { forwardRef, useEffect, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BottomSheetModal = forwardRef(
(
{
open,
onClose,
children,
title,
footer,
height = 'auto',
maxHeight = '90vh',
expandedHeight = '95vh',
backdropBlur = true,
showHandle = true,
showCloseButton = true,
...props
},
ref,
) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const [internalOpen, setInternalOpen] = useState(open)
// Handle opening
useEffect(() => {
if (open) {
setInternalOpen(true)
setIsClosing(false)
}
}, [open])
// Handle closing with animation
useEffect(() => {
if (!open && internalOpen) {
setIsClosing(true)
// Wait for animation to complete before hiding modal
const timer = setTimeout(() => {
setInternalOpen(false)
setIsClosing(false)
setIsExpanded(false)
}, 250) // Match transition duration
return () => clearTimeout(timer)
}
}, [open, internalOpen])
// Handle toggle expansion
const handleToggleExpansion = () => {
setIsExpanded(prev => !prev)
}
// Close on escape key
useEffect(() => {
const handleEscape = event => {
if (event.key === 'Escape' && internalOpen) {
onClose?.()
}
}
if (internalOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll when modal is open
// document.body.style.overflow = 'hidden'
} else {
// Restore scroll immediately when modal starts closing
// document.body.style.overflow = 'unset'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [internalOpen, onClose])
// Calculate current height
const currentHeight = isExpanded ? expandedHeight : height
// Filter out DOM props that shouldn't be passed to Modal
const {
fullWidth: _fullWidth,
unmountDelay: _unmountDelay,
...modalProps
} = props
return (
<Modal
open={internalOpen}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
keepMounted
{...modalProps}
>
<Sheet
ref={ref}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minHeight: '20%',
width: '100%',
height: currentHeight,
maxHeight: isExpanded ? expandedHeight : maxHeight,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
p: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition:
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
transform:
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
// Handle safe area on mobile devices
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Header Section with drag handle, title, and close button */}
<div
style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: 'inherit',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
position: 'relative',
}}
>
{/* Close button positioned absolutely in top-right */}
{showCloseButton && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 16,
zIndex: 1,
borderRadius: '50%',
width: 32,
height: 32,
backgroundColor: 'neutral.softBg',
color: 'neutral.softColor',
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
transform: 'scale(1.05)',
},
transition: 'all 0.2s ease',
flexShrink: 0,
}}
>
<Close fontSize='small' />
</IconButton>
)}
{/* Drag Handle */}
{showHandle && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 0 8px 0',
cursor: 'pointer',
userSelect: 'none',
marginBottom: 16,
}}
onClick={handleToggleExpansion}
title={isExpanded ? 'Collapse' : 'Expand'}
>
<div
style={{
width: 30,
height: 4,
borderRadius: 2,
backgroundColor: 'var(--joy-palette-neutral-300)',
transition: 'background-color 0.2s ease',
}}
/>
</div>
)}
{/* Title Row */}
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: showHandle
? '0 20px 16px 20px'
: '16px 20px 16px 20px',
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
minHeight: 24,
}}
>
<Typography
level='title-lg'
sx={{
fontWeight: 600,
flex: 1,
}}
>
{title}
</Typography>
</div>
)}
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '0 20px 20px 20px',
minHeight: 0, // Important for flex child with overflow
}}
>
{children}
</div>
{footer && (
<>
<Divider />
<footer
style={{
flexShrink: 0,
// borderTop: '1px solid var(--joy-palette-divider)',
padding: '16px 20px',
}}
>
{footer}
</footer>
</>
)}
</Sheet>
</Modal>
)
},
)
BottomSheetModal.displayName = 'BottomSheetModal'
export default BottomSheetModal

View File

@@ -1,95 +0,0 @@
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
* FadeModal component with consistent fade-in/out animations
* Can be used as a drop-in replacement for Joy UI's Modal component
*/
const FadeModal = ({
open,
onClose,
children,
size = 'md',
fullWidth = true,
backdropBlur = true,
title,
footer,
...props
}) => {
// Filter out props that shouldn't be passed to Modal
const { unmountDelay: _unmountDelay, ...modalProps } = props
return (
<Modal
open={open}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
},
}}
keepMounted
// These transition properties create a smooth fade + slide effect
transition={{
mount: { opacity: 1, transform: 'translateY(0px)' },
unmount: { opacity: 0, transform: 'translateY(20px)' },
duration: 250, // Animation duration in ms
easing: {
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
},
}}
{...modalProps}
>
<ModalOverflow>
<ModalDialog
size={size}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minWidth: fullWidth ? '90%' : 'auto',
animation: open
? 'modalFadeIn 0.35s forwards'
: 'modalFadeOut 0.25s forwards',
'@keyframes modalFadeIn': {
from: { opacity: 0, transform: 'translateY(8px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
'@keyframes modalFadeOut': {
from: { opacity: 1, transform: 'translateY(0)' },
to: { opacity: 0, transform: 'translateY(8px)' },
},
// Add staggered animation for child elements
'& > *': {
opacity: 0,
animation: open
? 'contentFadeIn 0.35s forwards'
: 'contentFadeOut 0.2s forwards',
},
// Stagger child animations
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
'@keyframes contentFadeIn': {
to: { opacity: 1 },
},
'@keyframes contentFadeOut': {
to: { opacity: 0 },
},
}}
>
<ModalClose />
{title && (
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
{title}
</Typography>
)}
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{footer && <div style={{ marginTop: 16 }}>{footer}</div>}
</ModalDialog>
</ModalOverflow>
</Modal>
)
}
export default FadeModal

View File

@@ -10,7 +10,8 @@ import {
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
import AppModal from './AppModal'
import ModalActions from './ModalActions'
import ActiveFilterChips from './filter/ActiveFilterChips'
/**
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
label: 'Today',
getRange: () => {
const t = d(new Date())
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
return {
from: t.toISOString(),
to: d(new Date(), 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -49,8 +53,12 @@ const DATE_RANGE_PRESETS = [
label: 'Yesterday',
getRange: () => {
const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
const y = new Date(t)
y.setDate(t.getDate() - 1)
return {
from: d(y).toISOString(),
to: d(y, 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -58,9 +66,14 @@ const DATE_RANGE_PRESETS = [
label: 'This Week',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
const start = new Date(t)
start.setDate(t.getDate() - t.getDay())
const end = new Date(start)
end.setDate(start.getDate() + 6)
return {
from: d(start).toISOString(),
to: d(end, 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -68,8 +81,12 @@ const DATE_RANGE_PRESETS = [
label: 'Last 7 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
const start = new Date(t)
start.setDate(t.getDate() - 6)
return {
from: d(start).toISOString(),
to: d(new Date(), 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -79,7 +96,10 @@ const DATE_RANGE_PRESETS = [
const n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
return {
from: start.toISOString(),
to: d(end, 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -87,8 +107,12 @@ const DATE_RANGE_PRESETS = [
label: 'Last 30 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
const start = new Date(t)
start.setDate(t.getDate() - 29)
return {
from: d(start).toISOString(),
to: d(new Date(), 23, 59, 59, 999).toISOString(),
}
},
},
{
@@ -96,8 +120,12 @@ const DATE_RANGE_PRESETS = [
label: 'Last 3 Months',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
const start = new Date(t)
start.setMonth(t.getMonth() - 3)
return {
from: d(start).toISOString(),
to: d(new Date(), 23, 59, 59, 999).toISOString(),
}
},
},
]
@@ -127,7 +155,8 @@ const FilterBar = ({
const activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return false
if (def.defaultValue !== undefined && value === def.defaultValue) return false
if (def.defaultValue !== undefined && value === def.defaultValue)
return false
if (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to)
return true
@@ -183,13 +212,18 @@ const FilterBar = ({
if (value === undefined || value === null) return null
if (def.type === 'single-select') {
if (def.defaultValue !== undefined && value === def.defaultValue) return null
if (def.defaultValue !== undefined && value === def.defaultValue)
return null
return def.options?.find(o => o.value === value)?.label ?? def.label
}
if (def.type === 'boolean') return def.label
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
if (
def.type === 'multi-select' &&
Array.isArray(value) &&
value.length > 0
) {
if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label
}
@@ -199,7 +233,10 @@ const FilterBar = ({
if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null
if (value.preset) {
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
return (
DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ??
'Date Range'
)
}
const from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to)
@@ -259,7 +296,15 @@ const FilterBar = ({
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mb: 2,
}}
>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
@@ -309,37 +354,42 @@ const FilterBar = ({
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal
<AppModal
open={isOpen}
isMobile
onClose={() => setIsOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
<Chip
size='sm'
variant='solid'
color='primary'
sx={modalCountChipSx}
>
{activeFilterCount}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasActive}
onClick={onClearAll}
>
Clear all
</Button>
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
{resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
<ModalActions
tertiary={{
label: 'Clear all',
color: 'danger',
disabled: !hasActive,
onClick: onClearAll,
}}
primary={{
label:
resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done',
onClick: () => setIsOpen(false),
}}
/>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
@@ -348,9 +398,18 @@ const FilterBar = ({
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
>
{def.icon && (
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{def.icon}
</Box>
)}
@@ -359,21 +418,41 @@ const FilterBar = ({
</Typography>
{/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{activeFilters[def.id].length} selected
</Chip>
)}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{opt.label}
{def.type === 'multi-select' &&
(activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip
size='sm'
variant='solid'
color='primary'
sx={sectionBadgeChipSx}
>
{activeFilters[def.id].length} selected
</Chip>
) : null
})()}
)}
{def.type === 'single-select' &&
activeFilters[def.id] != null &&
(() => {
const opt = def.options?.find(
o => o.value === activeFilters[def.id],
)
return opt ? (
<Chip
size='sm'
variant='solid'
color='primary'
sx={sectionBadgeChipSx}
>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
<Chip
size='sm'
variant='solid'
color='primary'
sx={sectionBadgeChipSx}
>
{getActiveChipLabel(def)}
</Chip>
)}
@@ -383,18 +462,28 @@ const FilterBar = ({
{def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
const isSelected = (activeFilters[def.id] || []).includes(
opt.value,
)
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
color={
isSelected ? (opt.color ?? 'primary') : 'neutral'
}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
<Avatar
src={opt.avatar}
alt={opt.label}
sx={{ '--Avatar-size': '20px' }}
/>
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
) : (
(opt.icon ?? null)
)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={selectableChipSx}
@@ -415,13 +504,21 @@ const FilterBar = ({
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
color={
isSelected ? (opt.color ?? 'primary') : 'neutral'
}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
<Avatar
src={opt.avatar}
alt={opt.label}
sx={{ '--Avatar-size': '20px' }}
/>
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
) : (
(opt.icon ?? null)
)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={selectableChipSx}
@@ -438,7 +535,11 @@ const FilterBar = ({
<Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
startDecorator={
activeFilters[def.id] ? (
<Check sx={{ fontSize: 14 }} />
) : null
}
onClick={() => handleBoolToggle(def.id)}
sx={selectableChipSx}
>
@@ -447,58 +548,84 @@ const FilterBar = ({
)}
{/* date-range */}
{def.type === 'date-range' && (() => {
const val = activeFilters[def.id] || {}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{def.type === 'date-range' &&
(() => {
const val = activeFilters[def.id] || {}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : null
}
onClick={() =>
handleDateRangePreset(def.id, preset.value)
}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
{/* Custom date inputs */}
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e =>
handleDateRangeInput(def.id, 'from', e.target.value)
}
slotProps={{
input: { max: toInputDate(val.to) || undefined },
}}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', flexShrink: 0 }}
>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e =>
handleDateRangeInput(def.id, 'to', e.target.value)
}
slotProps={{
input: { min: toInputDate(val.from) || undefined },
}}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box>
</Box>
)
})()}
)
})()}
</Box>
))}
</Box>
</BottomSheetModal>
</AppModal>
</>
)
}

View File

@@ -0,0 +1,61 @@
import { Box, Button } from '@mui/joy'
const ActionButton = ({ action, defaults, sx }) => {
if (!action) return null
const { label, sx: actionSx, ...props } = action
return (
<Button {...defaults} {...props} sx={{ ...sx, ...actionSx }}>
{label}
</Button>
)
}
/**
* Consistent modal action row. Secondary actions are rendered first and the
* primary action is always the final, highest-emphasis control.
*/
const ModalActions = ({
primary,
secondary,
tertiary,
children,
stackOnMobile = false,
sx,
}) => {
const responsiveButtonStyles = stackOnMobile
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
: undefined
const layoutSx = {
display: 'flex',
flexDirection: stackOnMobile ? { xs: 'column-reverse', sm: 'row' } : 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 1,
...responsiveButtonStyles,
...sx,
}
if (children) return <Box sx={layoutSx}>{children}</Box>
return (
<Box sx={layoutSx}>
<ActionButton
action={tertiary}
defaults={{ color: 'neutral', variant: 'plain' }}
sx={{ mr: { sm: 'auto' } }}
/>
<ActionButton
action={secondary}
defaults={{ color: 'neutral', variant: 'outlined' }}
/>
<ActionButton
action={primary}
defaults={{ color: 'primary', variant: 'solid' }}
/>
</Box>
)
}
export default ModalActions

View File

@@ -0,0 +1,52 @@
# UI foundations
## Modals
Use `AppModal` for new work. Existing features may continue using
`useResponsiveModal`; it now renders the same primitive.
### Presentations
- Desktop: centered, constrained dialog (`sm` 400, `md` 520, `lg` 680,
`xl` 840 pixels).
- Mobile default: bottom sheet.
- Long mobile workflows: `mobilePresentation='fullscreen'`.
- Destructive confirmation: `size='sm'`, `role='alertdialog'`, and
`closeOnBackdrop={false}`.
`AppModal` owns the header, close control, content scrolling, safe-area spacing,
and footer. Do not add another close button or duplicate the title inside the
content.
```jsx
<AppModal
open={open}
onClose={onClose}
title='Create item'
description='Add a recognizable name.'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Create', onClick: onCreate, loading: isSaving }}
/>
}
>
{content}
</AppModal>
```
## Buttons
- Primary: `solid primary`; one primary action per surface.
- Secondary: `outlined neutral`.
- Tertiary: `plain neutral`.
- Destructive confirmation: `solid danger`.
- Destructive trigger: usually `outlined danger` or `plain danger`.
- Modal order: secondary first, primary last.
- Every icon-only button requires an `aria-label`.
- Use the built-in `loading` state to prevent repeated submission.
Button heights, radii, focus states, and reduced-motion behavior are defined in
`src/contexts/ThemeContext.jsx`. Avoid local overrides for those properties.
The live reference is available at `/test`.

View File

@@ -1,5 +1,6 @@
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from '/tailwind.config.js'
import tailwindConfig from '/tailwind.config.mjs'
export const { theme: THEME } = resolveConfig(tailwindConfig)

View File

@@ -28,7 +28,7 @@ export const AVAILABLE_LANGUAGES = [
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
]
export const LocalizationProvider = ({ children }) => {

View File

@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types'
const primaryColor = 'cyan'
const shades = [
'50',
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
]
const getPallete = (key = primaryColor) => {
return shades.reduce((acc, shade) => {
acc[shade] = COLORS[key][shade]
return acc
const getPalette = (key = primaryColor) =>
shades.reduce((palette, shade) => {
palette[shade] = COLORS[key][shade]
return palette
}, {})
}
const primaryPalette = getPallete(primaryColor)
const primaryPalette = getPalette(primaryColor)
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
// takes precedence, which is what keeps connected groups looking connected.
const CONTROL_RADIUS = '12px'
const ICON_BUTTON_RADIUS = '10px'
const theme = extendTheme({
radius: {
xs: '6px',
sm: '8px',
md: '10px',
lg: '12px',
xl: '16px',
},
colorSchemes: {
light: {
palette: {
@@ -42,42 +53,100 @@ const theme = extendTheme({
200: '#fbd5d5',
300: '#f9c1c1',
400: '#f6a8a8',
500: '',
600: '#f47272',
700: '#e33434',
800: '#cc1f1a',
900: '#b91c1c',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
warning: {
50: '#fffdf7',
100: '#fef8e1',
200: '#fdecb2',
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
},
},
warning: {
50: '#fffdf7',
100: '#fef8e1',
200: '#fdecb2',
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
},
dark: {
palette: {
primary: primaryPalette,
},
},
},
dark: {
palette: {
primary: primaryPalette,
components: {
JoyButton: {
styleOverrides: {
root: ({ ownerState }) => ({
minHeight:
ownerState.size === 'lg'
? '44px'
: ownerState.size === 'sm'
? '36px'
: '40px',
// Read through the CSS variable so parents that own the radius
// (ButtonGroup, Input/Select decorators, Card actions) still win.
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
fontWeight: 600,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.98)',
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyIconButton: {
styleOverrides: {
root: ({ ownerState }) => ({
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.96)',
},
// Touch target only for the default size. `sm`/`lg` are explicit
// choices by the call site and keep Joy's own sizing.
...(ownerState.size === 'md' && {
minWidth: '40px',
minHeight: '40px',
'@media (max-width: 768px)': {
minWidth: '44px',
minHeight: '44px',
},
}),
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyButtonGroup: {
styleOverrides: {
root: {
'--ButtonGroup-radius': CONTROL_RADIUS,
},
},
},
},
})
const ThemeContext = ({ children }) => {
return (
<CssVarsProvider theme={theme}>
<CssBaseline />
{children}
</CssVarsProvider>
)
}
const ThemeContext = ({ children }) => (
<CssVarsProvider theme={theme}>
<CssBaseline />
{children}
</CssVarsProvider>
)
ThemeContext.propTypes = {
children: PropType.node,

120
src/hooks/useLongPress.js Normal file
View File

@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef } from 'react'
const DEFAULT_DELAY_MS = 450
// Deliberately smaller than the swipe list's swipeStartThreshold (10px) so the
// hold is abandoned before a swipe is even recognized.
const MOVE_TOLERANCE_PX = 6
const haptic = async () => {
try {
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
await Haptics.impact({ style: ImpactStyle.Medium })
} catch {
// no haptics on this platform
}
}
/**
* Press-and-hold gesture that works for both touch and mouse.
*
* Returns `handlers` to spread on the element and a `cancel` function so the
* owner can abandon a pending hold when another gesture wins (e.g. the swipe
* list reports a swipe start). A press that drifts more than
* MOVE_TOLERANCE_PX, scrolls, or gets cancelled by the browser never fires,
* and the click that follows a successful hold is swallowed so the element's
* normal click action doesn't also run.
*/
export const useLongPress = (
onLongPress,
{ delay = DEFAULT_DELAY_MS, enabled = true } = {},
) => {
const timerRef = useRef(null)
const originRef = useRef(null)
const firedRef = useRef(false)
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
originRef.current = null
}, [])
// Watch movement on the window rather than only on the element: while the
// swipe list drags the row it translates under the finger, and the pointer
// can end up over a different element than the one we started on.
useEffect(() => {
const handleWindowMove = event => {
if (!timerRef.current || !originRef.current) return
const point = event.touches?.[0] ?? event
if (point.clientX === undefined) return
const dx = Math.abs(point.clientX - originRef.current.x)
const dy = Math.abs(point.clientY - originRef.current.y)
if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) {
clear()
}
}
window.addEventListener('pointermove', handleWindowMove, {
capture: true,
passive: true,
})
window.addEventListener('touchmove', handleWindowMove, {
capture: true,
passive: true,
})
window.addEventListener('scroll', clear, { capture: true, passive: true })
return () => {
window.removeEventListener('pointermove', handleWindowMove, true)
window.removeEventListener('touchmove', handleWindowMove, true)
window.removeEventListener('scroll', clear, true)
clear()
}
}, [clear])
const start = useCallback(
event => {
if (!enabled || !onLongPress) return
// Ignore right/middle mouse buttons
if (event.pointerType === 'mouse' && event.button !== 0) return
clear()
firedRef.current = false
originRef.current = { x: event.clientX, y: event.clientY }
timerRef.current = setTimeout(() => {
firedRef.current = true
timerRef.current = null
haptic()
onLongPress(event)
}, delay)
},
[enabled, onLongPress, delay, clear],
)
const handlers = {
onPointerDown: start,
onPointerUp: clear,
onPointerCancel: clear,
onPointerLeave: clear,
onDragStart: clear,
// Swallow the click that the browser fires after the finger lifts
onClickCapture: event => {
if (firedRef.current) {
firedRef.current = false
event.preventDefault()
event.stopPropagation()
}
},
onContextMenu: event => {
// A touch long-press otherwise pops the native context menu on top
if (firedRef.current) {
event.preventDefault()
}
},
}
return { handlers, cancel: clear }
}
export default useLongPress

View File

@@ -1,18 +1,23 @@
import BottomSheetModal from '../components/common/BottomSheetModal'
import FadeModal from '../components/common/FadeModal'
import useWindowWidth from './useWindowWidth'
import useMediaQuery from '@mui/material/useMediaQuery'
import { createElement } from 'react'
import AppModal from '../components/common/AppModal'
const MobileAppModal = props =>
createElement(AppModal, { ...props, isMobile: true })
const DesktopAppModal = props =>
createElement(AppModal, { ...props, isMobile: false })
/**
* Hook that returns the appropriate modal component based on screen size
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
* @returns {Object} - { Modal: Component, isMobile: boolean }
* Backwards-compatible access to the app modal system.
*
* New code may render AppModal directly when it already knows the desired
* presentation. Existing callers can continue using ResponsiveModal.
*/
export const useResponsiveModal = (breakpoint = 768) => {
const windowWidth = useWindowWidth()
const isMobile = windowWidth <= breakpoint
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
return {
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
isMobile,
}
}

View File

@@ -67,6 +67,24 @@ const isNetworkError = error =>
((error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError')
// The backend returns { error: "..." } on failures. Surface that message when
// it is there, flagged so callers can tell it apart from our generic fallback.
const errorFromResponse = async (resp, fallbackMessage) => {
if (!resp) return new Error(fallbackMessage)
let serverMessage = null
try {
const body = await resp.json()
if (typeof body?.error === 'string' && body.error.trim() !== '') {
serverMessage = body.error
}
} catch {
// body was empty or not JSON, keep the fallback
}
const error = new Error(serverMessage || fallbackMessage)
error.isServerMessage = Boolean(serverMessage)
return error
}
const buildOfflineChore = task => ({
...task,
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
try {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
throw await errorFromResponse(resp, 'Failed to create chore')
}
const createdChore = await resp.json()
if (!createdChore) {
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
try {
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
throw await errorFromResponse(resp, 'Failed to save chore')
}
const updatedChoreRes = await resp.json()
if (!updatedChoreRes) {

View File

@@ -0,0 +1,475 @@
import { InAppReview } from '@capacitor-community/in-app-review'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { Preferences } from '@capacitor/preferences'
import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
const STATE_KEY = 'feedbackState'
// Eligibility thresholds for the automatic sentiment prompt.
const MIN_COMPLETIONS = 10
const MIN_DAYS_SINCE_SIGNUP = 7
const COOLDOWN_DAYS = 120
// A recent crash/API failure poisons the sentiment reading, so hold off.
const ERROR_QUIET_PERIOD_MS = 10 * 60 * 1000
const APP_STORE_URL =
'https://apps.apple.com/app/apple-store/id6742807441?pt=127258663&ct=website&mt=8'
const PLAY_STORE_URL =
'https://play.google.com/store/apps/details?id=com.donetick.app'
const GITHUB_URL = 'https://github.com/donetick/donetick'
const DAY_MS = 24 * 60 * 60 * 1000
const defaultState = {
completions: 0,
// null until the first prompt is shown/snoozed.
lastPromptedAt: null,
lastPromptedVersion: null,
dismissCount: 0,
reviewRequestedAt: null,
lastSentiment: null,
optedOut: false,
// Developer Settings escape hatch; never set in normal use.
devForced: false,
}
let cachedState = null
const readState = async () => {
if (cachedState) return cachedState
try {
const { value } = await Preferences.get({ key: STATE_KEY })
cachedState = { ...defaultState, ...(value ? JSON.parse(value) : {}) }
} catch (error) {
console.warn('FeedbackService: unable to read state', error)
cachedState = { ...defaultState }
}
return cachedState
}
const writeState = async patch => {
const current = await readState()
cachedState = { ...current, ...patch }
try {
await Preferences.set({
key: STATE_KEY,
value: JSON.stringify(cachedState),
})
} catch (error) {
console.warn('FeedbackService: unable to persist state', error)
}
return cachedState
}
export const getFeedbackState = () => readState()
/**
* Counts a completed task towards prompt eligibility. Called from the single
* network choke point for completions so offline completions are counted when
* they sync rather than twice.
*/
export const recordTaskCompleted = async () => {
const state = await readState()
// Stop writing once we are well past the threshold; nothing reads the exact
// number beyond reporting it as context.
if (state.completions > MIN_COMPLETIONS * 100) return
await writeState({ completions: state.completions + 1 })
}
// ---------------------------------------------------------------------------
// Recent error breadcrumbs (in memory only, never persisted)
// ---------------------------------------------------------------------------
const recentErrors = []
const MAX_ERRORS = 5
export const recordFeedbackError = message => {
if (!message) return
recentErrors.push({ at: Date.now(), message: String(message).slice(0, 300) })
if (recentErrors.length > MAX_ERRORS) recentErrors.shift()
}
let errorListenersInstalled = false
export const installFeedbackErrorListeners = () => {
if (errorListenersInstalled || typeof window === 'undefined') return
errorListenersInstalled = true
window.addEventListener('error', event => {
recordFeedbackError(event?.message)
})
window.addEventListener('unhandledrejection', event => {
recordFeedbackError(event?.reason?.message || event?.reason)
})
}
const hasRecentError = () =>
recentErrors.some(error => Date.now() - error.at < ERROR_QUIET_PERIOD_MS)
// ---------------------------------------------------------------------------
// Context collection
// ---------------------------------------------------------------------------
const getAppVersion = async () => {
if (Capacitor.isNativePlatform()) {
try {
const { App } = await import('@capacitor/app')
const info = await App.getInfo()
return `${info.version} (${info.build})`
} catch {
// fall through to the web bundle version
}
}
return import.meta.env.VITE_APP_VERSION || 'web'
}
const getDeviceContext = async () => {
try {
const info = await Device.getInfo()
return {
deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
osVersion: `${info.operatingSystem} ${info.osVersion}`,
}
} catch {
return { deviceModel: 'unknown', osVersion: 'unknown' }
}
}
/**
* Everything we attach to a submission without asking the user for it.
*/
export const collectFeedbackContext = async ({ feature, userProfile } = {}) => {
const [version, device, state, isCloud] = await Promise.all([
getAppVersion(),
getDeviceContext(),
readState(),
isOfficialDonetickInstance().catch(() => false),
])
const signupDate = userProfile?.created_at
const daysSinceSignup = signupDate
? Math.floor((Date.now() - new Date(signupDate).getTime()) / DAY_MS)
: null
return {
appVersion: version,
platform: Capacitor.getPlatform(),
isNative: Capacitor.isNativePlatform(),
deviceModel: device.deviceModel,
osVersion: device.osVersion,
locale:
localStorage.getItem('i18nextLng') || navigator.language || 'unknown',
hosting: isCloud ? 'cloud' : 'self-hosted',
feature: feature || 'unknown',
tasksCompleted: state.completions,
daysSinceSignup,
userId: userProfile?.id ?? null,
subscription: userProfile?.subscription ?? null,
recentErrors: recentErrors.map(error => error.message),
submittedAt: new Date().toISOString(),
}
}
// ---------------------------------------------------------------------------
// Eligibility
// ---------------------------------------------------------------------------
/**
* Runs every gate and reports which ones failed, so Developer Settings can
* explain why the prompt is or isn't showing rather than just saying "no".
*/
export const evaluatePromptEligibility = async ({ userProfile } = {}) => {
const state = await readState()
const version = await getAppVersion()
if (state.devForced) {
return { eligible: true, forced: true, blockers: [], state, version }
}
const blockers = []
if (state.optedOut) {
blockers.push('User opted out (chose a sentiment, or dismissed 3 times)')
}
if (state.completions < MIN_COMPLETIONS) {
blockers.push(
`Only ${state.completions} completions, needs ${MIN_COMPLETIONS}`,
)
}
if (hasRecentError()) {
blockers.push('An error occurred in the last 10 minutes')
}
const signupDate = userProfile?.createdAt
if (signupDate) {
const days = (Date.now() - new Date(signupDate).getTime()) / DAY_MS
if (days < MIN_DAYS_SINCE_SIGNUP) {
blockers.push(
`Account is ${Math.floor(days)} days old, needs ${MIN_DAYS_SINCE_SIGNUP}`,
)
}
}
if (state.lastPromptedAt) {
const daysSincePrompt = (Date.now() - state.lastPromptedAt) / DAY_MS
if (daysSincePrompt < COOLDOWN_DAYS) {
blockers.push(
`Cooldown: ${Math.ceil(COOLDOWN_DAYS - daysSincePrompt)} days remaining`,
)
}
}
// Never ask twice on the same build, even after the cooldown expires.
if (state.lastPromptedVersion && state.lastPromptedVersion === version) {
blockers.push(`Already prompted on this version (${version})`)
}
return {
eligible: blockers.length === 0,
forced: false,
blockers,
state,
version,
}
}
export const shouldShowSentimentPrompt = async options =>
(await evaluatePromptEligibility(options)).eligible
/** Developer Settings: bypass every gate on the next eligibility check. */
export const setDevForcedPrompt = forced => writeState({ devForced: !!forced })
/** Developer Settings: back to a never-prompted, zero-completions user. */
export const resetFeedbackState = async () => {
cachedState = { ...defaultState }
try {
await Preferences.remove({ key: STATE_KEY })
} catch (error) {
console.warn('FeedbackService: unable to clear state', error)
}
return cachedState
}
export const markPromptShown = async () => {
const version = await getAppVersion()
return writeState({
lastPromptedAt: Date.now(),
lastPromptedVersion: version,
// A forced prompt is spent once shown, otherwise it would fire on every
// visit to My Chores.
devForced: false,
})
}
export const markPromptDismissed = async () => {
const state = await readState()
const dismissCount = state.dismissCount + 1
// Three dismissals in a row is an answer: stop asking automatically.
return writeState({ dismissCount, optedOut: dismissCount >= 3 })
}
export const markSentiment = async sentiment =>
writeState({ lastSentiment: sentiment, dismissCount: 0 })
export const optOutOfFeedbackPrompts = () => writeState({ optedOut: true })
// ---------------------------------------------------------------------------
// Store review + submission
// ---------------------------------------------------------------------------
export const getStoreUrl = () => {
const platform = Capacitor.getPlatform()
if (platform === 'ios') return APP_STORE_URL
if (platform === 'android') return PLAY_STORE_URL
return GITHUB_URL
}
export const storeLinks = {
appStore: APP_STORE_URL,
playStore: PLAY_STORE_URL,
github: GITHUB_URL,
}
/**
* Asks the OS to show its native review dialog. The OS decides whether to
* actually display it and gives no feedback either way, so this resolves true
* only to mean "the request went through".
*/
export const requestStoreReview = async () => {
if (!Capacitor.isNativePlatform()) return false
try {
await InAppReview.requestReview()
await writeState({ reviewRequestedAt: Date.now(), optedOut: true })
return true
} catch (error) {
console.warn('FeedbackService: review request failed', error)
return false
}
}
const WEBHOOK_URL = import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
/**
* A chat-provider webhook pasted straight into the env var. That can't work —
* the app posts its own schema, which Discord rejects with "Cannot send an
* empty message" (50006) — and it would publish the webhook to every user,
* since VITE_ vars are baked into the bundle. Relay through workers/feedback.
*/
const isRawChatWebhook = url =>
/^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
url || '',
)
export const isFeedbackSubmissionConfigured = () =>
Boolean(WEBHOOK_URL) && !isRawChatWebhook(WEBHOOK_URL)
/** Developer Settings: flag the misconfiguration in the UI, not just the log. */
export const isRawChatWebhookConfigured = () => isRawChatWebhook(WEBHOOK_URL)
/**
* Whether this app is talking to the hosted donetick.com service. Self-hosted
* instances route feedback to GitHub instead of the webhook, so their data
* never leaves infrastructure they control.
*/
export const isCloudInstance = () =>
isOfficialDonetickInstance().catch(() => false)
export const SUBMIT_RESULT = {
SENT: 'sent',
FAILED: 'failed',
UNCONFIGURED: 'unconfigured',
MISCONFIGURED: 'misconfigured',
SELF_HOSTED: 'self-hosted',
}
/**
* Builds a pre-filled GitHub issue for self-hosted users. Everything the
* webhook would have collected goes into the issue body, where the user can
* see and edit it before anything is published.
*/
export const buildGithubIssueUrl = ({
sentiment,
category,
message,
context,
}) => {
const labelFor = {
bugs: 'bug',
missingFeature: 'feature request',
tooComplicated: 'usability',
slow: 'performance',
notifications: 'notifications',
ai: 'ai',
other: 'feedback',
}
const title = `[${labelFor[category] || 'feedback'}] ${
message?.split('\n')[0]?.slice(0, 80) || 'App feedback'
}`
const body = [
message?.trim() || '_no description_',
'',
'---',
'',
'<details><summary>Environment</summary>',
'',
`- App version: ${context.appVersion}`,
`- Platform: ${context.platform}${context.isNative ? ' (native)' : ''}`,
`- Device: ${context.deviceModel}`,
`- OS: ${context.osVersion}`,
`- Locale: ${context.locale}`,
`- Hosting: ${context.hosting}`,
`- Screen: ${context.feature}`,
`- Sentiment: ${sentiment}`,
...(context.recentErrors.length
? ['', 'Recent errors:', '```', ...context.recentErrors, '```']
: []),
'',
'</details>',
].join('\n')
return `${GITHUB_URL}/issues/new?title=${encodeURIComponent(
title,
)}&body=${encodeURIComponent(body)}`
}
/**
* Posts the structured feedback plus the auto-collected context to the
* configured webhook. Never throws, so the UI can show a soft failure without
* losing what the user typed.
*
* Self-hosted instances are never relayed: the caller gets SELF_HOSTED plus a
* pre-filled GitHub issue URL to send the user to instead.
*/
export const submitFeedback = async ({
sentiment,
category,
message,
feature,
userProfile,
}) => {
const context = await collectFeedbackContext({ feature, userProfile })
const payload = {
source: 'donetick-app',
sentiment,
category: category || null,
message: message?.trim() || null,
context,
}
// Enforced here rather than only in the UI so no future caller can leak a
// self-hosted user's feedback to the hosted relay.
if (context.hosting !== 'cloud') {
return {
result: SUBMIT_RESULT.SELF_HOSTED,
githubUrl: buildGithubIssueUrl({ sentiment, category, message, context }),
}
}
if (!WEBHOOK_URL) {
console.info('FeedbackService: no webhook configured, feedback:', payload)
return { result: SUBMIT_RESULT.UNCONFIGURED }
}
if (isRawChatWebhook(WEBHOOK_URL)) {
console.error(
'FeedbackService: VITE_FEEDBACK_WEBHOOK_URL points directly at a ' +
'Discord/Slack webhook. Discord will reject this with 50006 ' +
'("Cannot send an empty message") because the app posts its own ' +
'schema, and the URL would ship inside the public bundle. Deploy ' +
'workers/feedback and point the variable at the Worker instead.',
payload,
)
return { result: SUBMIT_RESULT.MISCONFIGURED }
}
try {
const response = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return {
result: response.ok ? SUBMIT_RESULT.SENT : SUBMIT_RESULT.FAILED,
}
} catch (error) {
console.warn('FeedbackService: submission failed', error)
return { result: SUBMIT_RESULT.FAILED }
}
}
export const FEEDBACK_CATEGORIES = [
'bugs',
'missingFeature',
'tooComplicated',
'slow',
'notifications',
'ai',
'other',
]
export const SENTIMENTS = {
LOVE: 'love',
OKAY: 'okay',
ISSUES: 'issues',
}

View File

@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(body),
}).then(response => {
if (response?.ok) {
// Single choke point for completions, so queued offline completions are
// counted once, when they sync.
import('../service/FeedbackService')
.then(({ recordTaskCompleted }) => recordTaskCompleted())
.catch(() => {})
}
return response
})
}

View File

@@ -0,0 +1,160 @@
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'
import {
Box,
Button,
FormControl,
FormHelperText,
FormLabel,
IconButton,
Input,
Link,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { authButtonSx, authInputSx } from './authStyles'
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
<FormControl error={Boolean(error)} {...formProps}>
<FormLabel sx={labelSx}>{label}</FormLabel>
{children}
{(error || helper) && (
<FormHelperText
sx={{
fontSize: '0.8125rem',
color: error ? 'danger.plainColor' : 'text.secondary',
}}
>
{error || helper}
</FormHelperText>
)}
</FormControl>
)
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
<AuthField label={label} error={error} helper={helper}>
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
</AuthField>
)
export const AuthPasswordField = ({
label = 'Password',
error,
helper,
sx,
...inputProps
}) => {
const [visible, setVisible] = useState(false)
return (
<AuthField label={label} error={error} helper={helper}>
<Input
size='lg'
type={visible ? 'text' : 'password'}
sx={{ ...authInputSx, ...sx }}
endDecorator={
<IconButton
variant='plain'
color='neutral'
size='sm'
tabIndex={-1}
aria-label={visible ? 'Hide password' : 'Show password'}
onClick={() => setVisible(v => !v)}
sx={{ borderRadius: '8px' }}
>
{visible ? (
<VisibilityOffOutlined fontSize='small' />
) : (
<VisibilityOutlined fontSize='small' />
)}
</IconButton>
}
{...inputProps}
/>
</AuthField>
)
}
export const AuthSubmitButton = ({ children, sx, ...props }) => (
<Button
type='submit'
size='lg'
variant='solid'
fullWidth
sx={{ ...authButtonSx, ...sx }}
{...props}
>
{children}
</Button>
)
export const SocialButton = ({ icon, children, sx, ...props }) => (
<Button
type='button'
size='lg'
variant='outlined'
color='neutral'
fullWidth
startDecorator={icon}
sx={{
...authButtonSx,
fontWeight: 500,
justifyContent: 'center',
...sx,
}}
{...props}
>
{children}
</Button>
)
export const AuthDivider = ({ children = 'or' }) => (
<Box
role='separator'
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
'&::before, &::after': {
content: '""',
flex: 1,
height: '1px',
bgcolor: 'divider',
},
}}
>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
{children}
</Typography>
</Box>
)
export const LegalLinks = () => (
<Typography
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
<Link
href='https://donetick.com/privacy'
target='_blank'
rel='noopener'
color='neutral'
underline='hover'
>
Privacy Policy
</Link>
{' · '}
<Link
href='https://donetick.com/terms'
target='_blank'
rel='noopener'
color='neutral'
underline='hover'
>
Terms of Use
</Link>
</Typography>
)

View File

@@ -0,0 +1,117 @@
import { Capacitor } from '@capacitor/core'
import { Box, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
/**
* Full-height auth layout: edge-to-edge on phones, a centered surface card from
* the `sm` breakpoint up. The route renders without a navbar, so the shell owns
* its own safe-area padding (the top inset is already reserved by NavBar).
*/
const AuthShell = ({
title,
subtitle,
action,
children,
footer,
logoSize = 48,
// In the app the user already came through the app icon and the Get Started
// mark, so repeating it here is noise. On the web these routes are the first
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
// so the mark is the only thing identifying the app. Views reached from an
// emailed link override this to always show it.
showLogo = !Capacitor.isNativePlatform(),
}) => {
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 2,
pt: { xs: 3, sm: 5 },
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
bgcolor: 'background.body',
}}
>
{/* my:auto centers the column without the top-clipping that
justify-content:center causes once the form outgrows the viewport. */}
<Box sx={{ width: '100%', maxWidth: 420, my: 'auto' }}>
<Sheet
variant='plain'
sx={{
position: 'relative',
borderRadius: { xs: 0, sm: '20px' },
bgcolor: { xs: 'transparent', sm: 'background.surface' },
border: { xs: 'none', sm: '1px solid' },
borderColor: { sm: 'divider' },
boxShadow: { xs: 'none', sm: 'sm' },
p: { xs: 0, sm: 3.5 },
animation: 'authPanelIn 240ms cubic-bezier(0.22, 1, 0.36, 1) both',
'@keyframes authPanelIn': {
from: { opacity: 0, transform: 'translateY(8px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
}}
>
{action && (
<Box sx={{ position: 'absolute', top: 0, right: 0 }}>{action}</Box>
)}
{/* Mark only: the wordmark sat at nearly the same size and weight as
the title below it, so the two competed instead of forming a
hierarchy. */}
{showLogo && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 2,
}}
>
<Logo size={`${logoSize}px`} />
</Box>
)}
{title && (
<Typography
level='h2'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textAlign: 'center',
textWrap: 'balance',
}}
>
{title}
</Typography>
)}
{subtitle && (
<Typography
level='body-sm'
sx={{
mt: 0.75,
textAlign: 'center',
color: 'text.secondary',
textWrap: 'pretty',
}}
>
{subtitle}
</Typography>
)}
<Box sx={{ mt: title || subtitle ? 3 : 0 }}>{children}</Box>
</Sheet>
{footer && <Box sx={{ mt: 2.5 }}>{footer}</Box>}
</Box>
</Box>
)
}
export default AuthShell

View File

@@ -1,6 +1,5 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { Box, Button, LinearProgress } from '@mui/joy'
import { useEffect, useState } from 'react'
import Logo from '../../Logo'
import { Capacitor } from '@capacitor/core'
import Cookies from 'js-cookie'
@@ -11,14 +10,16 @@ import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Authenticating')
const [subMessage, setSubMessage] = useState('Please wait')
const [message, setMessage] = useState('Signing you in')
const [subMessage, setSubMessage] = useState('This will only take a moment.')
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
@@ -30,14 +31,15 @@ const AuthenticationLoading = () => {
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown Authentication Provider')
setSubMessage('Please contact support')
setMessage('Unknown sign-in provider')
setSubMessage('Please contact support.')
setStatus('error')
}
return endOAuthExchange
}, [provider])
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
GetUserProfile().then(response => {
response.json().then(() => {
refetchUserProfile().then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
@@ -69,8 +71,8 @@ const AuthenticationLoading = () => {
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Authentication failed')
setSubMessage('Two-factor authentication was cancelled')
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication was cancelled.')
setStatus('error')
}
@@ -83,8 +85,8 @@ const AuthenticationLoading = () => {
const storedState = localStorage.getItem('authState')
if (returnedState !== storedState) {
setMessage('Authentication failed')
setSubMessage('State does not match')
setMessage('Sign-in failed')
setSubMessage('The sign-in request could not be verified.')
setStatus('error')
return
}
@@ -110,8 +112,8 @@ const AuthenticationLoading = () => {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Authentication failed')
setSubMessage('Please try again')
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setStatus('error')
return
}
@@ -120,22 +122,22 @@ const AuthenticationLoading = () => {
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Authentication failed')
setSubMessage('MFA session is missing. Please try again')
setMessage('Sign-in failed')
setSubMessage('The MFA session is missing. Please try again.')
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-Factor Authentication Required')
setSubMessage('Please verify your login to continue')
setMessage('Two-factor authentication')
setSubMessage('Verify your login to continue.')
return
}
if (!data.token && !data.access_token) {
setMessage('Authentication failed')
setSubMessage('No valid authentication token returned')
setMessage('Sign-in failed')
setSubMessage('No valid authentication token was returned.')
setStatus('error')
return
}
@@ -156,66 +158,52 @@ const AuthenticationLoading = () => {
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Authentication failed')
setSubMessage('Please try again')
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setStatus('error')
}
}
}
return (
<Container className='flex h-full items-center justify-center'>
<AuthShell title={message} subtitle={subMessage}>
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
}}
role='status'
aria-live='polite'
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
>
<CircularProgress
determinate={status === 'error'}
color={status === 'pending' ? 'primary' : 'danger'}
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
}}
>
{message}
</Box>
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
{subMessage}
</Typography>
{status === 'pending' && (
<LinearProgress
sx={{ width: '60%', '--LinearProgress-radius': '999px' }}
/>
)}
{status === 'error' && (
<Button
component={Link}
to='/login'
size='lg'
variant='outlined'
sx={{
mt: 4,
}}
variant='soft'
color='neutral'
fullWidth
sx={authButtonSx}
>
<Link to='/login'>Go back Login</Link>
Back to sign in
</Button>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Authentication failed')
setSubMessage('Two-factor authentication failed. Please try again')
}}
/>
</Box>
</Container>
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication failed. Please try again.')
}}
/>
</AuthShell>
)
}

View File

@@ -1,46 +1,38 @@
// create boilerplate for ResetPasswordView:
import {
Box,
Button,
Container,
FormControl,
FormHelperText,
Input,
Sheet,
Typography,
} from '@mui/joy'
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
import { Box, Button, Link, Typography } from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
const isInvalidEmail = email =>
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
const ForgotPasswordView = () => {
const navigate = useNavigate()
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const { showError, showNotification } = useNotification()
const validateEmail = email => {
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
}
const handleSubmit = async e => {
e?.preventDefault()
const handleSubmit = async () => {
if (!email) {
return setEmailError('Email is required')
setEmailError('Email is required')
return
}
// validate email:
if (validateEmail(email)) {
if (isInvalidEmail(email)) {
setEmailError('Please enter a valid email address')
return
}
if (emailError) {
return
}
setIsSubmitting(true)
try {
const response = await ResetPassword(email)
@@ -64,146 +56,106 @@ const ForgotPasswordView = () => {
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
} finally {
setIsSubmitting(false)
}
}
// Validate on blur/submit only; flagging a half-typed address as invalid on
// every keystroke reads as the form yelling at you mid-word.
const handleEmailChange = e => {
setEmail(e.target.value)
if (validateEmail(e.target.value)) {
setEmailError('Please enter a valid email address')
} else {
if (emailError) {
setEmailError(null)
}
}
return (
<Container component='main' maxWidth='xs'>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
const handleEmailBlur = () => {
if (email && isInvalidEmail(email)) {
setEmailError('Please enter a valid email address')
}
}
if (resetStatusOk !== null) {
return (
<AuthShell
title='Check your email'
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
footer={<LegalLinks />}
logoSize={0}
>
<Sheet
component='form'
<Box
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
}}
>
<Logo />
<MarkEmailReadOutlined
sx={{ fontSize: 40, color: 'primary.plainColor', mb: 2 }}
/>
<Button
fullWidth
size='lg'
variant='solid'
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
</Button>
</Box>
</AuthShell>
)
}
<Typography level='h2'>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
{resetStatusOk === null && (
<>
<Typography level='body2' sx={{ mb: 3 }}>
Enter your email, and we'll send you a link to get into your
account.
</Typography>
return (
<AuthShell
title='Reset your password'
subtitle="Enter your email and we'll send you a link to get back into your account."
footer={<LegalLinks />}
logoSize={0}
>
<Box
component='form'
onSubmit={handleSubmit}
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthTextField
label='Email address'
id='email'
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
autoFocus
value={email}
error={emailError}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
/>
<Typography level='body2' alignSelf={'start'} mb={1}>
Email Address
</Typography>
<FormControl
error={emailError !== null}
sx={{ width: '100%', mb: 2 }}
>
<Input
margin='normal'
required
fullWidth
id='email'
placeholder='Enter your email address'
type='email'
name='email'
autoComplete='email'
autoFocus
value={email}
onChange={handleEmailChange}
error={emailError !== null}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<FormHelperText>{emailError}</FormHelperText>
</FormControl>
<Button
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={handleSubmit}
>
Reset Password
</Button>
<Button
type='submit'
fullWidth
size='lg'
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/login')
}}
color='neutral'
>
Back to Login
</Button>
</>
)}
{resetStatusOk != null && (
<>
<Typography
level='body-md'
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
>
If there is an account associated with the email you entered,
you will receive an email with instructions on how to reset your
password.
</Typography>
<Button
variant='solid'
size='lg'
fullWidth
onClick={() => {
navigate('/login')
}}
>
Go to Login
</Button>
</>
)}
</Sheet>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Send reset link
</AuthSubmitButton>
</Box>
</Container>
<Typography
level='body-sm'
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
>
Remembered it?{' '}
<Link
component='button'
type='button'
level='body-sm'
fontWeight={600}
underline='hover'
onClick={() => navigate('/login')}
>
Back to sign in
</Link>
</Typography>
</AuthShell>
)
}

View File

@@ -2,23 +2,16 @@ import { Preferences } from '@capacitor/preferences'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi'
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Input,
Sheet,
Typography,
} from '@mui/joy'
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient'
import { offlineDB } from '../../utils/OfflineDB'
import { AuthSubmitButton, AuthTextField } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
const CONNECTION_TIMEOUT_MS = 8000
@@ -138,7 +131,8 @@ const LoginSettings = () => {
}
}
const handleSave = async () => {
const handleSave = async e => {
e.preventDefault()
const trimmedURL = serverURL.trim()
if (trimmedURL === '') {
@@ -192,138 +186,113 @@ const LoginSettings = () => {
const isTesting = status === 'testing'
return (
<Container component='main' maxWidth='xs'>
<AuthShell
title='Server settings'
subtitle='Point the app at your own self-hosted Donetick server.'
>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
component='form'
onSubmit={handleSave}
sx={{ display: 'flex', flexDirection: 'column' }}
>
<Sheet
component='form'
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
<AuthTextField
label='Server URL'
id='serverURL'
name='serverURL'
inputMode='url'
autoCapitalize='none'
autoCorrect='off'
spellCheck='false'
placeholder='https://your-server:2021'
autoFocus
value={serverURL}
onChange={handleURLChange}
disabled={isTesting}
color={
status === 'success'
? 'success'
: status === 'error'
? 'danger'
: 'neutral'
}
endDecorator={
status === 'success' ? (
<CheckCircleOutlineIcon color='success' fontSize='small' />
) : status === 'error' ? (
<ErrorOutlineIcon color='error' fontSize='small' />
) : null
}
helper='Include the protocol (http:// or https://) and the port if needed. Donetick defaults to port 2021.'
/>
{status === 'error' && (
<Alert
color='danger'
variant='soft'
startDecorator={<ErrorOutlineIcon />}
sx={{ mt: 2, borderRadius: '12px', alignItems: 'flex-start' }}
>
{errorMessage}
</Alert>
)}
{status === 'success' && (
<Alert
color='success'
variant='soft'
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, borderRadius: '12px' }}
>
Connected. Taking you to sign in...
</Alert>
)}
{status === 'testing' && (
<Alert
color='neutral'
variant='soft'
startDecorator={<WifiIcon />}
sx={{ mt: 2, borderRadius: '12px' }}
>
Testing connection to server...
</Alert>
)}
<AuthSubmitButton
loading={isTesting}
disabled={status === 'success'}
startDecorator={isTesting ? <CircularProgress size='sm' /> : null}
sx={{ mt: 3 }}
>
{isTesting ? 'Testing connection' : 'Save & connect'}
</AuthSubmitButton>
<Button
type='button'
fullWidth
size='lg'
variant='plain'
color='neutral'
disabled={isTesting}
sx={{ ...authButtonSx, mt: 1 }}
onClick={async () => {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
await apiClient.init(true)
refetchResource()
Navigate('/login')
}}
>
<Logo />
<Typography level='h2'>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
Server URL
</Typography>
<Input
margin='normal'
required
fullWidth
id='serverURL'
name='serverURL'
autoFocus
value={serverURL}
onChange={handleURLChange}
disabled={isTesting}
color={
status === 'success'
? 'success'
: status === 'error'
? 'danger'
: 'neutral'
}
endDecorator={
status === 'success' ? (
<CheckCircleOutlineIcon color='success' fontSize='small' />
) : status === 'error' ? (
<ErrorOutlineIcon color='error' fontSize='small' />
) : null
}
/>
<Typography mt={1} level='body-xs'>
Change the server URL to connect to a different server, such as your
own self-hosted Donetick server.
</Typography>
<Typography mt={1} level='body-xs'>
Include the protocol (http:// or https://) and port if necessary
(default Donetick port is 2021).
</Typography>
{status === 'error' && (
<Alert
color='danger'
variant='soft'
startDecorator={<ErrorOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
{errorMessage}
</Alert>
)}
{status === 'success' && (
<Alert
color='success'
variant='soft'
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
Connected! Redirecting to login...
</Alert>
)}
{status === 'testing' && (
<Alert
color='neutral'
variant='soft'
startDecorator={<WifiIcon />}
sx={{ mt: 2, width: '100%' }}
>
Testing connection to server...
</Alert>
)}
<Button
fullWidth
size='lg'
variant='solid'
disabled={isTesting || status === 'success'}
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
onClick={handleSave}
startDecorator={
isTesting ? <CircularProgress size='sm' /> : undefined
}
>
{isTesting ? 'Testing...' : 'Save & Connect'}
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='danger'
disabled={isTesting}
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
onClick={async () => {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
await apiClient.init(true)
refetchResource()
Navigate('/login')
}}
>
Cancel and Reset
</Button>
</Sheet>
Reset to default server
</Button>
</Box>
</Container>
<Typography
level='body-xs'
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
>
Changing the server clears locally cached data on this device.
</Typography>
</AuthShell>
)
}

View File

@@ -3,24 +3,10 @@ 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 { SettingsOutlined } from '@mui/icons-material'
import AppleIcon from '@mui/icons-material/Apple'
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
Box,
Button,
Container,
Divider,
IconButton,
Input,
Sheet,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
@@ -28,25 +14,83 @@ import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { useAuth } from '../../hooks/useAuth.jsx'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import {
AuthDivider,
AuthPasswordField,
AuthSubmitButton,
AuthTextField,
LegalLinks,
SocialButton,
} from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'
const SegmentedControl = ({ value, onChange, options }) => (
<Box
role='tablist'
sx={{
display: 'flex',
p: 0.5,
gap: 0.5,
borderRadius: '12px',
bgcolor: 'neutral.softBg',
mb: 2.5,
}}
>
{options.map(option => {
const selected = option.value === value
return (
<Box
key={option.value}
component='button'
type='button'
role='tab'
aria-selected={selected}
onClick={() => onChange(option.value)}
sx={{
flex: 1,
border: 'none',
cursor: 'pointer',
borderRadius: '9px',
py: 1,
fontSize: '0.875rem',
fontFamily: 'inherit',
fontWeight: 600,
color: selected ? 'text.primary' : 'text.secondary',
bgcolor: selected ? 'background.surface' : 'transparent',
boxShadow: selected ? 'xs' : 'none',
transition: 'background-color 180ms ease, color 180ms ease',
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.500',
outlineOffset: '2px',
},
}}
>
{option.label}
</Box>
)
})}
</Box>
)
const LoginView = () => {
// Use React Query client directly to invalidate the user profile query
const queryClient = useQueryClient()
// const [userProfile, setUserProfile] = useState(null)
const { data: userProfile } = useUserProfile()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// Child login state
const [loginType, setLoginType] = useState('primary')
@@ -54,7 +98,7 @@ const LoginView = () => {
const [childName, setChildName] = useState('')
// Clear fields when switching login modes
const handleLoginModeChange = (event, newValue) => {
const handleLoginModeChange = newValue => {
setLoginType(newValue)
setUsername('')
setParentUsername('')
@@ -94,7 +138,6 @@ const LoginView = () => {
}, [])
useEffect(() => {
if (isAuthenticated && user) {
setUserProfile(user)
Navigate('/chores')
}
}, [isAuthenticated, user, Navigate])
@@ -141,7 +184,19 @@ const LoginView = () => {
? buildChildUsername(parentUsername, childName)
: username
const result = await authLogin({ username: actualUsername, password })
setIsSubmitting(true)
let result
try {
result = await authLogin({ username: actualUsername, password })
} catch (error) {
showError({
title: 'Login Failed',
message: error?.message || 'An error occurred, please try again',
})
return
} finally {
setIsSubmitting(false)
}
if (result.success) {
if (result.data?.mfaRequired) {
@@ -352,473 +407,262 @@ const LoginView = () => {
}
}
return (
<Container
component='main'
maxWidth='xs'
const displayName = userProfile?.displayName || userProfile?.username
const showSocialLogin = import.meta.env.VITE_IS_SELF_HOSTED !== 'true'
const hasSocialOptions =
showSocialLogin || Boolean(resource?.identity_provider?.client_id)
// make content center in the middle of the page:
return (
<AuthShell
title={userProfile ? 'Welcome back' : 'Sign in'}
subtitle={
userProfile
? 'Pick up right where you left off.'
: 'Sign in to your account to continue.'
}
logoSize={0}
footer={<LegalLinks />}
action={
Capacitor.isNativePlatform() ? (
<IconButton
variant='plain'
color='neutral'
aria-label='Server settings'
onClick={() => Navigate('/login/settings')}
>
<SettingsOutlined />
</IconButton>
) : null
}
>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Sheet
component='form'
{userProfile ? (
<Box
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
gap: 1.5,
}}
>
{Capacitor.isNativePlatform() && (
<IconButton
// on top right of the screen:
sx={{ position: 'absolute', top: 2, right: 2, color: 'black' }}
onClick={() => {
Navigate('/login/settings')
}}
>
{' '}
<Settings />
</IconButton>
)}
<Logo />
<Typography level='h2'>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
{userProfile && (
<>
<Avatar
src={userProfile?.image}
alt={userProfile?.username}
size='lg'
sx={{ mt: 2, width: '96px', height: '96px', mb: 1 }}
/>
<Typography level='body-md' alignSelf={'center'}>
Welcome back,{' '}
{userProfile?.displayName || userProfile?.username}
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography
component='span'
level='body-xs'
color='neutral'
sx={{ ml: 1 }}
>
(Sub Account)
</Typography>
)}
<Avatar
src={userProfile?.image}
alt={displayName}
sx={{ width: 88, height: 88 }}
/>
<Box sx={{ textAlign: 'center' }}>
<Typography level='title-md'>{displayName}</Typography>
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
Sub Account
</Typography>
<Button
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
getUserProfileAndNavigateToHome()
}}
>
Continue as {userProfile.displayName || userProfile.username}
</Button>
<Button
type='submit'
fullWidth
size='lg'
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
apiClient.handleLogout()
}}
>
Logout
</Button>
</>
)}
{!userProfile && (
<>
<Typography level='body2' sx={{ mb: 3 }}>
Sign in to your account to continue
</Typography>
{/* Login Type Tabs */}
<Tabs
value={loginType}
onChange={handleLoginModeChange}
sx={{ width: '100%', mb: 3 }}
>
<TabList
sx={{
width: '100%',
p: 0.5,
borderBottom: 'none',
boxShadow: 'none',
'&::after': {
display: 'none',
},
}}
>
<Tab
value='primary'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Primary Account
</Tab>
<Tab
value='sub'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Sub Account
</Tab>
</TabList>
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='Email Address'
name='email'
autoComplete='email'
autoFocus
value={username}
onChange={e => {
setUsername(e.target.value)
}}
/>
</TabPanel>
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Primary Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='parentUsername'
name='parentUsername'
placeholder='Enter primary account username'
autoFocus
value={parentUsername}
onChange={e => {
setParentUsername(e.target.value)
}}
/>
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
Sub Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name'
value={childName}
onChange={e => {
setChildName(e.target.value)
}}
/>
</TabPanel>
</Tabs>
<Typography level='body2' alignSelf={'start'} mb={1}>
Password:
</Typography>
<Input
margin='normal'
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
autoComplete='password'
value={password}
onChange={e => {
setPassword(e.target.value)
}}
/>
<Button
type='submit'
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={handleSubmit}
>
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
</Button>
<Button
type='submit'
fullWidth
size='lg'
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={handleForgotPassword}
>
Forgot password?
</Button>
</>
)}
<Divider> or </Divider>
{import.meta.env.VITE_IS_SELF_HOSTED !== 'true' && (
<>
{!Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<LoginSocialGoogle
client_id={GOOGLE_CLIENT_ID}
redirect_uri={REDIRECT_URL}
scope='openid profile email'
discoveryDocs='claims_supported'
access_type='online'
isOnlyGetToken={true}
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={() => {
showError({
title: 'Google Login Failed',
message:
"Couldn't log in with Google, please try again",
})
}}
>
<Button
variant='soft'
color='neutral'
size='lg'
fullWidth
sx={{
width: '100%',
mt: 1,
mb: 1,
border: 'moccasin',
borderRadius: '8px',
}}
>
<div className='flex gap-2'>
<GoogleIcon />
Continue with Google
</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
fullWidth
variant='soft'
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={async () => {
try {
const user = await SocialLogin.login({
provider: 'google',
options: { scopes: ['profile', 'email', 'openid'] },
})
console.log('Google user', user)
loggedWithProvider('google', user.result)
} catch (error) {
console.error('Google login error:', error)
showError({
title: 'Google Login Failed',
message: `Couldn't log in with Google, please try again${
error?.message ? `: ${error.message}` : ''
}`,
})
}
}}
>
<div className='flex gap-2'>
<GoogleIcon />
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>
)}
</>
)}
{resource?.identity_provider?.client_id && (
<Button
fullWidth
color='neutral'
variant='soft'
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={handleAuthentikLogin}
>
Continue with {resource?.identity_provider?.name}
</Button>
)}
{!resource?.is_user_creation_disabled && (
<Button
onClick={() => {
Navigate('/signup')
}}
fullWidth
variant='soft'
size='lg'
// sx={{ mt: 3, mb: 2 }}
>
Create new account
</Button>
)}
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
)}
</Box>
</Sheet>
<Button
fullWidth
size='lg'
sx={{ ...authButtonSx, mt: 1 }}
onClick={getUserProfileAndNavigateToHome}
>
Continue as {displayName}
</Button>
<Button
fullWidth
size='lg'
variant='plain'
color='neutral'
sx={authButtonSx}
onClick={() => apiClient.handleLogout()}
>
Use a different account
</Button>
</Box>
) : (
<Box
component='form'
onSubmit={handleSubmit}
sx={{ display: 'flex', flexDirection: 'column' }}
>
<SegmentedControl
value={loginType}
onChange={handleLoginModeChange}
options={[
{ value: 'primary', label: 'Primary Account' },
{ value: 'sub', label: 'Sub Account' },
]}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{loginType === 'primary' ? (
<AuthTextField
label='Username'
id='username'
name='username'
autoComplete='username'
placeholder='Your username'
autoFocus
value={username}
onChange={e => setUsername(e.target.value)}
/>
) : (
<>
<AuthTextField
label='Primary account username'
id='parentUsername'
name='parentUsername'
autoComplete='username'
placeholder='Enter primary account username'
autoFocus
value={parentUsername}
onChange={e => setParentUsername(e.target.value)}
/>
<AuthTextField
label='Sub account name'
id='childName'
name='childName'
placeholder='Enter sub account name'
value={childName}
onChange={e => setChildName(e.target.value)}
/>
</>
)}
<Box>
<AuthPasswordField
id='password'
name='password'
autoComplete='current-password'
placeholder='Enter your password'
value={password}
onChange={e => setPassword(e.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Link
component='button'
type='button'
level='body-sm'
underline='hover'
onClick={handleForgotPassword}
>
Forgot password?
</Link>
</Box>
</Box>
</Box>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 3 }}>
{loginType === 'sub' ? 'Sign in as sub account' : 'Sign in'}
</AuthSubmitButton>
</Box>
)}
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{showSocialLogin && !Capacitor.isNativePlatform() && (
<LoginSocialGoogle
client_id={GOOGLE_CLIENT_ID}
redirect_uri={REDIRECT_URL}
scope='openid profile email'
discoveryDocs='claims_supported'
access_type='online'
isOnlyGetToken={true}
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={() => {
showError({
title: 'Google Login Failed',
message: "Couldn't log in with Google, please try again",
})
}}
>
<SocialButton icon={<GoogleIcon />}>Google</SocialButton>
</LoginSocialGoogle>
)}
{showSocialLogin && Capacitor.isNativePlatform() && (
<>
<SocialButton
icon={<GoogleIcon />}
onClick={async () => {
try {
const user = await SocialLogin.login({
provider: 'google',
options: { scopes: ['profile', 'email', 'openid'] },
})
console.log('Google user', user)
loggedWithProvider('google', user.result)
} catch (error) {
console.error('Google login error:', error)
showError({
title: 'Google Login Failed',
message: `Couldn't log in with Google, please try again${
error?.message ? `: ${error.message}` : ''
}`,
})
}
}}
>
Google
</SocialButton>
{isAppleSignInSupported && (
<SocialButton
icon={<AppleIcon />}
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",
})
})
}}
>
Apple
</SocialButton>
)}
</>
)}
{resource?.identity_provider?.client_id && (
<SocialButton onClick={handleAuthentikLogin}>
{resource?.identity_provider?.name}
</SocialButton>
)}
</Box>
{!userProfile && !resource?.is_user_creation_disabled && (
<Typography
level='body-sm'
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
>
Don&apos;t have an account?{' '}
<Link
component='button'
type='button'
level='body-sm'
fontWeight={600}
underline='hover'
onClick={() => Navigate('/signup')}
>
Create one
</Link>
</Typography>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
@@ -826,7 +670,7 @@ const LoginView = () => {
onSuccess={handleMFASuccess}
onError={handleMFAError}
/>
</Container>
</AuthShell>
)
}

View File

@@ -1,18 +1,10 @@
import { Security, Smartphone } from '@mui/icons-material'
import {
Alert,
Box,
Button,
Input,
Link,
ModalClose,
Stack,
Typography,
} from '@mui/joy'
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher'
import { authInputSx } from './authStyles'
const MFAVerificationModal = ({
open,
@@ -26,6 +18,7 @@ const MFAVerificationModal = ({
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { ResponsiveModal } = useResponsiveModal()
const handleVerify = async () => {
if (!verificationCode.trim()) {
setError('Please enter a verification code')
@@ -43,12 +36,17 @@ const MFAVerificationModal = ({
onSuccess(data)
} else {
const errorData = await response.json()
setError(
errorData.message || 'Invalid verification code. Please try again.',
)
const message =
errorData.message || 'Invalid verification code. Please try again.'
setError(message)
onError?.(message)
}
} catch (error) {
setError('Failed to verify code. Please try again.')
// A wrong code is shown inline; a failed request is escalated to the
// caller so it can surface a toast instead of looking like a bad code.
const message = 'Failed to verify code. Please try again.'
setError(message)
onError?.(message)
console.error('MFA verification error:', error)
} finally {
setLoading(false)
@@ -63,8 +61,9 @@ const MFAVerificationModal = ({
onClose()
}
const handleKeyPress = e => {
const handleKeyDown = e => {
if (e.key === 'Enter' && !loading) {
e.preventDefault()
handleVerify()
}
}
@@ -73,86 +72,104 @@ const MFAVerificationModal = ({
<ResponsiveModal
open={open}
onClose={handleClose}
size='lg'
fullWidth={true}
title='Two-Factor Authentication'
size='md'
title='Two-factor authentication'
description={
isBackupCode
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
: 'Enter the 6-digit code from your authenticator app.'
}
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: loading,
}}
primary={{
label: 'Verify & Sign In',
onClick: handleVerify,
loading,
disabled: !verificationCode.trim(),
}}
/>
}
>
<ModalClose />
<Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app
</Typography>
</Box>
<Stack spacing={3}>
<Stack spacing={2.5}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
<Typography
component='label'
htmlFor='mfa-code'
level='body-sm'
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
>
{isBackupCode ? 'Backup code' : 'Verification code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
id='mfa-code'
size='lg'
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
error={Boolean(error)}
autoFocus
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
...authInputSx,
// Targets the inner <input>; styling the root leaves the text
// itself unaligned.
'& input': {
textAlign: 'center',
letterSpacing: isBackupCode ? 'normal' : '0.4em',
fontVariantNumeric: 'tabular-nums',
fontSize: '1.125rem',
},
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
inputMode: isBackupCode ? 'text' : 'numeric',
pattern: isBackupCode ? undefined : '[0-9]*',
autoComplete: isBackupCode ? 'off' : 'one-time-code',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
{error && (
<Alert color='danger' size='sm'>
<Alert color='danger' variant='soft' sx={{ borderRadius: '12px' }}>
{error}
</Alert>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Box sx={{ textAlign: 'center' }}>
<Link
component='button'
type='button'
level='body-sm'
underline='hover'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
: 'Use a backup code instead'}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
{isBackupCode && (
<Typography
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
Each backup code can only be used once.
</Typography>
</Alert>
)}
</Stack>
</ResponsiveModal>
)

View File

@@ -1,20 +1,16 @@
import {
Box,
Button,
Container,
Divider,
FormControl,
FormHelperText,
Input,
Sheet,
Typography,
} from '@mui/joy'
import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
import {
AuthPasswordField,
AuthSubmitButton,
AuthTextField,
LegalLinks,
} from './AuthFields'
import AuthShell from './AuthShell'
const SignupView = () => {
const [username, setUsername] = React.useState('')
@@ -27,6 +23,7 @@ const SignupView = () => {
const [passwordError, setPasswordError] = React.useState('')
const [emailError, setEmailError] = React.useState('')
const [displayNameError, setDisplayNameError] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
@@ -90,10 +87,10 @@ const SignupView = () => {
isValid = false
}
// username should only contain lowercase letters, dot and dash:
if (!/^[a-z.-]+$/.test(username)) {
// username should only contain lowercase letters, numbers, dot and dash:
if (!/^[a-z0-9.-]+$/.test(username)) {
setUsernameError(
'Username can only contain lowercase letters, dot and dash',
'Username can only contain lowercase letters, numbers, dot and dash',
)
isValid = false
}
@@ -105,208 +102,129 @@ const SignupView = () => {
if (!handleSignUpValidation()) {
return
}
signUp(username, password, displayName, email).then(response => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
})
} else {
console.log('Signup failed')
response.json().then(res => {
setIsSubmitting(true)
signUp(username, password, displayName, email)
.then(response => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
showError({
title: 'Signup Failed',
message: res.error || 'An error occurred during signup',
message: 'Signup disabled, please contact admin',
})
})
}
})
} else {
console.log('Signup failed')
response.json().then(res => {
showError({
title: 'Signup Failed',
message: res.error || 'An error occurred during signup',
})
})
}
})
.finally(() => setIsSubmitting(false))
}
return (
<Container component='main' maxWidth='xs'>
<AuthShell
title='Create your account'
subtitle='Track chores and tasks together, in one shared place.'
footer={<LegalLinks />}
logoSize={0}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: 4,
}}
component='form'
onSubmit={handleSubmit}
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<Sheet
component='form'
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
// alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
<AuthTextField
label='Display name'
id='displayName'
name='displayName'
autoComplete='name'
placeholder='How others see your name'
autoFocus
value={displayName}
error={displayNameError}
onChange={e => {
setDisplayNameError(null)
setDisplayName(e.target.value)
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Logo />
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
<Typography level='body2'>
Create an account to get started!
</Typography>
</Box>
<Typography level='body2' alignSelf={'start'} mt={4}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='username'
label='Username'
name='username'
autoComplete='username'
autoFocus
value={username}
onChange={e => {
setUsernameError(null)
setUsername(e.target.value.trim())
}}
/>
<FormControl error={usernameError}>
<FormHelperText c>{usernameError}</FormHelperText>
</FormControl>
{/* Error message display */}
<Typography level='body2' alignSelf={'start'}>
Email
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='email'
name='email'
autoComplete='email'
value={email}
onChange={e => {
setEmailError(null)
setEmail(e.target.value.trim())
}}
/>
<FormControl error={emailError}>
<FormHelperText c>{emailError}</FormHelperText>
</FormControl>
<Typography level='body2' alignSelf={'start'}>
Password:
</Typography>
<Input
margin='normal'
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
placeholder='Enter password (8-64 characters)'
value={password}
onChange={e => {
setPasswordError(null)
setPassword(e.target.value)
}}
/>
<FormControl error={passwordError}>
<FormHelperText>{passwordError}</FormHelperText>
</FormControl>
<Typography level='body2' alignSelf={'start'}>
Display Name:
</Typography>
<Input
margin='normal'
required
fullWidth
name='displayName'
label='Display Name'
id='displayName'
placeholder='How others see your name'
value={displayName}
onChange={e => {
setDisplayNameError(null)
setDisplayName(e.target.value)
}}
/>
<FormControl error={displayNameError}>
<FormHelperText>{displayNameError}</FormHelperText>
</FormControl>
<Typography
level='body2'
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
>
By signing up, you agree to our Terms of Service and Privacy Policy
</Typography>
<Button
// type='submit'
size='lg'
fullWidth
variant='solid'
sx={{ mt: 1, mb: 1 }}
onClick={handleSubmit}
>
Sign Up
</Button>
<Divider> or </Divider>
<Button
size='lg'
onClick={() => {
Navigate('/login')
}}
fullWidth
variant='soft'
// sx={{ mt: 3, mb: 2 }}
>
Login
</Button>
/>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy-policy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
<AuthTextField
label='Username'
id='username'
name='username'
autoComplete='username'
placeholder='lowercase letters, numbers, dot and dash'
value={username}
error={usernameError}
onChange={e => {
setUsernameError(null)
setUsername(e.target.value.trim())
}}
/>
<AuthTextField
label='Email'
id='email'
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
value={email}
error={emailError}
onChange={e => {
setEmailError(null)
setEmail(e.target.value.trim())
}}
/>
<AuthPasswordField
id='password'
name='password'
autoComplete='new-password'
placeholder='At least 8 characters'
value={password}
error={passwordError}
helper='Use 8 to 64 characters.'
onChange={e => {
setPasswordError(null)
setPassword(e.target.value)
}}
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Create account
</AuthSubmitButton>
<Typography
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
By creating an account you agree to our Terms of Service and Privacy
Policy.
</Typography>
</Box>
</Container>
<Typography
level='body-sm'
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
>
Already have an account?{' '}
<Link
component='button'
type='button'
level='body-sm'
fontWeight={600}
underline='hover'
onClick={() => Navigate('/login')}
>
Sign in
</Link>
</Typography>
</AuthShell>
)
}

View File

@@ -1,57 +1,68 @@
// create boilerplate for ResetPasswordView:
import {
Box,
Button,
Container,
FormControl,
FormHelperText,
Input,
Sheet,
Typography,
} from '@mui/joy'
import { Box, Button } from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ChangePassword } from '../../utils/Fetcher'
import { AuthPasswordField, AuthSubmitButton, LegalLinks } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
const UpdatePasswordView = () => {
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [passwordError, setPasswordError] = useState(null)
const [passworConfirmationError, setPasswordConfirmationError] =
const [passwordConfirmationError, setPasswordConfirmationError] =
useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [searchParams] = useSearchParams()
const { showError, showNotification } = useNotification()
const verifiticationCode = searchParams.get('c')
const verificationCode = searchParams.get('c')
const handlePasswordChange = e => {
const password = e.target.value
setPassword(password)
if (password.length < 8 || password.length > 64) {
setPasswordError('Password must be between 8 and 64 characters')
} else {
setPassword(e.target.value)
if (passwordError) {
setPasswordError(null)
}
}
const handlePasswordConfirmChange = e => {
setPasswordConfirm(e.target.value)
if (e.target.value !== password) {
setPasswordConfirmationError('Passwords do not match')
} else {
if (passwordConfirmationError) {
setPasswordConfirmationError(null)
}
}
const handleSubmit = async () => {
if (passwordError != null || passworConfirmationError != null) {
const validate = () => {
let isValid = true
if (password.length < 8 || password.length > 64) {
setPasswordError('Password must be between 8 and 64 characters')
isValid = false
}
if (passwordConfirm !== password) {
setPasswordConfirmationError('Passwords do not match')
isValid = false
}
return isValid
}
const handleSubmit = async e => {
e?.preventDefault()
// The old version only bailed when an error was already set, so an
// untouched form submitted an empty password.
if (!validate()) {
return
}
setIsSubmitting(true)
try {
const response = await ChangePassword(verifiticationCode, password)
const response = await ChangePassword(verificationCode, password)
if (response.ok) {
showNotification({
@@ -60,7 +71,6 @@ const UpdatePasswordView = () => {
message:
'Your password has been updated successfully. Redirecting to login...',
})
// wait 3 seconds and then redirect to login:
setTimeout(() => {
navigate('/login')
}, 3000)
@@ -75,111 +85,99 @@ const UpdatePasswordView = () => {
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Container component='main' maxWidth='xs'>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: 4,
}}
if (!verificationCode) {
return (
<AuthShell
title='This link is not valid'
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
footer={<LegalLinks />}
showLogo
>
<Sheet
component='form'
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
// alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
}}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Button
fullWidth
size='lg'
variant='solid'
sx={authButtonSx}
onClick={() => navigate('/forgot-password')}
>
Request a new link
</Button>
<Button
fullWidth
size='lg'
variant='plain'
color='neutral'
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
</Button>
</Box>
</AuthShell>
)
}
return (
<AuthShell
title='Set a new password'
subtitle='Choose a password you have not used on this account before.'
footer={<LegalLinks />}
// Reached from an emailed link, usually in a browser: an unbranded page
// asking for a new password is the exact shape of a phishing screen.
showLogo
>
<Box
component='form'
onSubmit={handleSubmit}
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthPasswordField
label='New password'
id='password'
name='password'
autoComplete='new-password'
placeholder='At least 8 characters'
autoFocus
value={password}
error={passwordError}
helper='Use 8 to 64 characters.'
onChange={handlePasswordChange}
/>
<AuthPasswordField
label='Confirm new password'
id='passwordConfirm'
name='passwordConfirm'
autoComplete='new-password'
placeholder='Re-enter your password'
value={passwordConfirm}
error={passwordConfirmationError}
onChange={handlePasswordConfirmChange}
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Save password
</AuthSubmitButton>
<Button
type='button'
fullWidth
size='lg'
variant='plain'
color='neutral'
sx={authButtonSx}
onClick={() => navigate('/login')}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Logo />
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
<Typography level='body2' mb={4}>
Please enter your new password below
</Typography>
</Box>
<FormControl error>
<Input
placeholder='Password'
type='password'
value={password}
onChange={handlePasswordChange}
error={passwordError !== null}
// onKeyDown={e => {
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
// handleSubmit(e)
// }
// }}
/>
<FormHelperText>{passwordError}</FormHelperText>
</FormControl>
<FormControl error>
<Input
placeholder='Confirm Password'
type='password'
value={passwordConfirm}
onChange={handlePasswordConfirmChange}
error={passworConfirmationError !== null}
// onKeyDown={e => {
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
// handleSubmit(e)
// }
// }}
/>
<FormHelperText>{passworConfirmationError}</FormHelperText>
</FormControl>
{/* helper to show password not matching : */}
<Button
fullWidth
size='lg'
sx={{
mt: 5,
mb: 1,
}}
onClick={handleSubmit}
>
Save Password
</Button>
<Button
fullWidth
size='lg'
variant='soft'
onClick={() => {
navigate('/login')
}}
>
Cancel
</Button>
</Sheet>
Cancel
</Button>
</Box>
</Container>
</AuthShell>
)
}

View File

@@ -0,0 +1,13 @@
// Shared shape for the auth screens so all four stay in one control vocabulary.
export const authInputSx = {
'--Input-radius': '12px',
'--Input-minHeight': '48px',
'--Input-focusedThickness': '2px',
fontSize: '1rem',
}
export const authButtonSx = {
'--Button-radius': '12px',
minHeight: 48,
fontWeight: 600,
}

View File

@@ -415,7 +415,9 @@ const ChoreEdit = () => {
console.error('Failed to save chore:', error)
showError({
title: 'Save Failed',
message: 'Failed to save chore, please try again.',
message: error?.isServerMessage
? error.message
: 'Failed to save chore, please try again.',
})
})
}
@@ -631,18 +633,19 @@ const ChoreEdit = () => {
}, [frequencyType])
useEffect(() => {
if (assignees.length === 0) {
if (anyone || assignableTo.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else {
if (!assignees.some(a => a.userId === assignedTo)) {
setAssignedTo(assignees[0].userId)
}
if (assignStrategy === 'no_assignee') {
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
} else if (assignStrategy === 'no_assignee') {
// user explicitly picked no_assignee while having assignees, keep it
// but there is nobody currently assigned
if (assignedTo !== null) {
setAssignedTo(null)
}
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
setAssignedTo(assignableTo[0].userId)
}
}, [assignStrategy, assignedTo, assignees])
}, [assignStrategy, assignedTo, assignableTo, anyone])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
@@ -1257,9 +1260,14 @@ const ChoreEdit = () => {
)}
</Box>
{assignees.length > 1 && (
{!anyone && assignableTo.length > 1 && (
<>
<Box mb={3}>
<Box
mb={3}
sx={{
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
}}
>
<Typography level='h4'>Currently Assigned To</Typography>
<Typography level='body-md'>
Who is assigned the next due?
@@ -1795,13 +1803,13 @@ const ChoreEdit = () => {
<FormControl>
<Radio
overlay
disabled={assignees.length === 0}
disabled={anyone || assignableTo.length === 0}
value={true}
label='Limited'
/>
<FormHelperText>
You and others that are assigned to the task
{assignees.length === 0
{anyone || assignableTo.length === 0
? ' (No assignees selected, Limited option is disabled)'
: ''}
</FormHelperText>

View File

@@ -5,7 +5,7 @@ import {
Pause,
PlayArrow,
} from '@mui/icons-material'
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
import { Box, Button, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
const TimerSplitButton = ({
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
disabled={disabled}
>
{/* Main action button */}
<IconButton
<Button
onClick={handleMainAction}
disabled={disabled}
size='md'
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
sx={{
px: 3,
py: 1,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
minWidth: fullWidth ? 'auto' : 120,
flex: fullWidth ? 1 : 'none',
}}
>
{chore.status === 1 ? <Pause /> : <PlayArrow />}
{chore.status === 1 ? 'Pause' : 'Resume'}
</IconButton>
</Button>
{/* Dropdown arrow button */}
<IconButton
onClick={handleMenuOpen}
disabled={disabled}
size='lg'
sx={{
px: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: '1px solid',
borderLeftColor: 'divider',
minWidth: 'auto',
}}
size='md'
sx={{ px: 1, minWidth: 'auto' }}
>
<ArrowDropDown />
</IconButton>

View File

@@ -432,6 +432,16 @@ const ArchivedTasks = () => {
setSelectedChores(newSelection)
}
// Press-and-hold on a task card enters multi-select with that task picked
const enterMultiSelectWithChore = choreId => {
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId]))
return
}
toggleChoreSelection(choreId)
}
const selectAllVisibleChores = () => {
if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id)))
@@ -706,7 +716,7 @@ const ArchivedTasks = () => {
}}
onChange={handleSearchChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
}
endDecorator={
searchTerm && (
@@ -1051,6 +1061,7 @@ const ArchivedTasks = () => {
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
</List>
</Box>

View File

@@ -108,27 +108,29 @@ const ChoreCard = ({
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
</Chip>
<Chip
variant='soft'
sx={{
position: 'relative',
top: 10,
zIndex: 3,
ml: 0.4,
left: 10,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
{!['once', 'no_repeat'].includes(chore.frequencyType) && (
<Chip
variant='soft'
sx={{
position: 'relative',
top: 10,
zIndex: 3,
ml: 0.4,
left: 10,
}}
>
{getFrequencyIcon(chore)}
{getRecurrentChipText(chore)}
</div>
</Chip>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{getFrequencyIcon(chore)}
{getRecurrentChipText(chore)}
</div>
</Chip>
)}
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
<PendingBadge commands={pendingCmds} />

View File

@@ -18,9 +18,60 @@ import {
} from '@mui/icons-material'
import { Box, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import { useLongPress } from '../../hooks/useLongPress'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
/**
* One swipeable row. Owns the press-and-hold gesture (multi-select), which
* can't live in the render loop because it needs a hook.
*/
const ChoreSwipeableItem = ({
trailingActions,
onClick,
onLongPress,
longPressEnabled,
children,
// SwipeableList clones its children to inject list-level config
// (listType, fullSwipe, thresholds…), so it has to be passed through.
...listProps
}) => {
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress(
onLongPress,
{ enabled: longPressEnabled },
)
// The swipe list owns the gesture the moment it recognizes a drag — a hold
// that turned into a swipe must not also open multi-select.
const handleSwipeStart = () => {
cancelLongPress()
}
return (
<SwipeableListItem
{...listProps}
trailingActions={trailingActions}
onClick={onClick}
onSwipeStart={handleSwipeStart}
onSwipeProgress={cancelLongPress}
>
<Box
{...longPressHandlers}
sx={{
width: '100%',
// Keep a long press from selecting the task text / popping the
// native callout on mobile
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
}}
>
{children}
</Box>
</SwipeableListItem>
)
}
const ChoreListView = ({
chores,
viewMode,
@@ -34,6 +85,7 @@ const ChoreListView = ({
userProfile,
isOfficialInstance,
toggleMultiSelectMode,
onLongPressChore,
showActions = true,
}) => {
const navigate = useNavigate()
@@ -248,7 +300,7 @@ const ChoreListView = ({
return (
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{chores.map(chore => (
<SwipeableListItem
<ChoreSwipeableItem
key={chore.id}
trailingActions={getTrailingActions(chore)}
onClick={() => {
@@ -258,9 +310,11 @@ const ChoreListView = ({
navigate(`/chores/${chore.id}`)
}
}}
longPressEnabled={Boolean(onLongPressChore)}
onLongPress={() => onLongPressChore?.(chore.id)}
>
{renderChoreCard(chore)}
</SwipeableListItem>
</ChoreSwipeableItem>
))}
</SwipeableList>
)

View File

@@ -84,7 +84,9 @@ const CompactChoreCard = ({
const parts = []
// Frequency
parts.push(getRecurrentChipText(chore))
if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
parts.push(getRecurrentChipText(chore))
}
// Assignee
if (chore.assignedTo) {
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
{/* Line 2: Metadata */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{getFrequencyIcon(chore)}
{!['once', 'no_repeat'].includes(chore.frequencyType) &&
getFrequencyIcon(chore)}
<Typography
level='body-xs'
color='text.secondary'

View File

@@ -1,6 +1,7 @@
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
import { HelpOutline } from '@mui/icons-material'
import { Box, Card, IconButton, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const MultiSelectHelp = ({ isVisible = true }) => {
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
borderRadius: '50%',
boxShadow: 'lg',
}}
aria-label='Show keyboard shortcuts'
title='Show keyboard shortcuts'
>
<HelpOutline />
</IconButton>
{/* Help Modal */}
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Keyboard color='primary' />
<Typography level='title-lg'>Multi-select Mode</Typography>
</Box>
<IconButton
variant='plain'
size='sm'
onClick={() => setIsHelpOpen(false)}
>
<Close />
</IconButton>
</Box>
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
Use these keyboard shortcuts to work more efficiently with multiple
tasks:
</Typography>
<ResponsiveModal
open={isHelpOpen}
onClose={() => setIsHelpOpen(false)}
title='Multi-select Mode'
description='Use these keyboard shortcuts to work more efficiently.'
footer={
<ModalActions
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
/>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
</Box>
</Card>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant='soft'
onClick={() => setIsHelpOpen(false)}
sx={{ minWidth: 120 }}
>
Got it!
</Button>
</Box>
</ResponsiveModal>
</>
)

View File

@@ -47,6 +47,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
@@ -121,7 +122,7 @@ const MyChores = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
useProjectFilter(projects)
useProjectFilter(projects, !projectsLoading)
const {
searchTerm,
@@ -143,6 +144,7 @@ const MyChores = () => {
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores,
clearSelection,
getSelectedChoresData,
@@ -366,6 +368,7 @@ const MyChores = () => {
}
processEffectAsync()
// throw new Error('Fake Error to test posthog')
}
}, [
membersLoading,
@@ -570,6 +573,7 @@ const MyChores = () => {
handleBulkArchive,
handleBulkDelete,
handleBulkSkip,
handleBulkMoveToProject,
} = useChoreActions({
chores,
filteredChores,
@@ -865,8 +869,8 @@ const MyChores = () => {
[getFilteredChores],
)
const updateChores = newChore => {
let newChores = [...chores, newChore]
const appendChore = (prev, newChore) => {
let newChores = [...prev, newChore]
if (impersonatedUser) {
newChores = newChores.filter(
@@ -874,8 +878,15 @@ const MyChores = () => {
)
}
setChores(newChores)
setFilteredChores(newChores)
return newChores
}
// Uses functional setState so back-to-back calls (e.g. creating several
// voice-captured tasks in a row) each build on the latest state instead of
// a closure snapshot taken before earlier calls landed.
const updateChores = newChore => {
setChores(prev => appendChore(prev, newChore))
setFilteredChores(prev => appendChore(prev, newChore))
clearQuickFilters()
}
@@ -1046,12 +1057,22 @@ const MyChores = () => {
<MultiSelectToolbar
isVisible={isMultiSelectMode}
selectedCount={selectedChores.size}
onSelectAll={selectAllVisibleChores}
onSelectAll={() =>
selectAllVisibleChores(
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores
: null,
choreSections,
openChoreSections,
)
}
onClear={clearSelection}
onComplete={handleBulkComplete}
onSkip={handleBulkSkip}
onArchive={handleBulkArchive}
onDelete={handleBulkDelete}
onMoveToProject={handleBulkMoveToProject}
projects={projects}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || hasQuickFilters
@@ -1116,6 +1137,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
)}
{viewMode === 'calendar' && (
@@ -1293,6 +1315,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
)}
</Box>
@@ -1373,6 +1396,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
</AccordionDetails>
</Accordion>
@@ -1456,6 +1480,7 @@ const MyChores = () => {
/>
</Box>
<NotificationAccessSnackbar />
<FeedbackPrompt />
{addTaskModalOpen && (
<TaskInput
autoFocus={taskInputFocus}

View File

@@ -1,5 +1,9 @@
import { Capacitor } from '@capacitor/core'
import DateModal from '../../Modals/Inputs/DateModal'
import DueDatePickerModal, {
combineDueDate,
splitDueDate,
} from '../../components/DueDatePickerModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal'
@@ -24,13 +28,16 @@ const ChoreModals = ({
return (
<>
{activeModal === 'changeDueDate' && modalChore && (
<DateModal
isOpen={true}
<DueDatePickerModal
open={true}
key={'changeDueDate' + modalChore.id}
current={modalChore.nextDueDate}
title='Change due date'
{...splitDueDate(modalChore.nextDueDate)}
onClose={onClose}
onSave={onChangeDueDate}
onApply={parts =>
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
}
onRemove={() => onChangeDueDate(null)}
/>
)}

View File

@@ -45,7 +45,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import AppModal from '../../../components/common/AppModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.icon != null
? isSelected
? <Check sx={{ fontSize: 14 }} />
: opt.icon
: undefined
opt.icon != null ? (
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
opt.icon
)
) : undefined
}
onClick={() => onToggle(opt.value)}
sx={{
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
return (
CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
@@ -363,8 +367,7 @@ const ChoreToolbar = ({
setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) {
const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
null
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
setEditingSavedFilter(
sourceFilter ||
(tempFilterMeta.sourceFilterId
@@ -444,7 +447,13 @@ const ChoreToolbar = ({
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].value
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
saveFilter?.({
name,
description: '',
color,
conditions,
operator: 'AND',
})?.then?.(() => {
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name)
})
@@ -460,16 +469,13 @@ const ChoreToolbar = ({
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
updateFilter(
editingSavedFilter.id,
{
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
},
)?.then?.(() => {
updateFilter(editingSavedFilter.id, {
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
})?.then?.(() => {
clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name)
@@ -498,9 +504,21 @@ const ChoreToolbar = ({
]
const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
{
value: 'default',
label: 'Cards',
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
},
{
value: 'compact',
label: 'Compact',
icon: <ViewComfy sx={{ fontSize: 16 }} />,
},
{
value: 'calendar',
label: 'Calendar',
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
},
]
return (
@@ -536,6 +554,7 @@ const ChoreToolbar = ({
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={openFilterSheet}
aria-label='Filters'
title='Filters'
>
<FilterList />
@@ -543,13 +562,14 @@ const ChoreToolbar = ({
</Badge>
{/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{!filterActive &&
projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* Display button — View + Group combined */}
<IconButton
@@ -558,6 +578,7 @@ const ChoreToolbar = ({
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
aria-label='View and group options'
title='View & Group'
>
{viewMode === 'calendar' ? (
@@ -577,6 +598,9 @@ const ChoreToolbar = ({
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={onToggleMultiSelect}
aria-label={
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
}
title={
isMultiSelectMode
? 'Exit multi-select (Ctrl+S)'
@@ -628,8 +652,9 @@ const ChoreToolbar = ({
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
<AppModal
open={filterSheetOpen}
isMobile
onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
@@ -649,7 +674,12 @@ const ChoreToolbar = ({
footer={
savingFilter ? (
<Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
sx={{
display: 'flex',
gap: 1,
width: '100%',
alignItems: 'center',
}}
>
<Input
size='sm'
@@ -710,12 +740,11 @@ const ChoreToolbar = ({
}}
sx={{ minWidth: 140 }}
>
{resultCount != null
? `Show ${resultCount}`
: 'Done'}
{resultCount != null ? `Show ${resultCount}` : 'Done'}
</Button>
<IconButton
ref={saveMenuRef}
aria-label='More save options'
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
@@ -825,11 +854,12 @@ const ChoreToolbar = ({
</>
)}
</Box>
</BottomSheetModal>
</AppModal>
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal
<AppModal
open={displaySheetOpen}
isMobile
onClose={() => setDisplaySheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -838,7 +868,10 @@ const ChoreToolbar = ({
</Box>
}
footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
<Button
onClick={() => setDisplaySheetOpen(false)}
sx={{ minWidth: 140 }}
>
Done
</Button>
}
@@ -853,9 +886,11 @@ const ChoreToolbar = ({
variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={
viewMode === opt.value
? <Check sx={{ fontSize: 14 }} />
: opt.icon
viewMode === opt.value ? (
<Check sx={{ fontSize: 14 }} />
) : (
opt.icon
)
}
onClick={() => onToggleViewMode?.(opt.value)}
sx={{
@@ -910,7 +945,8 @@ const ChoreToolbar = ({
label='Show tasks for'
badge={
selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
?.label
: null
}
/>
@@ -920,9 +956,8 @@ const ChoreToolbar = ({
multi={false}
onToggle={v => onAssigneeFilterChange?.(v)}
/>
</Box>
</BottomSheetModal>
</AppModal>
</>
)
}

View File

@@ -5,11 +5,39 @@ import {
Close,
Delete,
Done,
DriveFileMove,
SelectAll,
SkipNext,
} from '@mui/icons-material'
import { Box, Button, Divider, Typography } from '@mui/joy'
import {
Avatar,
Box,
Button,
Divider,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../../utils/Colors'
import { getIconComponent } from '../../../utils/ProjectIcons'
const renderProjectAvatar = (color, icon) => {
const bg = color || LABEL_COLORS[0].value
const IconComponent = getIconComponent(icon || 'FolderOpen')
return (
<Avatar size='sm' sx={{ width: 22, height: 22, backgroundColor: bg }}>
<IconComponent
sx={{ fontSize: 13, color: getTextColorFromBackgroundColor(bg) }}
/>
</Avatar>
)
}
const MultiSelectToolbar = ({
isVisible,
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
onSkip,
onArchive,
onDelete,
onMoveToProject,
projects = [],
showKeyboardShortcuts,
selectAllDisabled,
}) => {
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
const projectMenuRef = useRef(null)
const closeProjectMenu = () => setProjectMenuAnchor(null)
const handleMoveToProject = project => {
closeProjectMenu()
onMoveToProject?.(project)
}
return (
<Box
sx={{
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
/>
)}
</Button>
{onMoveToProject && (
<>
<Button
size='sm'
variant='soft'
color='neutral'
ref={projectMenuRef}
onClick={() =>
setProjectMenuAnchor(prev =>
prev ? null : projectMenuRef.current,
)
}
startDecorator={<DriveFileMove />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
title='Move selected tasks to a project'
>
Move
</Button>
<Menu
size='md'
anchorEl={projectMenuAnchor}
open={Boolean(projectMenuAnchor)}
onClose={closeProjectMenu}
placement='bottom-end'
>
<MenuItem
onClick={() =>
handleMoveToProject({ id: null, name: 'Default Project' })
}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>Default Project</Typography>
</ListItemContent>
</MenuItem>
{projects.map(project => (
<MenuItem
key={project.id}
onClick={() => handleMoveToProject(project)}
>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{project.name}</Typography>
</ListItemContent>
</MenuItem>
))}
</Menu>
</>
)}
<Button
size='sm'
variant='soft'

View File

@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
setConfirmModelConfig,
])
const handleBulkMoveToProject = useCallback(
async project => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
const projectId = project?.id ?? null
const movedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
movedTasks.push(chore)
} else {
failedTasks.push(chore)
}
} catch (error) {
failedTasks.push(chore)
}
}
if (movedTasks.length > 0) {
const movedIds = new Set(movedTasks.map(c => c.id))
const applyMove = list =>
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
setChores(applyMove)
setFilteredChores(applyMove)
showSuccess({
title: 'Tasks Moved',
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
})
}
refetchChores()
clearSelection()
},
[
chores,
getSelectedChoresData,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
],
)
return {
handleChoreAction,
handleChangeDueDate,
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
handleBulkArchive,
handleBulkDelete,
handleBulkSkip,
handleBulkMoveToProject,
}
}

View File

@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
[selectedChores],
)
// Entry point for press-and-hold on a task card: turn multi-select on (if it
// isn't already) with that task selected.
const enterMultiSelectWithChore = useCallback(
choreId => {
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId]))
return
}
toggleChoreSelection(choreId)
},
[isMultiSelectMode, toggleChoreSelection],
)
const selectAllVisibleChores = useCallback(
(visibleChores, choreSections = [], openChoreSections = {}) => {
let choresToSelect = []
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
expandedChores.every(chore => selectedChores.has(chore.id))
if (allExpandedSelected) {
choresToSelect = choreSections.flatMap(section => section.content || [])
choresToSelect = choreSections.flatMap(
section => section.content || [],
)
} else {
choresToSelect = expandedChores
}
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores,
clearSelection,
getSelectedChoresData,

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
export const useProjectFilter = projects => {
export const useProjectFilter = (projects, projectsLoaded = false) => {
const [selectedProject, setSelectedProject] = useState(() => {
const saved = localStorage.getItem('selectedProject')
return saved ? JSON.parse(saved) : null
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
window.history.replaceState({}, '', newUrl)
}, [])
// The cached selection is a whole project object, so a stale one keeps
// rendering its old name/color even though the project no longer belongs to
// this account (deleted project, or a different user signing in on a device
// where logout didn't get to clear storage). Once the real list has loaded,
// drop any selection that isn't in it.
useEffect(() => {
if (!projectsLoaded) return
if (!selectedProject || selectedProject.id === 'default') return
if (!projects.some(p => p.id === selectedProject.id)) {
setSelectedProjectWithCache(null)
}
}, [projectsLoaded, projects, selectedProject, setSelectedProjectWithCache])
return {
selectedProject,
projectsWithDefault,

View File

@@ -1,7 +1,8 @@
import { Box, Button, FormLabel, Input } from '@mui/joy'
import { FormLabel, Input } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import ConfirmationModal from './Inputs/ConfirmationModal'
@@ -41,31 +42,19 @@ function EditHistoryModal({ config, historyRecord }) {
// fullWidth={true}
title='Edit History'
footer={
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
onClick={() =>
<ModalActions
secondary={{ label: 'Cancel', onClick: config.onClose }}
primary={{
label: 'Save',
onClick: () =>
config.onSave({
id: historyRecord.id,
performedAt: moment(completedDate).toISOString(),
dueDate: moment(dueDate).toISOString(),
notes,
})
}
fullWidth
sx={{ mr: 1 }}
>
Save
</Button>
<Button
fullWidth
size='lg'
onClick={config.onClose}
variant='outlined'
>
Cancel
</Button>
</Box>
}),
}}
/>
}
>
<FormLabel>Due Date</FormLabel>
@@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) {
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
color: 'danger',
}}
/>
</ResponsiveModal>

View File

@@ -0,0 +1,324 @@
import { Browser } from '@capacitor/browser'
import { Android, Apple, Favorite, GitHub } from '@mui/icons-material'
import {
Box,
Button,
Chip,
CircularProgress,
Divider,
FormControl,
FormLabel,
Stack,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { useUserProfile } from '../../queries/UserQueries'
import {
FEEDBACK_CATEGORIES,
isCloudInstance,
markSentiment,
requestStoreReview,
SENTIMENTS,
storeLinks,
submitFeedback,
SUBMIT_RESULT,
} from '../../service/FeedbackService'
import { Capacitor } from '@capacitor/core'
const STEP = {
SENTIMENT: 'sentiment',
DETAILS: 'details',
THANKS: 'thanks',
WEB_REVIEW: 'webReview',
GITHUB: 'github',
}
// Native webviews swallow target="_blank"; route through the system browser.
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const SENTIMENT_OPTIONS = [
{ value: SENTIMENTS.LOVE, emoji: '😍' },
{ value: SENTIMENTS.OKAY, emoji: '🙂' },
{ value: SENTIMENTS.ISSUES, emoji: '😕' },
]
/**
* Sentiment-first feedback flow. "Love it" routes to the native store review
* dialog (or star links on web); anything else collects structured feedback
* and never asks for a review.
*/
const FeedbackModal = ({ open, onClose, onDismiss }) => {
const { t } = useTranslation()
const { ResponsiveModal } = useResponsiveModal()
const { data: userProfile } = useUserProfile()
const location = useLocation()
const [step, setStep] = useState(STEP.SENTIMENT)
const [sentiment, setSentiment] = useState(null)
const [category, setCategory] = useState(null)
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const [isCloud, setIsCloud] = useState(true)
const [githubUrl, setGithubUrl] = useState(null)
useEffect(() => {
if (open) {
setStep(STEP.SENTIMENT)
setSentiment(null)
setCategory(null)
setMessage('')
setSubmitting(false)
setGithubUrl(null)
isCloudInstance().then(setIsCloud)
}
}, [open])
const handleClose = () => {
// Backing out before answering counts as a dismissal for the cooldown.
if (step === STEP.SENTIMENT) onDismiss?.()
onClose()
}
const handleSentiment = async value => {
setSentiment(value)
await markSentiment(value)
if (value !== SENTIMENTS.LOVE) {
setStep(STEP.DETAILS)
return
}
if (Capacitor.isNativePlatform()) {
const requested = await requestStoreReview()
if (requested) {
onClose()
return
}
}
setStep(STEP.WEB_REVIEW)
}
const handleSubmit = async () => {
setSubmitting(true)
const { result, githubUrl: url } = await submitFeedback({
sentiment,
category,
message,
feature: location.pathname,
userProfile,
})
setSubmitting(false)
// Self-hosted feedback is never relayed; hand the user a pre-filled issue
// instead so they choose what gets published.
if (result === SUBMIT_RESULT.SELF_HOSTED) {
setGithubUrl(url)
setStep(STEP.GITHUB)
return
}
setStep(STEP.THANKS)
}
const canSubmit = Boolean(category) || message.trim().length > 0
return (
<ResponsiveModal open={open} onClose={handleClose} size='md'>
<Stack spacing={2}>
{step === STEP.SENTIMENT && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.sentiment.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.sentiment.subtitle')}
</Typography>
<Stack spacing={1} sx={{ mt: 1 }}>
{SENTIMENT_OPTIONS.map(option => (
<Button
key={option.value}
variant='outlined'
color='neutral'
size='lg'
onClick={() => handleSentiment(option.value)}
sx={{
justifyContent: 'flex-start',
fontWeight: 500,
py: 1.5,
}}
startDecorator={
<Box component='span' sx={{ fontSize: '1.4rem' }}>
{option.emoji}
</Box>
}
>
{t(`feedback.sentiment.options.${option.value}`)}
</Button>
))}
</Stack>
<Button variant='plain' color='neutral' onClick={handleClose}>
{t('feedback.later')}
</Button>
</>
)}
{step === STEP.DETAILS && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.details.title')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
{FEEDBACK_CATEGORIES.map(item => (
<Chip
key={item}
variant={category === item ? 'solid' : 'outlined'}
color={category === item ? 'primary' : 'neutral'}
size='lg'
onClick={() => setCategory(category === item ? null : item)}
>
{t(`feedback.categories.${item}`)}
</Chip>
))}
</Box>
<FormControl>
<FormLabel sx={{ fontWeight: 600 }}>
{t('feedback.details.messageLabel')}
</FormLabel>
<Textarea
minRows={3}
maxRows={6}
value={message}
onChange={e => setMessage(e.target.value)}
placeholder={t('feedback.details.messagePlaceholder')}
/>
</FormControl>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{isCloud
? t('feedback.details.contextNote')
: t('feedback.details.contextNoteSelfHosted')}
</Typography>
<Divider />
<Stack direction='row' spacing={1} justifyContent='flex-end'>
<Button variant='plain' color='neutral' onClick={handleClose}>
{t('cancel')}
</Button>
<Button
variant='solid'
color='primary'
disabled={!canSubmit || submitting}
startDecorator={
submitting ? <CircularProgress size='sm' /> : null
}
onClick={handleSubmit}
>
{isCloud
? t('feedback.details.submit')
: t('feedback.details.submitSelfHosted')}
</Button>
</Stack>
</>
)}
{step === STEP.WEB_REVIEW && (
<>
<Typography
level='h4'
sx={{ fontWeight: 600 }}
startDecorator={<Favorite color='error' />}
>
{t('feedback.review.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.review.subtitle')}
</Typography>
<Stack spacing={1} sx={{ mt: 1 }}>
<Button
variant='outlined'
color='neutral'
startDecorator={<GitHub />}
onClick={() => openUrl(storeLinks.github)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.github')}
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Apple />}
onClick={() => openUrl(storeLinks.appStore)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.appStore')}
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Android />}
onClick={() => openUrl(storeLinks.playStore)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.playStore')}
</Button>
</Stack>
<Button variant='plain' color='neutral' onClick={onClose}>
{t('close')}
</Button>
</>
)}
{step === STEP.GITHUB && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.github.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.github.subtitle')}
</Typography>
<Button
variant='solid'
color='primary'
startDecorator={<GitHub />}
onClick={() => {
openUrl(githubUrl)
onClose()
}}
>
{t('feedback.github.open')}
</Button>
<Button variant='plain' color='neutral' onClick={onClose}>
{t('close')}
</Button>
</>
)}
{step === STEP.THANKS && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.thanks.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.thanks.subtitle')}
</Typography>
<Button variant='solid' color='primary' onClick={onClose}>
{t('close')}
</Button>
</>
)}
</Stack>
</ResponsiveModal>
)
}
export default FeedbackModal

View File

@@ -15,28 +15,40 @@ import {
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
<Box
sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}
>
{icon}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>
{label}
</Typography>
{children ?? (
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.primary', fontWeight: 'md' }}
>
{value}
</Typography>
)}
</Box>
</Box>
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
const gracePeriod = 6 * 60 * 60 * 1000
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
return (
<Chip
size='sm'
variant='soft'
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
startDecorator={<Check />}
>
{label}
</Chip>
)
} else {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
>
{label}
</Chip>
)
}
}
@@ -79,7 +117,8 @@ function HistoryDetailModal({ config }) {
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
const isDifferentAssignee =
entry.assignedTo && entry.completedBy !== entry.assignedTo
// updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt =
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
footer={
<ModalActions>
{entry.choreId && (
<Button
variant='outlined'
color='neutral'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</ModalActions>
}
>
{/* Status header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon}
</Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
<Typography
level='title-md'
fontWeight='lg'
sx={{ color: `${statusCfg.color}.plainColor` }}
>
{statusLabel}
</Typography>
</Box>
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
<Stack spacing={0}>
{/* Who performed it */}
{performer && (
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
<DetailRow
icon={<Check sx={{ fontSize: 16 }} />}
label='Performed by'
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
<Avatar
src={performer.image}
alt={performer.displayName}
size='sm'
sx={{ width: 20, height: 20 }}
/>
<Typography level='body-sm' fontWeight='md'>
{performer.displayName}
</Typography>
</Box>
</DetailRow>
)}
{/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && (
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
<DetailRow
icon={<Person sx={{ fontSize: 16 }} />}
label='Assigned to'
value={assignedTo.displayName}
/>
)}
<Divider />
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
{entry.performedAt && (
<DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />}
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
label={
isFirstSchedule
? 'Scheduled on'
: entry.status === 6
? 'Rescheduled on'
: entry.status === 2
? 'Skipped on'
: 'Completed on'
}
value={fmt.dateTime(entry.performedAt)}
/>
)}
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
{entry.dueDate && (
<DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
label={
entry.status === 6
? 'Previous due date'
: entry.status === 5
? 'Was due'
: 'Due date'
}
value={fmt.dateTime(entry.dueDate)}
/>
)}
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
<>
<Divider />
<Box sx={{ pt: 1 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', mb: 0.5 }}
>
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
</Box>
</>
)}
</Stack>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
{entry.choreId && (
<Button
variant='soft'
color='neutral'
size='sm'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
variant='soft'
color='neutral'
size='md'
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/joy'
import { Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function AcknowledgmentModal({ config }) {
@@ -11,42 +12,24 @@ function AcknowledgmentModal({ config }) {
config.onClose()
}, [config])
// Keyboard shortcuts for acknowledgment modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
// Ctrl/Cmd + Y for acknowledge
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
if (
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
event.key === 'Escape' ||
event.key === 'Enter'
) {
event.preventDefault()
handleAction()
return
}
// Escape key for acknowledge
if (event.key === 'Escape') {
event.preventDefault()
handleAction()
return
}
// Enter key for acknowledge
if (event.key === 'Enter') {
event.preventDefault()
handleAction()
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
}
if (config?.isOpen) {
@@ -63,43 +46,33 @@ function AcknowledgmentModal({ config }) {
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='lg'
fullWidth={true}
unmountDelay={250}
onClose={handleAction}
size='sm'
title={config?.title}
>
<Box
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
>
<Typography
level='body-md'
mb={3}
sx={{
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
showCloseButton={false}
footer={
<ModalActions
primary={{
label: config?.acknowledgeText,
color: config?.color || 'primary',
onClick: handleAction,
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
>
{config?.message}
</Typography>
<Box display={'flex'} justifyContent={'center'} mt={2}>
<Button
size='lg'
onClick={handleAction}
color={config?.color || 'primary'}
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
sx={{ minWidth: '120px' }}
>
{config?.acknowledgeText}
</Button>
</Box>
</Box>
/>
}
>
<Typography
level='body-md'
sx={{
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{config?.message}
</Typography>
</ResponsiveModal>
)
}

View File

@@ -1,14 +1,8 @@
import { Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { Box, Button, Chip, Divider, Input, Typography } from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import AppModal from '../../../components/common/AppModal'
import ModalActions from '../../../components/common/ModalActions'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
@@ -18,6 +12,8 @@ import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import { useFilters } from '../../Filters/FilterQueries'
const EMPTY_FILTERS = []
const AdvancedFilterBuilder = ({
isOpen,
onClose,
@@ -33,7 +29,7 @@ const AdvancedFilterBuilder = ({
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [selections, setSelections] = useState(defaultSelections())
const [error, setError] = useState('')
const { data: existedFilters = [] } = useFilters()
const { data: existedFilters = EMPTY_FILTERS } = useFilters()
const filterNameExists = (name, excludeId = null) =>
existedFilters.some(
@@ -55,9 +51,12 @@ const AdvancedFilterBuilder = ({
setSelections(defaultSelections())
}
setError('')
}, [editingFilter, isOpen])
}, [editingFilter, existedFilters, isOpen])
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
const conditions = useMemo(
() => selectionsToConditions(selections),
[selections],
)
const previewChores = useMemo(() => {
if (conditions.length === 0) return []
@@ -100,8 +99,9 @@ const AdvancedFilterBuilder = ({
}
return (
<BottomSheetModal
<AppModal
open={isOpen}
isMobile
onClose={onClose}
maxHeight='92vh'
title={
@@ -109,7 +109,8 @@ const AdvancedFilterBuilder = ({
{editingFilter ? 'Edit Filter' : 'New Filter'}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
{activeConditionCount} condition
{activeConditionCount !== 1 ? 's' : ''}
</Chip>
)}
</Box>
@@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
<ModalActions>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='sm'
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
</Button>
</Box>
</ModalActions>
</Box>
}
>
@@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({
projects={projects}
/>
</Box>
</BottomSheetModal>
</AppModal>
)
}

View File

@@ -1,7 +1,6 @@
import { AttachFile, Close, Image } from '@mui/icons-material'
import { AttachFile, Image } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
List,
ListItem,
@@ -9,6 +8,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers'
@@ -87,16 +87,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
onClose={handleClose}
title='Attachments'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
</Box>
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
}
>
{isLoading ? (

View File

@@ -1,8 +1,9 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Close, Download } from '@mui/icons-material'
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
import { Download } from '@mui/icons-material'
import { Box, CircularProgress, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const openUrl = async url => {
@@ -47,25 +48,15 @@ function AttachmentViewerModal({ config }) {
title={fileName || 'Attachment'}
maxHeight='92vh'
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
<Button
variant='soft'
color='neutral'
startDecorator={<Download />}
onClick={() => downloadUrl(url, fileName)}
disabled={!url}
>
Download
</Button>
</Box>
<ModalActions
secondary={{ label: 'Close', onClick: handleClose }}
primary={{
label: 'Download',
startDecorator: <Download />,
onClick: () => downloadUrl(url, fileName),
disabled: !url,
}}
/>
}
>
<Box
@@ -78,10 +69,7 @@ function AttachmentViewerModal({ config }) {
}}
>
{!imgLoaded && !imgError && (
<CircularProgress
sx={{ position: 'absolute' }}
size='md'
/>
<CircularProgress sx={{ position: 'absolute' }} size='md' />
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>

View File

@@ -1,6 +1,5 @@
import {
Box,
Button,
Checkbox,
CircularProgress,
FormControl,
@@ -13,6 +12,7 @@ import {
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const response = await RestoreBackup(restoreEncryptionKey, backupData)
if (response.ok) {
const data = await response.json()
showNotification({
type: 'success',
message: 'Backup restored successfully. Please refresh the page.',
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
placeholder='Enter a strong encryption key'
/>
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safe - you'll need it to restore your backup
Keep this key safeyou&apos;ll need it to restore your backup
</Typography>
</FormControl>
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
size='lg'
color='primary'
onClick={handleCreateBackup}
loading={loading}
disabled={!encryptionKey.trim()}
fullWidth
>
Create Backup
</Button>
</Box>
</Box>
)
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
size='lg'
color='warning'
onClick={handleRestore}
loading={loading}
disabled={!restoreEncryptionKey.trim() || !backupFile}
fullWidth
>
Restore Backup
</Button>
</Box>
</Box>
)
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
size='lg'
fullWidth={true}
unmountDelay={250}
title='🔄 Backup & Restore'
title='Backup & Restore'
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: loading,
}}
primary={{
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
color: activeTab === 0 ? 'primary' : 'warning',
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
loading,
disabled:
activeTab === 0
? !encryptionKey.trim()
: !restoreEncryptionKey.trim() || !backupFile,
}}
/>
}
>
{loading ? (
<Box

View File

@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/joy'
import { Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function ConfirmationModal({ config }) {
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
[config],
)
// Keyboard shortcuts for confirmation modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
// Ctrl/Cmd + Y for confirm
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault()
handleAction(true)
return
}
// Ctrl/Cmd + X for cancel
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
event.preventDefault()
handleAction(false)
return
}
// Escape key for cancel
if (event.key === 'Escape') {
} else if (event.key === 'Escape') {
event.preventDefault()
handleAction(false)
return
}
// Enter key for confirm
if (event.key === 'Enter') {
} else if (event.key === 'Enter' && config?.color !== 'danger') {
event.preventDefault()
handleAction(true)
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
}
if (config?.isOpen) {
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [config?.isOpen, handleAction])
}, [config?.isOpen, config?.color, handleAction])
const isDestructive = config?.color === 'danger'
return (
<ResponsiveModal
open={config?.isOpen}
onClose={() => handleAction(false)}
size='sm'
unmountDelay={250}
role={isDestructive ? 'alertdialog' : 'dialog'}
title={config?.title}
showCloseButton={false}
closeOnBackdrop={!isDestructive}
footer={
<ModalActions
stackOnMobile
secondary={{
label: config?.cancelText,
onClick: () => handleAction(false),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='X' />
) : undefined,
}}
primary={{
label: config?.confirmText,
color: config?.color || 'primary',
onClick: () => handleAction(true),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
/>
}
>
<Typography level='h4' mb={1}>
{config?.title}
</Typography>
<Typography level='body-md' gutterBottom>
<Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
{config?.message}
</Typography>
<Box display={'flex'} justifyContent={'space-around'} mt={1} gap={1}>
<Button
size='lg'
onClick={() => {
handleAction(true)
}}
fullWidth
color={config?.color || 'primary'}
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
>
{config?.confirmText}
</Button>
<Button
size='lg'
onClick={() => {
handleAction(false)
}}
variant='outlined'
endDecorator={
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
}
>
{config?.cancelText}
</Button>
</Box>
</ResponsiveModal>
)
}
export default ConfirmationModal

View File

@@ -1,12 +1,6 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
@@ -104,16 +98,30 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
password === confirmPassword
return (
<ResponsiveModal open={isOpen} onClose={handleClose}>
<Typography level='h4' mb={2}>
Create Sub Account
</Typography>
<Typography level='body-md' mb={3}>
Create a new sub account. The user will be able to log in using their
combined username and complete tasks assigned to them.
</Typography>
<ResponsiveModal
open={isOpen}
onClose={handleClose}
title='Create Sub Account'
description='Create a login that can complete tasks assigned to this account.'
size='md'
closeOnBackdrop={!isSubmitting}
closeOnEscape={!isSubmitting}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: 'Create Account',
onClick: handleSubmit,
disabled: !isValid || isSubmitting,
loading: isSubmitting,
}}
/>
}
>
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Sub Account Name *
@@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormHelperText>{errors.confirmPassword}</FormHelperText>
)}
</FormControl>
<Box display='flex' justifyContent='space-between' gap={2}>
<Button
size='lg'
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
size='lg'
onClick={handleSubmit}
disabled={!isValid || isSubmitting}
loading={isSubmitting}
sx={{ flex: 1 }}
>
Create Account
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,15 +1,14 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Option,
Select,
Textarea,
Typography,
FormControl,
FormHelperText,
Input,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
@@ -29,7 +28,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
setState(0)
}
}
}, [type])
}, [type, state])
const isValid = () => {
const newErrors = {}
@@ -63,9 +62,20 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='md'
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: onClose,
}}
primary={{
label: currentThing?.id ? 'Update' : 'Create',
onClick: handleSave,
}}
/>
}
>
<FormControl>
<Typography>Name</Typography>
@@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
</FormControl>
<FormControl>
<Typography>Type</Typography>
<Select value={type} sx={{ minWidth: 300 }}>
<Select value={type} onChange={(_, value) => setType(value)}>
{['text', 'number', 'boolean'].map(type => (
<Option value={type} key={type} onClick={() => setType(type)}>
<Option value={type} key={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Option>
))}
@@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
{type === 'boolean' && (
<FormControl>
<Typography>Value</Typography>
<Select sx={{ minWidth: 300 }} value={state}>
<Select value={state} onChange={(_, value) => setState(value)}>
{['true', 'false'].map(value => (
<Option value={value} key={value} onClick={() => setState(value)}>
<Option value={value} key={value}>
{value.charAt(0).toUpperCase() + value.slice(1)}
</Option>
))}
</Select>
</FormControl>
)}
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'}
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,10 +1,10 @@
import { Box, Button, Input } from '@mui/joy'
import { Input } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function DateModal({ isOpen, onClose, onSave, current, title }) {
const { ResponsiveModal } = useResponsiveModal()
const [date, setDate] = useState(
current ? new Date(current).toISOString().split('T')[0] : '',
)
@@ -18,74 +18,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
/>
}
>
<Input
sx={{ mt: 3 }}
autoFocus
type='date'
value={date}
onChange={e => setDate(e.target.value)}
onChange={event => setDate(event.target.value)}
/>
{/* <Box sx={{ mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 1.5, fontWeight: 500 }}>
Quick select:
</Typography>
<Stack direction='row' spacing={1} flexWrap='wrap' useFlexGap>
<Chip
variant='soft'
color='primary'
startDecorator={<Today />}
size='lg'
onClick={() => handleQuickSchedule('today')}
sx={{ cursor: 'pointer' }}
>
Today
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<WbSunny />}
size='lg'
onClick={() => handleQuickSchedule('tomorrow')}
sx={{ cursor: 'pointer' }}
>
Tomorrow
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<Weekend />}
size='lg'
onClick={() => handleQuickSchedule('weekend')}
sx={{ cursor: 'pointer' }}
>
Weekend
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<NextWeek />}
size='lg'
onClick={() => handleQuickSchedule('next-week')}
sx={{ cursor: 'pointer' }}
>
Next week
</Chip>
</Stack>
</Box> */}
<Box display={'flex'} justifyContent={'space-around'} mt={4}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
Save
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default DateModal

View File

@@ -1,12 +1,6 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
@@ -31,7 +25,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
return
}
onSave({
name,
name: currentThing?.name,
type: currentThing?.type,
id: currentThing?.id,
state: state || null,
@@ -43,9 +37,14 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title='Update state'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Update', onClick: handleSave }}
/>
}
>
<FormControl>
<Typography>Value</Typography>
@@ -57,15 +56,6 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
/>
<FormHelperText color='danger'>{errors.state}</FormHelperText>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'}
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,12 +1,5 @@
import {
Avatar,
Box,
Button,
FormControl,
FormLabel,
Grid,
Typography,
} from '@mui/joy'
import { Avatar, Box, FormControl, FormLabel, Grid, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
@@ -33,8 +26,10 @@ const IconPickerModal = ({
fullWidth={true}
unmountDelay={250}
title='Choose Project Icon'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
}
>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<Grid
@@ -58,7 +53,9 @@ const IconPickerModal = ({
border: '2px solid',
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
'&:hover': {
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
borderColor: isCurrentIcon
? 'primary.600'
: 'neutral.300',
},
transition: 'border-color 0.2s',
}}
@@ -96,12 +93,6 @@ const IconPickerModal = ({
})}
</Grid>
</FormControl>
<Box display='flex' justifyContent='center' mt={3}>
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,7 +1,8 @@
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
import { Box, FormControl, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
import { useNotification } from '../../../service/NotificationProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
@@ -90,14 +91,13 @@ function LabelModal({ isOpen, onClose, label }) {
fullWidth={true}
title={label ? 'Edit Label' : 'Add Label'}
footer={
<Box display='flex' justifyContent='space-around' mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{label ? 'Save Changes' : 'Add Label'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{
label: label ? 'Save Changes' : 'Add Label',
onClick: handleSave,
}}
/>
}
>
<Box>
@@ -120,12 +120,18 @@ function LabelModal({ isOpen, onClose, label }) {
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{LABEL_COLORS.map(colorOption => (
<Box
component='button'
type='button'
key={colorOption.value}
aria-label={`Select ${colorOption.name}`}
aria-pressed={color === colorOption.value}
title={colorOption.name}
onClick={() => setColor(colorOption.value)}
sx={{
width: 26,
height: 26,
width: 40,
height: 40,
border: 0,
p: 0,
borderRadius: '50%',
background: colorOption.value,
cursor: 'pointer',

View File

@@ -1,15 +1,33 @@
import { Box, Button, Typography } from '@mui/joy'
import { Box, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
<Typography level='h4' sx={{ mb: 2 }}>
Cancel Subscription
</Typography>
<Box sx={{ p: 2 }}>
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
title='Cancel Subscription'
footer={
<ModalActions
stackOnMobile
tertiary={{ label: 'Dismiss', onClick: onClose }}
secondary={{
label: "I'll cancel from my app store",
onClick: onClose,
}}
primary={{
label: 'Cancel desktop subscription',
color: 'danger',
onClick: () => onClose('desktop'),
}}
/>
}
>
<Box>
<Typography level='body-md' mb={3}>
To cancel your subscription, please follow the instructions for your
platform (you should cancel through the same platform you used to
@@ -84,8 +102,8 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
<strong>Important:</strong> You must cancel your subscription
through the same platform where you originally subscribed. If you
subscribed through the iOS App Store or Google Play Store (even if
you're now using the web/desktop version), you must cancel through
that original platform using the instructions above.
you&apos;re now using the web/desktop version), you must cancel
through that original platform using the instructions above.
</Typography>
</Box>
@@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
Your subscription will remain active until the end of your current
billing period.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button size='lg' onClick={onClose} variant='outlined' fullWidth>
I'll cancel from my app store
</Button>
<Button
size='lg'
onClick={() => onClose('desktop')}
variant='solid'
color='danger'
fullWidth
>
I subscribed via desktop - Cancel now
</Button>
<Button size='lg' onClick={onClose} fullWidth>
Dismiss
</Button>
</Box>
</Box>
</ResponsiveModal>
)

View File

@@ -1,15 +1,15 @@
import {
Alert,
Box,
Button,
FormControl,
FormLabel,
Switch,
Textarea,
Typography,
Alert,
Box,
FormControl,
FormLabel,
Switch,
Textarea,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
@@ -108,19 +108,34 @@ function NudgeModal({ config }) {
fullWidth={true}
unmountDelay={250}
title='Send Nudge'
description='Send a gentle reminder to the people assigned to this task.'
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: () => handleAction(false),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='X' />
) : undefined,
}}
primary={{
label: 'Send Nudge',
onClick: () => handleAction(true),
disabled: !isOfficialInstance,
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
/>
}
>
<Typography level='body-md' mb={2}>
Send a gentle reminder to the assignee about this task. You can
customize the message and choose who gets notified.
</Typography>
{!isOfficialInstance && (
<Alert color='warning' sx={{ mb: 2 }}>
<Typography level='body-sm'>
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
Since you're using a self-hosted instance, nudges will requires you
to setup Google cloud account and Firebase Cloud Messaging (FCM).
and build the Android or the iOS app by yourself.
Since you&apos;re using a self-hosted instance, nudges will requires
you to setup Google cloud account and Firebase Cloud Messaging
(FCM). and build the Android or the iOS app by yourself.
<br />
Will update if we come up with a solution to make this easier for to
configure. for selfhosters
@@ -152,33 +167,6 @@ function NudgeModal({ config }) {
onChange={e => setNotifyAllAssignees(e.target.checked)}
/>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} gap={1}>
<Button
size='lg'
onClick={() => handleAction(true)}
disabled={!isOfficialInstance}
fullWidth
color='primary'
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
>
Send Nudge
</Button>
<Button
size='lg'
onClick={() => handleAction(false)}
variant='outlined'
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
}
>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,27 +1,20 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function PassowrdChangeModal({ isOpen, onClose }) {
function PasswordChangeModal({ isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState(null)
const [passwordTouched, setPasswordTouched] = useState(false)
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false)
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [passwordError, setPasswordError] = React.useState(false)
const [passwordTouched, setPasswordTouched] = React.useState(false)
const [confirmPasswordTouched, setConfirmPasswordTouched] =
React.useState(false)
useEffect(() => {
if (!passwordTouched || !confirmPasswordTouched) {
return
} else if (password !== confirmPassword) {
if (!passwordTouched || !confirmPasswordTouched) return
if (password !== confirmPassword) {
setPasswordError('Passwords do not match')
} else if (password.length < 8) {
setPasswordError('Password must be at least 8 characters')
@@ -32,90 +25,66 @@ function PassowrdChangeModal({ isOpen, onClose }) {
}
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
const handleAction = isConfirmed => {
if (!isConfirmed) {
onClose(null)
return
}
onClose(password)
}
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
const canSubmit =
passwordTouched &&
confirmPasswordTouched &&
password.length >= 8 &&
password === confirmPassword &&
passwordError == null
return (
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
onClose={() => handleAction(false)}
size='sm'
title='Change Password'
description='Choose a password between 8 and 64 characters.'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
primary={{
label: 'Change Password',
disabled: !canSubmit,
onClick: () => handleAction(true),
}}
/>
}
>
<Typography level='body-md' gutterBottom>
Please enter your new password.
</Typography>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
New Password
</Typography>
<FormControl sx={{ mb: 2 }}>
<Typography level='body-sm'>New password</Typography>
<Input
margin='normal'
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
placeholder='Enter password (8-64 characters)'
autoComplete='new-password'
placeholder='Enter password'
value={password}
onChange={e => {
onChange={event => {
setPasswordTouched(true)
setPassword(e.target.value)
setPassword(event.target.value)
}}
/>
</FormControl>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
Confirm Password
</Typography>
<FormControl error={Boolean(passwordError)}>
<Typography level='body-sm'>Confirm password</Typography>
<Input
margin='normal'
required
fullWidth
name='confirmPassword'
label='confirmPassword'
type='password'
id='confirmPassword'
autoComplete='new-password'
placeholder='Repeat password'
value={confirmPassword}
onChange={e => {
onChange={event => {
setConfirmPasswordTouched(true)
setConfirmPassword(e.target.value)
setConfirmPassword(event.target.value)
}}
/>
<FormHelperText>{passwordError}</FormHelperText>
{passwordError && <FormHelperText>{passwordError}</FormHelperText>}
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
disabled={passwordError != null}
onClick={() => {
handleAction(true)
}}
fullWidth
sx={{ mr: 1 }}
>
Change Password
</Button>
<Button
size='lg'
onClick={() => {
handleAction(false)
}}
variant='outlined'
>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default PassowrdChangeModal
export default PasswordChangeModal

View File

@@ -10,6 +10,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import PROJECT_COLORS, {
getTextColorFromBackgroundColor,
@@ -124,28 +125,23 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
unmountDelay={250}
fullWidth={true}
title={project ? 'Edit Project' : 'Create New Project'}
closeOnBackdrop={!isSubmitting}
closeOnEscape={!isSubmitting}
footer={
<Box display='flex' justifyContent='space-around' gap={1}>
<Button
type='submit'
form='project-form'
loading={isSubmitting}
disabled={!projectName.trim() || isSubmitting}
fullWidth
size='lg'
>
{project ? 'Update' : 'Create'}
</Button>
<Button
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
fullWidth
size='lg'
>
Cancel
</Button>
</Box>
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: project ? 'Update' : 'Create',
type: 'submit',
form: 'project-form',
loading: isSubmitting,
disabled: !projectName.trim() || isSubmitting,
}}
/>
}
>
<form onSubmit={handleSubmit} id='project-form'>

View File

@@ -1,5 +1,6 @@
import { Box, Button, Option, Select } from '@mui/joy'
import React from 'react'
import { Option, Select } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function SelectModal({
@@ -12,8 +13,8 @@ function SelectModal({
placeholder,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [selected, setSelected] = useState(null)
const [selected, setSelected] = React.useState(null)
const handleSave = () => {
onSave(options.find(item => item.id === selected))
onClose()
@@ -23,33 +24,33 @@ function SelectModal({
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{
label: 'Save',
onClick: handleSave,
disabled: selected == null,
}}
/>
}
>
<Select placeholder={placeholder}>
{options.map((item, index) => (
<Option
value={item.id}
key={item[displayKey]}
onClick={() => {
setSelected(item.id)
}}
>
<Select
autoFocus
placeholder={placeholder}
value={selected}
onChange={(_, value) => setSelected(value)}
>
{options.map(item => (
<Option value={item.id} key={item[displayKey]}>
{item[displayKey]}
</Option>
))}
</Select>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
Save
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default SelectModal

View File

@@ -1,5 +1,6 @@
import { Box, Button, Textarea } from '@mui/joy'
import { Textarea } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function TextModal({
@@ -12,7 +13,6 @@ function TextModal({
cancelText,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [text, setText] = useState(current)
const handleSave = () => {
@@ -24,28 +24,25 @@ function TextModal({
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='md'
title={title}
footer={
<ModalActions
secondary={{ label: cancelText || 'Cancel', onClick: onClose }}
primary={{ label: okText || 'Save', onClick: handleSave }}
/>
}
>
<Textarea
autoFocus
placeholder='Type in here…'
value={text}
onChange={e => setText(e.target.value)}
minRows={2}
maxRows={4}
sx={{ minWidth: 300 }}
onChange={event => setText(event.target.value)}
minRows={3}
maxRows={8}
/>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{okText ? okText : 'Save'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{cancelText ? cancelText : 'Cancel'}
</Button>
</Box>
</ResponsiveModal>
)
}
export default TextModal

View File

@@ -13,6 +13,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useNotification } from '../../../service/NotificationProvider'
import {
@@ -59,7 +60,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
}
}, [isOpen, timerData])
const formatTime = seconds => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -304,10 +304,37 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
title='Timer Details'
footer={
<ModalActions
tertiary={
!loading && timerData && !editingSessions[timerData.id]
? {
label: 'Delete',
color: 'danger',
onClick: () => confirmDeleteSession(timerData.id),
}
: undefined
}
secondary={{ label: 'Close', onClick: handleClose }}
primary={
!loading && timerData
? editingSessions[timerData.id]
? {
label: 'Save',
onClick: () => saveSession(timerData.id),
loading,
}
: {
label: 'Edit',
startDecorator: <Edit />,
onClick: () => startEditingSession(),
}
: undefined
}
/>
}
>
<Typography level='h4'>Timer Details</Typography>
{loading && (
<Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data...
@@ -919,56 +946,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
)}
</Box>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='outlined' onClick={handleClose} color='neutral'>
Cancel
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{/* Action buttons on the right */}
{!loading && timerData && !editingSessions[timerData.id] && (
<>
<Button
size='sm'
variant='outlined'
color='danger'
onClick={() => confirmDeleteSession(timerData.id)}
>
Delete
</Button>
<Button
variant='outlined'
startDecorator={<Edit />}
onClick={() => startEditingSession()}
>
Edit
</Button>
</>
)}
{/* Save button when editing */}
{!loading && timerData && editingSessions[timerData.id] && (
<Button
variant='solid'
color='primary'
onClick={() => saveSession(timerData.id)}
loading={loading}
>
Save
</Button>
)}
</Box>
</Box>
</ResponsiveModal>
<ConfirmationModal config={confirmDeleteConfig} />

View File

@@ -1,6 +1,5 @@
import {
Box,
Button,
Card,
CircularProgress,
FormControl,
@@ -10,13 +9,13 @@ import {
Select,
Typography,
} from '@mui/joy'
import { data } from 'autoprefixer'
import { useCallback, useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useNavigate } from 'react-router-dom'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
function UserDeletionModal({ isOpen, onClose, userProfile }) {
function UserDeletionModal({ isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
@@ -70,7 +69,8 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
setError(data.error || 'Failed to check deletion requirements')
}
} catch (err) {
setError(data.error || 'Failed to check deletion requirements')
console.error('Failed to check deletion requirements:', err)
setError('Failed to check deletion requirements')
} finally {
setLoading(false)
}
@@ -119,6 +119,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
setError(data.message || 'Failed to delete account')
}
} catch (err) {
console.error('Failed to delete account:', err)
setError('Failed to delete account')
} finally {
setLoading(false)
@@ -148,10 +149,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
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:
@@ -193,30 +190,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
{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:
@@ -253,29 +231,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
</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.
@@ -296,7 +256,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
</FormControl>
<FormControl sx={{ mb: 3 }}>
<FormLabel>Type "DELETE" to confirm</FormLabel>
<FormLabel>Type &quot;DELETE&quot; to confirm</FormLabel>
<Input
value={confirmation}
onChange={e => setConfirmation(e.target.value)}
@@ -309,21 +269,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
{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>
</>
)
@@ -345,8 +290,42 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
open={isOpen}
onClose={() => handleClose(false)}
size='lg'
fullWidth={true}
title='Delete Account'
title={
step === 1
? 'Delete Account'
: step === 2
? 'Transfer Circle Ownership'
: 'Final Confirmation'
}
role={step === 3 ? 'alertdialog' : 'dialog'}
closeOnBackdrop={false}
footer={
!loading && (
<ModalActions
stackOnMobile
secondary={{
label: 'Cancel',
onClick: () => handleClose(false),
}}
primary={{
label: step === 3 ? 'Delete Account' : 'Continue',
color: step === 3 ? 'danger' : 'primary',
onClick:
step === 1
? checkDeletionRequirements
: step === 2
? proceedToConfirmation
: executeUserDeletion,
disabled:
step === 1
? !password
: step === 2
? circlesRequiringTransfer.length !== transferOptions.length
: !password || confirmation !== 'DELETE',
}}
/>
)
}
>
{loading && step === 1 ? (
<Box

View File

@@ -1,4 +1,5 @@
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
@@ -11,6 +12,9 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
size='lg'
fullWidth={true}
title='Select User'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
}
>
<List sx={{ mb: 2 }}>
{performers.map(user => (
@@ -38,11 +42,6 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button size='lg' variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -5,16 +5,9 @@ import {
ErrorOutline,
Nfc,
} from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
Input,
Switch,
Typography,
} from '@mui/joy'
import { Box, IconButton, Input, Switch, Typography } from '@mui/joy'
import { useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
@@ -29,9 +22,6 @@ const pulseKeyframes = `
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.nfc-pulse-ring { animation: none !important; }
}
`
function NFCIcon({ status }) {
@@ -55,7 +45,6 @@ function NFCIcon({ status }) {
{isWaiting && (
<>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
@@ -63,10 +52,10 @@ function NFCIcon({ status }) {
border: '2px solid',
borderColor: 'primary.400',
animation: 'nfc-pulse 1.8s ease-out infinite',
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
/>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
@@ -74,6 +63,7 @@ function NFCIcon({ status }) {
border: '2px solid',
borderColor: 'primary.300',
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
/>
</>
@@ -215,27 +205,37 @@ function WriteNFCModal({ config }) {
return (
<>
<style>{pulseKeyframes}</style>
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<ResponsiveModal
open={config?.isOpen}
onClose={handleClose}
title={title}
description={subtitle}
closeOnBackdrop={!isWaiting}
closeOnEscape={!isWaiting}
footer={
isSuccess ? (
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
) : isWaiting ? (
<ModalActions
secondary={{ label: 'Cancel', onClick: handleCancel }}
/>
) : (
<ModalActions
secondary={{ label: 'Cancel', onClick: handleClose }}
primary={{
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
onClick: writeToNFC,
disabled: nfcStatus === 'writing',
startDecorator: <Nfc />,
}}
/>
)
}
>
<Box sx={{ px: 0.5, pb: 1 }}>
{/* Icon */}
<NFCIcon status={nfcStatus} />
{/* Heading */}
<Typography
level='title-lg'
textAlign='center'
sx={{ mb: 0.75, fontWeight: 600 }}
>
{title}
</Typography>
<Typography
level='body-sm'
textAlign='center'
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
>
{subtitle}
</Typography>
{/* Idle / Error: URL + toggle + CTA */}
{!isWaiting && !isSuccess && (
<>
@@ -264,6 +264,7 @@ function WriteNFCModal({ config }) {
}}
endDecorator={
<IconButton
aria-label='Copy tag URL'
size='sm'
variant='plain'
color={copied ? 'success' : 'neutral'}
@@ -310,55 +311,8 @@ function WriteNFCModal({ config }) {
size='sm'
/>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size='lg'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
onClick={isError ? handleClose : handleClose}
>
Cancel
</Button>
<Button
size='lg'
sx={{ flex: 1 }}
onClick={writeToNFC}
disabled={nfcStatus === 'writing'}
startDecorator={
nfcStatus === 'writing' ? (
<CircularProgress size='sm' />
) : (
<Nfc />
)
}
>
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
</Button>
</Box>
</>
)}
{/* Waiting state */}
{isWaiting && (
<Button
size='lg'
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
)}
{/* Success state */}
{isSuccess && (
<Button size='lg' fullWidth onClick={handleClose}>
Done
</Button>
)}
</Box>
</ResponsiveModal>
</>

View File

@@ -2,10 +2,8 @@ import { CreditCard, Person, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
Chip,
Divider,
FormControl,
FormLabel,
IconButton,
@@ -15,6 +13,7 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions.jsx'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
@@ -53,22 +52,28 @@ function RedeemPointsModal({ config }) {
const canRedeem = points > 0 && points <= config.available
return (
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose} size='md'>
{/* Header Section */}
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='md'
title='Redeem Points'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config?.onClose }}
primary={{
label: 'Redeem',
startDecorator: <CreditCard />,
disabled: !canRedeem,
onClick: () =>
config?.onSave({
points: Number(points),
userId: config?.user?.userId,
}),
}}
/>
}
>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CreditCard
sx={{
fontSize: '1.5rem',
}}
/>
<Typography level='h4' sx={{ fontWeight: 600 }}>
Redeem Points
</Typography>
</Box>
<Divider />
{/* User Info Card */}
<Card
variant='soft'
@@ -155,6 +160,7 @@ function RedeemPointsModal({ config }) {
{predefinedPoints.map(point => (
<IconButton
key={point}
aria-label={`Add ${point} points`}
variant='outlined'
disabled={points + point > config?.available}
onClick={() => addPredefinedPoints(point)}
@@ -209,43 +215,6 @@ function RedeemPointsModal({ config }) {
</Typography>
</Card>
)}
<Divider />
{/* Action Buttons */}
<Stack direction='row' spacing={2}>
<Button
size='lg'
onClick={config?.onClose}
variant='outlined'
color='neutral'
fullWidth
sx={{
'&:hover': {
backgroundColor: 'neutral.50',
},
}}
>
Cancel
</Button>
<Button
size='lg'
onClick={() =>
config?.onSave({
points: Number(points),
userId: config?.user?.userId,
})
}
disabled={!canRedeem}
fullWidth
startDecorator={<CreditCard />}
sx={{
transition: 'all 0.2s ease',
}}
>
Redeem
</Button>
</Stack>
</Stack>
</ResponsiveModal>
)

View File

@@ -222,7 +222,10 @@ const ProjectView = () => {
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
const { setSelectedProjectWithCache } = useProjectFilter(projectsData)
const { setSelectedProjectWithCache } = useProjectFilter(
projectsData,
!projectsLoading,
)
const navigate = useNavigate()
const [userProjects, setUserProjects] = useState([])

View File

@@ -1,11 +1,20 @@
import { LocalNotifications } from '@capacitor/local-notifications'
import { Refresh, Token } from '@mui/icons-material'
import { Refresh, Star, Token } from '@mui/icons-material'
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useSSEContext } from '../../hooks/useSSEContext'
import { useUserProfile } from '../../queries/UserQueries'
import {
evaluatePromptEligibility,
isFeedbackSubmissionConfigured,
isRawChatWebhookConfigured,
requestStoreReview,
resetFeedbackState,
setDevForcedPrompt,
} from '../../service/FeedbackService'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { commandQueue } from '../../utils/CommandQueue'
@@ -13,11 +22,13 @@ import { RefreshToken } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import { syncEngine } from '../../utils/SyncEngine'
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
import FeedbackModal from '../Modals/FeedbackModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const DeveloperSettings = () => {
const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
@@ -41,6 +52,8 @@ const DeveloperSettings = () => {
const [scheduledNotifications, setScheduledNotifications] = useState([])
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
const [isResettingSync, setIsResettingSync] = useState(false)
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackEligibility, setFeedbackEligibility] = useState(null)
const [syncDiagnostics, setSyncDiagnostics] = useState({
cursor: null,
lastSync: null,
@@ -362,6 +375,45 @@ const DeveloperSettings = () => {
)
}
const refreshFeedbackEligibility = useCallback(async () => {
const result = await evaluatePromptEligibility({ userProfile })
setFeedbackEligibility(result)
return result
}, [userProfile])
useEffect(() => {
refreshFeedbackEligibility()
}, [refreshFeedbackEligibility])
const handleForceFeedbackPrompt = async () => {
await setDevForcedPrompt(true)
await refreshFeedbackEligibility()
showNotification({
type: 'success',
message: 'Next visit to My Chores will show the prompt after ~4s',
})
}
const handleResetFeedbackState = async () => {
await resetFeedbackState()
await refreshFeedbackEligibility()
showNotification({
type: 'success',
message: 'Feedback state cleared (completions, cooldown, opt-out)',
})
}
const handleRequestStoreReview = async () => {
const requested = await requestStoreReview()
await refreshFeedbackEligibility()
showNotification({
type: requested ? 'success' : 'warning',
message: requested
? 'Review requested. The OS decides whether to actually show it.'
: 'Not available — native platform only.',
})
}
const getNotificationStatusColor = scheduleTime => {
if (!scheduleTime) return 'neutral'
@@ -642,6 +694,166 @@ const DeveloperSettings = () => {
</Box>
</Card>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
}}
>
<Typography level='title-lg'>Feedback & Review Prompt</Typography>
<Button
size='sm'
variant='soft'
startDecorator={<Refresh />}
onClick={refreshFeedbackEligibility}
>
Refresh
</Button>
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Prompt Eligibility</Typography>
<Typography level='body-sm'>
Would auto-show:{' '}
<Chip
size='sm'
variant='soft'
color={feedbackEligibility?.eligible ? 'success' : 'neutral'}
>
{feedbackEligibility?.eligible ? 'Yes' : 'No'}
</Chip>
{feedbackEligibility?.forced && (
<Chip size='sm' variant='soft' color='warning' sx={{ ml: 1 }}>
Forced
</Chip>
)}
</Typography>
{feedbackEligibility?.blockers?.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{feedbackEligibility.blockers.map(blocker => (
<Typography key={blocker} level='body-xs' color='neutral'>
{blocker}
</Typography>
))}
</Box>
)}
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>State</Typography>
<Typography level='body-sm'>
Completions counted:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.completions ?? 'N/A'}
</Chip>
</Typography>
<Typography level='body-sm'>
Last sentiment:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.lastSentiment ?? 'None'}
</Chip>
</Typography>
<Typography level='body-sm'>
Dismissals:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.dismissCount ?? 0}
</Chip>
</Typography>
<Typography level='body-sm'>
Opted out:{' '}
<Chip
size='sm'
variant='soft'
color={
feedbackEligibility?.state?.optedOut ? 'danger' : 'success'
}
>
{feedbackEligibility?.state?.optedOut ? 'Yes' : 'No'}
</Chip>
</Typography>
<Typography level='body-xs' color='neutral'>
Last prompted:{' '}
{formatDateTime(feedbackEligibility?.state?.lastPromptedAt)}
{feedbackEligibility?.state?.lastPromptedVersion
? ` on ${feedbackEligibility.state.lastPromptedVersion}`
: ''}
</Typography>
<Typography level='body-xs' color='neutral'>
Review requested:{' '}
{formatDateTime(feedbackEligibility?.state?.reviewRequestedAt)}
</Typography>
<Typography level='body-xs' color='neutral'>
Current version: {feedbackEligibility?.version ?? 'N/A'} · Webhook{' '}
{isFeedbackSubmissionConfigured()
? 'configured'
: 'NOT configured (submissions log to console)'}
</Typography>
{isRawChatWebhookConfigured() && (
<Typography level='body-xs' color='danger'>
VITE_FEEDBACK_WEBHOOK_URL points straight at a Discord/Slack
webhook. Discord rejects that with 50006, and the URL ships
inside the public bundle deploy workers/feedback and point the
variable at the Worker.
</Typography>
)}
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Actions</Typography>
<Typography level='body-xs' color='neutral'>
&quot;Force next prompt&quot; bypasses every gate, then open My
Chores to see the automatic trigger.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
size='sm'
variant='soft'
onClick={() => setFeedbackModalOpen(true)}
>
Open Flow
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
onClick={handleForceFeedbackPrompt}
>
Force Next Prompt
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Star />}
onClick={handleRequestStoreReview}
disabled={!isNativePlatform}
>
Request Store Review
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleResetFeedbackState}
>
Reset State
</Button>
</Box>
</Box>
</Box>
</Card>
{isNativePlatform && (
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -990,6 +1202,14 @@ const DeveloperSettings = () => {
</Box>
</Card>
<FeedbackModal
open={feedbackModalOpen}
onClose={() => {
setFeedbackModalOpen(false)
refreshFeedbackEligibility()
}}
/>
<ConfirmationModal config={confirmModalConfig} />
</div>
)

View File

@@ -1,23 +1,13 @@
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
import {
Alert,
Box,
Button,
Card,
Input,
Modal,
ModalClose,
ModalDialog,
Stack,
Typography,
} from '@mui/joy'
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
import QRCode from 'qrcode'
import { useEffect, useState } from 'react'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import {
ConfirmMFA,
DisableMFA,
GetMFAStatus,
RegenerateBackupCodes,
SetupMFA,
} from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
@@ -168,24 +158,6 @@ const MFASettings = () => {
}
}
const handleRegenerateBackupCodes = async () => {
try {
setError('')
const response = await RegenerateBackupCodes()
if (response.ok) {
const data = await response.json()
setBackupCodes(data.backupCodes)
setBackupCodesModalOpen(true)
setSuccess('New backup codes have been generated!')
} else {
setError('Failed to regenerate backup codes. Please try again.')
}
} catch (error) {
setError('Failed to regenerate backup codes. Please try again.')
console.error('Error regenerating backup codes:', error)
}
}
const closeSetupModal = () => {
setSetupModalOpen(false)
setSetupStep(1)
@@ -263,7 +235,7 @@ const MFASettings = () => {
</Box>
</Box>
</Card>
{/*
{/*
{mfaEnabled && (
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
@@ -290,193 +262,105 @@ const MFASettings = () => {
)} */}
{/* Setup MFA Modal */}
<Modal open={setupModalOpen} onClose={closeSetupModal}>
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Set up Multi-Factor Authentication
</Typography>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 1:</strong> Scan the QR code below with your
authenticator app (Google Authenticator, Authy, etc.)
</Typography>
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use
the manual entry key below.
</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>
<Button
color='primary'
onClick={() => setSetupStep(2)}
startDecorator={<Smartphone />}
>
I've added the account to my app
</Button>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
</Typography>
<Input
placeholder='Enter 6-digit code'
value={verificationCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
}
}}
onChange={e => setVerificationCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={() => setSetupStep(1)}
sx={{ flex: 1 }}
>
Back
</Button>
<Button
color='primary'
onClick={handleConfirmMFA}
disabled={verificationCode.length !== 6}
sx={{ flex: 1 }}
>
Verify & Enable
</Button>
</Box>
</Stack>
)}
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'>
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
<Button color='primary' onClick={closeSetupModal}>
I've saved my backup codes
</Button>
</Stack>
)}
</ModalDialog>
</Modal>
{/* Disable MFA Modal */}
<Modal open={disableModalOpen} onClose={closeDisableModal}>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Disable Multi-Factor Authentication
</Typography>
<AppModal
open={setupModalOpen}
onClose={closeSetupModal}
title='Set up Multi-Factor Authentication'
size='md'
footer={
setupStep === 1 ? (
<ModalActions
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
primary={{
label: "I've added the account",
onClick: () => setSetupStep(2),
startDecorator: <Smartphone />,
}}
/>
) : setupStep === 2 ? (
<ModalActions
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
primary={{
label: 'Verify & Enable',
onClick: handleConfirmMFA,
disabled: verificationCode.length !== 6,
}}
/>
) : (
<ModalActions
primary={{
label: "I've saved my backup codes",
onClick: closeSetupModal,
}}
/>
)
}
>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
<Typography level='body-md'>
<strong>Step 1:</strong> Scan the QR code below with your
authenticator app (Google Authenticator, Authy, etc.)
</Typography>
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use the
manual entry key below.
</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
Enter a verification code from your authenticator app to
confirm:
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
</Typography>
<Input
placeholder='Enter 6-digit code'
value={disableCode}
value={verificationCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
}
}}
onChange={e => setDisableCode(e.target.value)}
onChange={e => setVerificationCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
@@ -491,44 +375,25 @@ const MFASettings = () => {
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={closeDisableModal}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
color='danger'
onClick={handleDisableMFA}
disabled={disableCode.length !== 6}
sx={{ flex: 1 }}
>
Disable MFA
</Button>
</Box>
</Stack>
</ModalDialog>
</Modal>
{/* Backup Codes Modal */}
<Modal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
New Backup Codes
</Typography>
)}
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new
codes in a safe place. Each code can only be used once.
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
</Typography>
</Alert>
@@ -545,16 +410,107 @@ const MFASettings = () => {
))}
</Box>
</Card>
<Button
color='primary'
onClick={() => setBackupCodesModalOpen(false)}
>
I've saved my backup codes
</Button>
</Stack>
</ModalDialog>
</Modal>
)}
</AppModal>
{/* Disable MFA Modal */}
<AppModal
open={disableModalOpen}
onClose={closeDisableModal}
title='Disable Multi-Factor Authentication'
size='sm'
role='alertdialog'
closeOnBackdrop={false}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
primary={{
label: 'Disable MFA',
color: 'danger',
onClick: handleDisableMFA,
disabled: disableCode.length !== 6,
}}
/>
}
>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
</Typography>
</Alert>
<Typography level='body-md'>
Enter a verification code from your authenticator app to confirm:
</Typography>
<Input
placeholder='Enter 6-digit code'
value={disableCode}
size='lg'
onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
}
}}
onChange={e => setDisableCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
</Stack>
</AppModal>
{/* Backup Codes Modal */}
<AppModal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
title='New Backup Codes'
size='sm'
footer={
<ModalActions
primary={{
label: "I've saved my backup codes",
onClick: () => setBackupCodesModalOpen(false),
}}
/>
}
>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new codes
in a safe place. Each code can only be used once.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
</Stack>
</AppModal>
</div>
</SettingsLayout>
)

View File

@@ -7,13 +7,13 @@ import {
Input,
Typography,
} from '@mui/joy'
import Modal from '@mui/joy/Modal'
import ModalDialog from '@mui/joy/ModalDialog'
import { useQueryClient } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop'
import { useTranslation } from 'react-i18next'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
@@ -141,9 +141,7 @@ const ProfileSettings = () => {
return (
<SettingsLayout title={t('profile.title')}>
<div className='grid gap-4 py-4' id='profile'>
<Typography level='body-md'>
{t('profile.description')}
</Typography>
<Typography level='body-md'>{t('profile.description')}</Typography>
<Card
sx={{
display: 'flex',
@@ -153,7 +151,10 @@ const ProfileSettings = () => {
maxWidth: 400,
}}
>
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
<Avatar
src={resolvePhotoURL(userProfile?.image)}
sx={{ width: 64, height: 64 }}
/>
<Box sx={{ flex: 1 }}>
<Button
variant='soft'
@@ -173,74 +174,56 @@ const ProfileSettings = () => {
/>
</Box>
</Card>
<Modal
<AppModal
open={showCropper}
onClose={() => {
setShowCropper(false)
setSelectedFile(null)
}}
>
<ModalDialog
layout='center'
sx={{
width: 360,
maxWidth: '90vw',
bgcolor: '#fff',
borderRadius: 2,
boxShadow: 24,
p: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 420,
}}
>
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%',
p: 2,
mt: 2,
}}
>
<Button
onClick={handleCropSave}
loading={isUploading}
variant='solid'
color='primary'
size='md'
sx={{ mr: 1 }}
>
{t('profile.save')}
</Button>
<Button
onClick={() => {
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
size='sm'
closeOnBackdrop={!isUploading}
closeOnEscape={!isUploading}
footer={
<ModalActions
secondary={{
label: t('profile.cancel'),
disabled: isUploading,
onClick: () => {
setShowCropper(false)
setSelectedFile(null)
}}
variant='soft'
color='neutral'
>
{t('profile.cancel')}
</Button>
</Box>
</ModalDialog>
</Modal>
},
}}
primary={{
label: t('profile.save'),
loading: isUploading,
onClick: handleCropSave,
}}
/>
}
>
<Box
sx={{
width: '100%',
maxWidth: 320,
aspectRatio: '1',
position: 'relative',
mx: 'auto',
}}
>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
</AppModal>
<Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
{t('profile.displayName')}

View File

@@ -5,6 +5,7 @@ import {
Circle,
Code,
FamilyRestroom,
Feedback,
Language,
Notifications,
Palette,
@@ -31,16 +32,19 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
import FeedbackModal from '../Modals/FeedbackModal'
const SettingsOverview = () => {
const { t } = useTranslation('settings')
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [feedbackOpen, setFeedbackOpen] = useState(false)
const settingsCards = [
{
@@ -122,10 +126,21 @@ const SettingsOverview = () => {
description: t('overview.sections.developer.description'),
icon: <Code />,
},
{
id: 'feedback',
title: t('overview.sections.feedback.title'),
description: t('overview.sections.feedback.description'),
icon: <Feedback />,
onSelect: () => setFeedbackOpen(true),
},
]
const handleCardClick = settingId => {
navigate(`/settings/${settingId}`)
const handleCardClick = setting => {
if (setting.onSelect) {
setting.onSelect()
return
}
navigate(`/settings/${setting.id}`)
}
// Filter settings based on user type
@@ -303,7 +318,7 @@ const SettingsOverview = () => {
{getAvailableSettings().map((setting, index) => (
<ListItem key={setting.id} sx={{ p: 0 }}>
<ListItemButton
onClick={() => handleCardClick(setting.id)}
onClick={() => handleCardClick(setting)}
sx={{
'&:hover': {
backgroundColor: 'background.level1',
@@ -367,6 +382,11 @@ const SettingsOverview = () => {
))}
</List>
</Box>
<FeedbackModal
open={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
</Container>
)
}

View File

@@ -105,8 +105,10 @@ const TermsView = () => {
</ul>
<h2>5. Acceptable Use Policy</h2>
<p><em>Applies to both Cloud and Self-Hosted Services</em></p>
<p>
<em>Applies to both Cloud and Self-Hosted Services</em>
</p>
<h3>You may not use our services to:</h3>
<ul>
<li>Violate any applicable laws or regulations</li>

View File

@@ -4,7 +4,7 @@ import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
@@ -24,6 +24,7 @@ import {
import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService'
@@ -42,7 +43,7 @@ import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask'
import { buildChorePayload } from './VoiceToTask/parseVoiceTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
@@ -75,6 +76,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const { data: userProfile } = useUserProfile()
// Stable identities for the voice panel: these queries are undefined while
// loading, and a fresh [] each render would churn the panel's parse context
const voiceLabels = useMemo(() => userLabels || [], [userLabels])
const voiceMembers = useMemo(() => circleMembers?.res || [], [circleMembers])
const handleCreateLabel = useCallback(
name => {
const color =
@@ -144,6 +150,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const [llmAvailable, setLlmAvailable] = useState(false)
const [showVoice, setShowVoice] = useState(false)
const [voiceAvailable, setVoiceAvailable] = useState(false)
// Voice capture state, reported up by VoicePanel so the modal footer owns
// the confirm action instead of the panel having its own button row
const [voiceState, setVoiceState] = useState({
segments: [],
isListening: false,
})
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
// Same arrangement for the scan panel: it reports the action for its
// current phase and the modal footer renders it
const [scanState, setScanState] = useState({
phase: 'idle',
primaryAction: null,
})
const { isNativeScanner } = useDocumentScanner()
useEffect(() => {
@@ -670,6 +689,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
// Multiple voice-captured tasks: they were reviewed as cards in the panel,
// so create them all directly.
const handleVoiceCreateMany = async parsedTasks => {
setCreatingVoiceTasks(true)
const notificationTemplates = getDefaultNotification()
for (const parsed of parsedTasks) {
const chore = buildChorePayload(parsed, {
@@ -693,13 +713,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
console.error('Error creating voice task:', error)
}
}
setCreatingVoiceTasks(false)
handleCloseModal(false)
}
// Footer confirm while the voice panel is open: one task lands in the smart
// input for review, several are created straight away.
const handleVoiceConfirm = () => {
const { segments } = voiceState
if (segments.length === 1) {
handleVoiceSingle(segments[0].text, segments[0].overrides || {})
} else if (segments.length > 1) {
// Parse only at confirm time — the cards already parse for their own
// display, so there's no need to keep a parsed copy in modal state
const parseCtx = {
userLabels: voiceLabels,
members: voiceMembers,
currentUserId: userProfile?.id,
}
handleVoiceCreateMany(
segments.map(segment => ({
...parseVoiceTask(segment.text, parseCtx),
...(segment.overrides || {}),
})),
)
}
}
const handleCloseModal = forceRefetch => {
onClose(forceRefetch)
setShowScan(false)
setShowVoice(false)
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -831,16 +878,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
fullWidth={true}
title='Create new task'
footer={
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<ModalActions>
<Button
size='lg'
variant='outlined'
color='neutral'
onClick={handleCloseModal}
@@ -854,10 +893,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
/>
)}
</Button>
{/* Sub-panels (voice/scan) own their own confirm action */}
{showVoice && (
<Button
variant='solid'
color='primary'
loading={creatingVoiceTasks}
disabled={
voiceState.segments.length === 0 || voiceState.isListening
}
onClick={handleVoiceConfirm}
>
{creatingVoiceTasks
? 'Creating…'
: voiceState.segments.length > 1
? `Create ${voiceState.segments.length} Tasks`
: 'Use Task'}
</Button>
)}
{showScan && scanState.primaryAction && (
<Button
variant='solid'
color='primary'
startDecorator={scanState.primaryAction.icon}
onClick={scanState.primaryAction.onClick}
>
{scanState.primaryAction.label}
</Button>
)}
{showScan && scanState.phase === 'processing' && (
<Button variant='solid' color='primary' loading disabled>
Processing
</Button>
)}
{!showScan && !showVoice && (
<Button
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
@@ -869,7 +938,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
)}
</Button>
)}
</Box>
</ModalActions>
}
>
{!showScan && !showVoice && (
@@ -1219,12 +1288,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
{showVoice && (
<VoicePanel
open
userLabels={userLabels || []}
members={circleMembers?.res || []}
userLabels={voiceLabels}
members={voiceMembers}
userProfile={userProfile}
onUseSingle={handleVoiceSingle}
onCreateMany={handleVoiceCreateMany}
onStateChange={setVoiceState}
/>
)}
@@ -1234,10 +1301,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
autoCapture={scanAutoCapture}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onStateChange={setScanState}
onClose={() => {
setShowScan(false)
setScanAutoCapture(false)
setPendingPhotoUrl(null)
setScanState({ phase: 'idle', primaryAction: null })
}}
/>
)}

View File

@@ -26,6 +26,9 @@ import {
Avatar,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemContent,
ListItemDecorator,
Menu,
@@ -33,8 +36,10 @@ import {
Tooltip,
Typography,
} from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import AppModal from '../../components/common/AppModal'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
@@ -64,6 +69,8 @@ const ChoreActionMenu = ({
const menuRef = React.useRef(null)
const navigate = useNavigate()
const { data: projects = [] } = useProjects()
// Phone-only condition (matches AddTaskModal.jsx) — tablets/desktop keep the Menu
const isSmallScreen = useMediaQuery(theme => theme.breakpoints.down('sm'))
useEffect(() => {
try {
@@ -75,11 +82,19 @@ const ChoreActionMenu = ({
}, [])
useEffect(() => {
if (isSmallScreen) {
// AppModal owns its own backdrop/escape close behavior on small screens.
if (anchorEl && onOpen) {
onOpen()
}
return
}
const handleMenuOutsideClick = event => {
if (
anchorEl &&
!anchorEl.contains(event.target) &&
!menuRef.current.contains(event.target)
!menuRef.current?.contains(event.target)
) {
handleMenuClose()
}
@@ -92,7 +107,7 @@ const ChoreActionMenu = ({
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [anchorEl, onOpen])
}, [anchorEl, onOpen, isSmallScreen])
const handleMenuOpen = event => {
event.stopPropagation()
@@ -229,6 +244,255 @@ const ChoreActionMenu = ({
)
}
// Shared action list, rendered as MenuItems on large screens and as a
// ListItemButton list inside an AppModal sheet on small screens.
const actionItems = [
{
key: 'completeNote',
icon: <NoteAdd />,
label: 'Complete with note',
onClick: () => {
onCompleteWithNote?.()
handleMenuClose()
},
},
{
key: 'completePast',
icon: <Update />,
label: 'Complete in past',
onClick: () => {
onCompleteWithPastDate?.()
handleMenuClose()
},
},
{
key: 'skip',
icon: <SwitchAccessShortcut />,
label: 'Skip to next due date',
onClick: handleSkip,
},
{
key: 'delegate',
icon: <RecordVoiceOver />,
label: 'Delegate to someone else',
onClick: () => {
onChangeAssignee?.()
handleMenuClose()
},
},
isOfficialInstance && {
key: 'nudge',
icon: <Notifications />,
label: 'Send nudge',
onClick: () => {
onNudge?.()
handleMenuClose()
},
},
{ key: 'divider-1', type: 'divider' },
{
key: 'history',
icon: <ManageSearch />,
label: 'History',
onClick: handleHistory,
},
{ key: 'divider-2', type: 'divider' },
{ key: 'quickSchedule', type: 'quickSchedule' },
{ key: 'divider-3', type: 'divider' },
{
key: 'changeDueDate',
icon: <MoreTime />,
label: 'Change due date',
onClick: () => {
onChangeDueDate?.()
handleMenuClose()
},
},
{
key: 'writeNfc',
icon: <Nfc />,
label: 'Write to NFC',
onClick: () => {
onWriteNFC?.()
handleMenuClose()
},
},
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit },
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone },
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView },
{
key: 'archive',
icon: chore.isActive ? <Archive /> : <Unarchive />,
label: chore.isActive ? 'Archive' : 'Unarchive',
onClick: handleArchive,
color: 'neutral',
},
projects.length > 0 && {
key: 'moveToProject',
icon: <DriveFileMove />,
label: 'Move to project',
onClick: () => setShowProjectPicker(true),
},
{ key: 'divider-4', type: 'divider' },
{
key: 'delete',
icon: <Delete />,
label: 'Delete',
onClick: handleDelete,
color: 'danger',
},
].filter(Boolean)
const quickScheduleButtons = (
<>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('today')
}}
>
<Today />
</IconButton>
</Tooltip>
<Tooltip title='Tomorrow' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow')
}}
>
<WbSunny />
</IconButton>
</Tooltip>
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
color='neutral'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
}}
>
<Cancel />
</IconButton>
</Tooltip>
</>
)
const quickScheduleRowSx = {
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
}
const renderMenuActionItems = () =>
actionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') {
return (
<MenuItem
key={item.key}
sx={{
...quickScheduleRowSx,
cursor: 'default',
'&:hover': { backgroundColor: 'transparent' },
}}
onClick={e => e.stopPropagation()}
>
{quickScheduleButtons}
</MenuItem>
)
}
return (
<MenuItem
key={item.key}
color={item.color}
onClick={e => {
e.stopPropagation()
item.onClick()
}}
>
{item.icon}
{item.label}
</MenuItem>
)
})
const renderModalActionItems = () =>
actionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') {
return (
<ListItem key={item.key} sx={quickScheduleRowSx}>
{quickScheduleButtons}
</ListItem>
)
}
return (
<ListItem key={item.key}>
<ListItemButton color={item.color} onClick={() => item.onClick()}>
<ListItemDecorator>{item.icon}</ListItemDecorator>
<ListItemContent>{item.label}</ListItemContent>
</ListItemButton>
</ListItem>
)
})
const renderModalProjectPicker = () => (
<List>
<ListItem>
<ListItemButton
onClick={() =>
handleMoveToProject({ id: null, name: 'Default Project' })
}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>Default Project</ListItemContent>
</ListItemButton>
</ListItem>
{projects.map(project => (
<ListItem key={project.id}>
<ListItemButton onClick={() => handleMoveToProject(project)}>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>{project.name}</ListItemContent>
</ListItemButton>
</ListItem>
))}
</List>
)
return (
<>
<IconButton
@@ -249,280 +513,95 @@ const ChoreActionMenu = ({
<MoreVert />
</IconButton>
<Menu
size='md'
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
sx={{
position: 'absolute',
top: '100%',
left: '50%',
}}
>
{showProjectPicker ? (
<>
{isSmallScreen ? (
<AppModal
open={Boolean(anchorEl)}
onClose={handleMenuClose}
title={showProjectPicker ? 'Move to project' : chore?.name}
mobilePresentation='sheet'
showHandle
contentSx={{ px: 0, pb: 1 }}
>
{showProjectPicker && (
<MenuItem
onClick={e => {
e.stopPropagation()
setShowProjectPicker(false)
}}
sx={{ gap: 1 }}
onClick={() => setShowProjectPicker(false)}
sx={{ gap: 1, mx: 2, mb: 1 }}
>
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Move to project
Back
</Typography>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleMoveToProject({ id: null, name: 'Default Project' })
}}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>Default Project</Typography>
</ListItemContent>
</MenuItem>
{projects.map(project => (
)}
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
{showProjectPicker
? renderModalProjectPicker()
: renderModalActionItems()}
</List>
</AppModal>
) : (
<Menu
size='md'
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
sx={{
position: 'absolute',
top: '100%',
left: '50%',
}}
>
{showProjectPicker ? (
<>
<MenuItem
key={project.id}
onClick={e => {
e.stopPropagation()
handleMoveToProject(project)
setShowProjectPicker(false)
}}
sx={{ gap: 1 }}
>
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Move to project
</Typography>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleMoveToProject({ id: null, name: 'Default Project' })
}}
>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{project.name}</Typography>
<Typography level='body-sm'>Default Project</Typography>
</ListItemContent>
</MenuItem>
))}
</>
) : (
<>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithNote?.()
handleMenuClose()
}}
>
<NoteAdd />
Complete with note
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithPastDate?.()
handleMenuClose()
}}
>
<Update />
Complete in past
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleSkip()
}}
>
<SwitchAccessShortcut />
Skip to next due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeAssignee?.()
handleMenuClose()
}}
>
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
{isOfficialInstance && (
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleHistory()
}}
>
<ManageSearch />
History
</MenuItem>
<Divider />
<MenuItem
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
cursor: 'default',
'&:hover': {
backgroundColor: 'transparent',
},
}}
onClick={e => e.stopPropagation()}
>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
{projects.map(project => (
<MenuItem
key={project.id}
onClick={e => {
e.stopPropagation()
handleQuickSchedule('today')
handleMoveToProject(project)
}}
>
<Today />
</IconButton>
</Tooltip>
<Tooltip title='Tomorrow' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow')
}}
>
<WbSunny />
</IconButton>
</Tooltip>
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
color='neutral'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
}}
>
<Cancel />
</IconButton>
</Tooltip>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeDueDate?.()
handleMenuClose()
}}
>
<MoreTime />
Change due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onWriteNFC?.()
handleMenuClose()
}}
>
<Nfc />
Write to NFC
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleEdit()
}}
>
<Edit />
Edit
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleClone()
}}
>
<CopyAll />
Clone
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleView()
}}
>
<ViewCarousel />
View
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleArchive()
}}
color='neutral'
>
{chore.isActive ? <Archive /> : <Unarchive />}
{chore.isActive ? 'Archive' : 'Unarchive'}
</MenuItem>
{projects.length > 0 && (
<MenuItem
onClick={e => {
e.stopPropagation()
setShowProjectPicker(true)
}}
>
<DriveFileMove />
Move to project
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleDelete()
}}
color='danger'
>
<Delete />
Delete
</MenuItem>
</>
)}
</Menu>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{project.name}</Typography>
</ListItemContent>
</MenuItem>
))}
</>
) : (
renderMenuActionItems()
)}
</Menu>
)}
</>
)
}

View File

@@ -1,31 +1,8 @@
import {
Bedtime,
CalendarMonth,
Close,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import { CalendarMonth, Close } from '@mui/icons-material'
import { Box, Button, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import Calendar from 'react-calendar'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useMemo, useState } from 'react'
import DueDatePickerModal from './DueDatePickerModal'
const DueDatePickerField = ({
dueDateOnly,
@@ -39,106 +16,17 @@ const DueDatePickerField = ({
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (isOpen) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const handleQuickSchedule = option => {
const date = getQuickScheduleDate(option)
setLocalDueDateOnly(date.toISOString().split('T')[0])
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
onUseCustomTimeChange?.(localUseCustomTime)
if (localUseCustomTime && localDueTime) {
onDueTimeChange?.({ target: { value: localDueTime } })
} else {
onDueTimeChange?.({ target: { value: '' } })
}
const handleSave = ({
dueDateOnly: nextDate,
dueTime: nextTime,
useCustomTime: nextUseCustomTime,
}) => {
onDueDateChange?.({ target: { value: nextDate || '' } })
onUseCustomTimeChange?.(nextUseCustomTime)
onDueTimeChange?.({
target: { value: nextUseCustomTime && nextTime ? nextTime : '' },
})
setIsOpen(false)
}
@@ -202,6 +90,7 @@ const DueDatePickerField = ({
</Button>
{hasDueDate && onClear && (
<IconButton
aria-label='Clear due date'
size='sm'
variant='soft'
color='danger'
@@ -211,11 +100,9 @@ const DueDatePickerField = ({
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
top: -18,
right: -18,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
@@ -227,401 +114,22 @@ const DueDatePickerField = ({
)}
</Box>
<ResponsiveModal
<DueDatePickerModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Due Date'
fullWidth={false}
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasDueDate && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={useCustomTime}
onApply={handleSave}
onRemove={
onClear
? () => {
onClear()
setIsOpen(false)
}
: undefined
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = getQuickScheduleDate(opt.key)
.toISOString()
.split('T')[0]
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly
? new Date(`${localDueDateOnly}T00:00:00`)
: null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
/>
</>
)
}

View File

@@ -0,0 +1,544 @@
import {
Bedtime,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import Calendar from 'react-calendar'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
// Split a date-ish value (ISO string / Date) into the parts this picker edits.
export const splitDueDate = value => {
if (!value) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const m = moment(value)
if (!m.isValid()) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const time = m.format('HH:mm')
return {
dueDateOnly: m.format('YYYY-MM-DD'),
dueTime: time,
// Midnight is how a date-only value round-trips, so treat it as "anytime"
useCustomTime: time !== '00:00',
}
}
// Inverse of splitDueDate — returns a Date, or null when there is no due date.
export const combineDueDate = ({ dueDateOnly, dueTime, useCustomTime }) => {
if (!dueDateOnly) return null
const time = useCustomTime && dueTime ? dueTime : '00:00'
return moment(`${dueDateOnly} ${time}`, 'YYYY-MM-DD HH:mm').toDate()
}
export const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const toDateKey = date => moment(date).format('YYYY-MM-DD')
/**
* The shared due-date picker UI (quick dates, quick times, calendar, custom
* time). Used both by DueDatePickerField and by anything that needs to
* reschedule a task — task cards, swipe actions, action menus.
*/
const DueDatePickerModal = ({
open,
onClose,
title = 'Due Date',
dueDateOnly,
dueTime,
useCustomTime,
onApply,
onRemove,
applyLabel = 'Apply',
}) => {
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (open) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [open, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const handleQuickSchedule = option => {
setLocalDueDateOnly(toDateKey(getQuickScheduleDate(option)))
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(toDateKey(new Date()))
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onApply?.({
dueDateOnly: localDueDateOnly || null,
dueTime: localUseCustomTime ? localDueTime || null : null,
useCustomTime: Boolean(localUseCustomTime && localDueTime),
})
}
return (
<ResponsiveModal
open={open}
onClose={onClose}
title={title}
fullWidth={false}
footer={
<ModalActions
tertiary={
onRemove && dueDateOnly
? {
label: 'Remove',
color: 'danger',
onClick: onRemove,
}
: undefined
}
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: applyLabel, onClick: handleSave }}
/>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = toDateKey(getQuickScheduleDate(opt.key))
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly ? new Date(`${localDueDateOnly}T00:00:00`) : null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default DueDatePickerModal

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import {
installFeedbackErrorListeners,
markPromptDismissed,
markPromptShown,
shouldShowSentimentPrompt,
} from '../../service/FeedbackService'
import FeedbackModal from '../Modals/FeedbackModal'
// Let the screen settle before interrupting.
const OPEN_DELAY_MS = 4000
/**
* Decides whether to surface the sentiment prompt automatically. Mount once,
* near the main task list.
*/
const FeedbackPrompt = () => {
const [open, setOpen] = useState(false)
const { data: userProfile } = useUserProfile()
useEffect(() => {
installFeedbackErrorListeners()
}, [])
useEffect(() => {
if (!userProfile) return
let timer = null
let cancelled = false
shouldShowSentimentPrompt({ userProfile }).then(eligible => {
if (!eligible || cancelled) return
timer = setTimeout(() => {
if (cancelled) return
markPromptShown()
setOpen(true)
}, OPEN_DELAY_MS)
})
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [userProfile])
if (!open) return null
return (
<FeedbackModal
open={open}
onClose={() => setOpen(false)}
onDismiss={markPromptDismissed}
/>
)
}
export default FeedbackPrompt

View File

@@ -24,7 +24,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
@@ -33,7 +33,6 @@ import { useLocalization } from '../../contexts/LocalizationContext'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
import { SafeArea } from 'capacitor-plugin-safe-area'
import Z_INDEX from '../../constants/zIndex'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient'
@@ -100,19 +99,6 @@ const NavBar = () => {
]
const location = useLocation()
const [searchParams] = useSearchParams()
useEffect(() => {
SafeArea.getSafeAreaInsets().then(data => {
const { insets } = data
const drawerContent = document.querySelector('.drawer-content')
if (drawerContent) {
drawerContent.style.paddingTop = `${insets.top}px`
drawerContent.style.paddingRight = `${insets.right}px`
drawerContent.style.paddingBottom = `${insets.bottom}px`
drawerContent.style.paddingLeft = `${insets.left}px`
}
})
}, [])
const getMenuIcon = () => {
const menuRounded = (
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
@@ -223,7 +209,10 @@ const NavBar = () => {
},
}}
>
<div className='drawer-content'>
{/* Safe-area padding comes from the --safe-area-inset-* variables that
Capacitor's SystemBars keeps in sync with the live window insets.
Top inset is left to the inner List so it isn't applied twice. */}
<div className='drawer-content safe-area-x safe-area-bottom'>
{/* <div className='align-center flex px-5 pt-4'>
<ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
</div> */}

View File

@@ -1,6 +1,7 @@
import { Close, NotificationsNone } from '@mui/icons-material'
import { Box, Button, IconButton, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import NotificationTemplate from '../../components/NotificationTemplate'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -11,8 +12,7 @@ const getDisplayLabel = templates => {
const n = templates[0]
const numericValue = Number(n.value)
if (numericValue === 0) return 'On due date'
const unitName =
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
const unitName = n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
const absValue = Math.abs(numericValue)
const plural = absValue !== 1 ? 's' : ''
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
@@ -48,33 +48,22 @@ const NotificationPickerField = ({
}
const footer = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasNotifications && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove all
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
Apply
</Button>
</Box>
<ModalActions
tertiary={
hasNotifications
? {
label: 'Remove all',
color: 'danger',
onClick: () => {
onClear?.()
setIsOpen(false)
},
}
: undefined
}
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
primary={{ label: 'Apply', onClick: handleSave }}
/>
)
return (
@@ -116,6 +105,7 @@ const NotificationPickerField = ({
{hasNotifications && onClear && (
<IconButton
aria-label='Remove reminders'
size='sm'
variant='soft'
color='danger'
@@ -125,11 +115,9 @@ const NotificationPickerField = ({
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
top: -18,
right: -18,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}

View File

@@ -1,8 +1,6 @@
import { Close, CloudSync } from '@mui/icons-material'
import {
Box,
Button,
Divider,
IconButton,
List,
ListItem,
@@ -11,6 +9,7 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { commandQueue } from '../../utils/CommandQueue'
@@ -139,10 +138,24 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
{/* </Badge> */}
</IconButton>
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
<Typography level='title-lg' mb={0.5}>
Pending actions
</Typography>
<ResponsiveModal
open={isOpen}
onClose={handleClose}
size='sm'
title='Pending actions'
footer={
<ModalActions
secondary={{ label: 'Close', onClick: handleClose }}
primary={{
label: 'Cancel all',
color: 'danger',
onClick: handleCancelAll,
loading: isCancelingAll,
disabled: commands.length === 0,
}}
/>
}
>
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
synced.
@@ -171,6 +184,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
</ListItemContent>
<IconButton
aria-label={`Cancel ${formatCommandLabel(cmd.commandType)}`}
variant='plain'
color='danger'
size='sm'
@@ -182,22 +196,6 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
</ListItem>
))}
</List>
<Divider sx={{ mb: 1 }} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' onClick={handleClose}>
Close
</Button>
<Button
color='danger'
onClick={handleCancelAll}
loading={isCancelingAll}
disabled={commands.length === 0}
>
Cancel all
</Button>
</Box>
</ResponsiveModal>
</Box>
)

View File

@@ -2,7 +2,6 @@ import {
ArrowBack,
CameraAlt,
CheckCircle,
Close,
DocumentScanner,
PhotoCamera,
Replay,
@@ -12,12 +11,12 @@ import {
Box,
Button,
CircularProgress,
IconButton,
LinearProgress,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { localAIService } from '../../service/LocalAIService'
@@ -82,7 +81,10 @@ async function runNativeOCR(imageSource) {
}
const result = await Ocr.process({ image })
return result.results.map(r => r.text).join('\n').trim()
return result.results
.map(r => r.text)
.join('\n')
.trim()
}
async function runOCR(imageSource, onProgress) {
@@ -214,7 +216,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
try {
text = await runNativeOCR(capturedImage)
} catch {
throw new Error('Native OCR is only available on iOS and Android devices.')
throw new Error(
'Native OCR is only available on iOS and Android devices.',
)
}
} else {
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
@@ -231,7 +235,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const task = await extractTaskFromOCR(text)
if (!task || !task.taskName) {
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
setErrorMsg(
'Could not identify a task from this image. Please try a different photo.',
)
setPhase('error')
return
}
@@ -258,7 +264,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const { image, cancelled, error } = await scanDocument()
if (cancelled) return
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
setErrorMsg(
error ? `Scanner error: ${error}` : 'Scan cancelled or failed.',
)
setPhase('error')
return
}
@@ -377,7 +385,11 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
<Typography level='body-sm'>
Reading text from image {ocrProgress}%
</Typography>
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
<LinearProgress
determinate
value={ocrProgress}
sx={{ width: '100%' }}
/>
</>
)}
{phase === 'ocr' && ocrMethod === 'native' && (
@@ -466,7 +478,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<ModalActions sx={{ mt: 1 }}>
{phase === 'capture' && (
<>
<Button
@@ -583,18 +595,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
</Button>
</>
)}
{!isProcessing && (
<IconButton
variant='plain'
color='neutral'
onClick={handleClose}
sx={{ ml: 'auto' }}
>
<Close />
</IconButton>
)}
</Box>
</ModalActions>
</Box>
</ResponsiveModal>
)

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -472,6 +473,7 @@ const RepeatPickerField = ({
{hasRepeat && onClear && (
<IconButton
aria-label='Clear repeat schedule'
size='sm'
variant='soft'
color='danger'
@@ -481,11 +483,9 @@ const RepeatPickerField = ({
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
top: -18,
right: -18,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
@@ -500,38 +500,22 @@ const RepeatPickerField = ({
onClose={() => setIsOpen(false)}
title='Repeat Schedule'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasRepeat && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
<ModalActions
tertiary={
hasRepeat
? {
label: 'Remove',
color: 'danger',
onClick: () => {
onClear?.()
setIsOpen(false)
},
}
: undefined
}
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
primary={{ label: 'Apply', onClick: handleSave }}
/>
}
>
{/* Frequency type selector */}

View File

@@ -12,7 +12,7 @@ import {
LinearProgress,
Typography,
} from '@mui/joy'
import { useEffect } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useScanToTask } from './useScanToTask'
/**
@@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask'
*
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel]
*
* The primary action (Capture / Scan Document / Retake) lives in the modal
* footer alongside Cancel — the panel reports it up through onStateChange
* rather than rendering its own button row. Upload stays inline because it
* belongs to the capture surface and drives a hidden input in this subtree.
*/
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCapture }) => {
const ScanPanel = ({
open,
onTaskExtracted,
onClose,
onStateChange,
initialImageUrl,
autoCapture,
}) => {
const {
isNativeScanner,
phase,
@@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, taskResult])
const openFilePicker = useCallback(
() => fileInputRef.current?.click(),
[fileInputRef],
)
// The one action the footer renders for the current phase; null while
// processing (nothing to do but wait) and when done (the panel closes)
const primaryAction = useMemo(() => {
if (phase === 'capture') {
if (isNativeScanner) {
return {
label: 'Scan Document',
icon: <DocumentScanner />,
onClick: handleNativeScan,
}
}
if (cameraAvailable) {
return { label: 'Capture', icon: <CameraAlt />, onClick: capture }
}
// No camera on this device — Upload is the only way forward, so it
// graduates from the inline secondary to the footer's primary
return {
label: 'Upload Photo',
icon: <PhotoCamera />,
onClick: openFilePicker,
}
}
if (phase === 'error') {
return { label: 'Retake', icon: <Replay />, onClick: retake }
}
return null
}, [
phase,
isNativeScanner,
cameraAvailable,
capture,
handleNativeScan,
retake,
openFilePicker,
])
useEffect(() => {
onStateChange?.({ phase, primaryAction })
}, [phase, primaryAction, onStateChange])
if (!open) return null
const isProcessing = phase === 'processing'
@@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
<DocumentScanner
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
<Typography
level='body-sm'
sx={{ color: 'white', opacity: 0.6 }}
>
Tap &quot;Scan Document&quot; to open the scanner
</Typography>
</Box>
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
<CameraAlt
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
<Typography
level='body-sm'
sx={{ color: 'white', opacity: 0.6 }}
>
Camera not available use Upload instead
</Typography>
</Box>
)}
</Box>
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<PhotoCamera fontSize='small' />}
onClick={() => fileInputRef.current?.click()}
{/* Hidden when Upload is already the footer's primary action */}
{(isNativeScanner || cameraAvailable) && (
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
Upload
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
{isNativeScanner ? (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<DocumentScanner fontSize='small' />}
onClick={handleNativeScan}
>
Scan Document
</Button>
) : (
cameraAvailable && (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<CameraAlt fontSize='small' />}
onClick={capture}
>
Capture
</Button>
)
)}
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<PhotoCamera fontSize='small' />}
onClick={openFilePicker}
>
Upload
</Button>
</Box>
</Box>
)}
</>
)}
@@ -280,24 +313,22 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
}}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>{errorMsg}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Replay fontSize='small' />}
onClick={retake}
>
Retake
</Button>
</Box>
</Box>
)}
{/* Kept outside the phase branches so the footer's Upload action can
reach it even when no capture surface is rendered */}
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Box>
)

View File

@@ -401,18 +401,18 @@ const TaskPreviewCard = ({
/**
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
*
* Opens straight into hands-free listening. Pauses and spoken separators
* ("also") split the transcript into task cards; tapping a card opens inline
* pickers whose edits override the parsed values. A single captured task
* lands in the smart input for review; multiple are created directly.
* Mounted only while voice capture is active, and opens straight into
* hands-free listening. Pauses and spoken separators ("also") split the
* transcript into task cards; tapping a card opens inline pickers whose edits
* override the parsed values. The confirm action lives in the modal footer
* alongside Cancel — this panel only reports its state up through
* onStateChange so the modal can label and enable that button.
*/
const VoicePanel = ({
open,
userLabels = [],
members = [],
userProfile,
onUseSingle,
onCreateMany,
onStateChange,
}) => {
const {
phase,
@@ -427,8 +427,6 @@ const VoicePanel = ({
patchSegment,
isNative,
} = useVoiceToTask({ members, userLabels })
const [creating, setCreating] = useState(false)
const autoStartedRef = useRef(false)
const segmentsScrollRef = useRef(null)
const parseCtx = useMemo(
@@ -441,14 +439,12 @@ const VoicePanel = ({
[partialText, parseCtx],
)
// Start capturing the moment the panel opens — the mic tap that opened it
// is the only tap needed
// Start capturing the moment the panel mounts — the mic tap that opened it
// is the only tap needed. startHandsFree no-ops if already listening.
useEffect(() => {
if (open && !autoStartedRef.current) {
autoStartedRef.current = true
startHandsFree()
}
}, [open, startHandsFree])
startHandsFree()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Keep the newest captured task visible as more are added
useEffect(() => {
@@ -456,24 +452,14 @@ const VoicePanel = ({
if (el) el.scrollTop = el.scrollHeight
}, [segments.length])
if (!open) return null
const isListening = phase === 'listening'
const showActions = segments.length > 0 && !isListening && !creating
const mergedTask = segment => ({
...parseVoiceTask(segment.text, parseCtx),
...(segment.overrides || {}),
})
const handleCreateAll = async () => {
setCreating(true)
try {
await onCreateMany(segments.map(mergedTask))
} finally {
setCreating(false)
}
}
// The confirm action lives in the modal footer, so report the raw segments
// and whether the mic is live — that's all it needs to label and enable the
// button. It parses the segments itself when the user confirms.
useEffect(() => {
onStateChange?.({ segments, isListening })
}, [segments, isListening, onStateChange])
const micCaption = isListening
? isLocked
@@ -642,47 +628,6 @@ const VoicePanel = ({
</Typography>
</Box>
)}
{/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
{(creating || showActions) && (
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
justifyContent: 'flex-end',
gap: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{creating ? (
<Button size='sm' variant='solid' color='primary' loading>
Creating
</Button>
) : segments.length === 1 ? (
<Button
size='sm'
variant='solid'
color='primary'
onClick={() =>
onUseSingle(segments[0].text, segments[0].overrides || {})
}
>
Use Task
</Button>
) : (
<Button
size='sm'
variant='solid'
color='primary'
onClick={handleCreateAll}
>
Create {segments.length} Tasks
</Button>
)}
</Box>
)}
</Box>
)
}

Some files were not shown because too many files have changed in this diff Show More