Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778ae72777 | ||
|
|
dc88fe9704 | ||
|
|
5da8724a7b | ||
|
|
f57c24e312 | ||
|
|
a077afb291 | ||
|
|
66d742d091 | ||
|
|
d641710a41 | ||
|
|
46fb2d5475 | ||
|
|
b2949b2dd9 | ||
|
|
9478f7d0f5 | ||
|
|
2e0ff5a10c | ||
|
|
85826d8068 | ||
|
|
9b0af72d74 | ||
|
|
eb088a259a | ||
|
|
dcd157274a | ||
|
|
19ed0ba3ab | ||
|
|
b8fe2833b6 | ||
|
|
c39bbe5984 | ||
|
|
9950d8ff6c | ||
|
|
99b467488f | ||
|
|
8dfaa4d8c6 | ||
|
|
000e4d0d61 | ||
|
|
0132408eec | ||
|
|
f5da57d7f7 |
@@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -2,7 +2,6 @@ name: Build validation
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -19,3 +18,4 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm i
|
run: npm i
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
- run: npm run lint:ci
|
||||||
|
|||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
LICENSE.md
|
||||||
|
dist/
|
||||||
13
.prettierrc
13
.prettierrc
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId "com.donetick.app"
|
applicationId "com.donetick.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 55
|
versionCode 57
|
||||||
versionName "1.2.34"
|
versionName "1.2.36"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-community-in-app-review')
|
||||||
implementation project(':capacitor-community-speech-recognition')
|
implementation project(':capacitor-community-speech-recognition')
|
||||||
implementation project(':capacitor-community-sqlite')
|
implementation project(':capacitor-community-sqlite')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
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'
|
include ':capacitor-community-speech-recognition'
|
||||||
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
|
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
|
||||||
|
|
||||||
|
|||||||
187
eslint.config.mjs
Normal file
187
eslint.config.mjs
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -462,12 +462,12 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 57;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.2.34;
|
MARKETING_VERSION = 1.2.36;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -485,12 +485,12 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 57;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.2.34;
|
MARKETING_VERSION = 1.2.36;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -504,12 +504,12 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 57;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.2.34;
|
MARKETING_VERSION = 1.2.36;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -527,12 +527,12 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 57;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.2.34;
|
MARKETING_VERSION = 1.2.36;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
|||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :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 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
|
||||||
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ PODS:
|
|||||||
- PromisesObjC (~> 2.4)
|
- PromisesObjC (~> 2.4)
|
||||||
- PromisesSwift (~> 2.4)
|
- PromisesSwift (~> 2.4)
|
||||||
- RecaptchaInterop (~> 101.0)
|
- RecaptchaInterop (~> 101.0)
|
||||||
- Capacitor (8.4.1):
|
- Capacitor (8.4.2):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (8.1.0):
|
- CapacitorApp (8.1.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorBrowser (8.0.3):
|
- CapacitorBrowser (8.0.4):
|
||||||
|
- Capacitor
|
||||||
|
- CapacitorCommunityInAppReview (8.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCommunitySpeechRecognition (7.0.1):
|
- CapacitorCommunitySpeechRecognition (7.0.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
@@ -24,14 +26,14 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- SQLCipher
|
- SQLCipher
|
||||||
- ZIPFoundation
|
- ZIPFoundation
|
||||||
- CapacitorCordova (8.4.1)
|
- CapacitorCordova (8.4.2)
|
||||||
- CapacitorDevice (8.0.2):
|
- CapacitorDevice (8.0.3):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorHaptics (8.0.2):
|
- CapacitorHaptics (8.0.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorLocalLlm (1.0.0):
|
- CapacitorLocalLlm (1.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorLocalNotifications (8.2.0):
|
- CapacitorLocalNotifications (8.2.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorNetwork (8.0.1):
|
- CapacitorNetwork (8.0.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
@@ -39,15 +41,15 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPreferences (8.0.1):
|
- CapacitorPreferences (8.0.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPushNotifications (8.1.1):
|
- CapacitorPushNotifications (8.1.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorStatusBar (8.0.2):
|
- CapacitorStatusBar (8.0.3):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapgoCapacitorDocumentScanner (8.4.0):
|
- CapgoCapacitorDocumentScanner (8.4.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapgoCapacitorNfc (8.2.2):
|
- CapgoCapacitorNfc (8.2.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapgoCapacitorSocialLogin (8.3.38):
|
- CapgoCapacitorSocialLogin (8.3.39):
|
||||||
- Alamofire (~> 5.10.2)
|
- Alamofire (~> 5.10.2)
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- GoogleSignIn (~> 9.0.0)
|
- GoogleSignIn (~> 9.0.0)
|
||||||
@@ -143,6 +145,7 @@ DEPENDENCIES:
|
|||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||||
|
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
|
||||||
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
|
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
|
||||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||||
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/app"
|
:path: "../../node_modules/@capacitor/app"
|
||||||
CapacitorBrowser:
|
CapacitorBrowser:
|
||||||
:path: "../../node_modules/@capacitor/browser"
|
:path: "../../node_modules/@capacitor/browser"
|
||||||
|
CapacitorCommunityInAppReview:
|
||||||
|
:path: "../../node_modules/@capacitor-community/in-app-review"
|
||||||
CapacitorCommunitySpeechRecognition:
|
CapacitorCommunitySpeechRecognition:
|
||||||
:path: "../../node_modules/@capacitor-community/speech-recognition"
|
:path: "../../node_modules/@capacitor-community/speech-recognition"
|
||||||
CapacitorCommunitySqlite:
|
CapacitorCommunitySqlite:
|
||||||
@@ -236,24 +241,25 @@ SPEC CHECKSUMS:
|
|||||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
|
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
|
||||||
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
|
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
|
||||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3
|
||||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06
|
||||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656
|
||||||
|
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
|
||||||
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
||||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4
|
||||||
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
|
CapacitorDevice: 708e742b60a61572cd593e44c809592e8f31da14
|
||||||
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
|
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
|
||||||
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
|
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
|
||||||
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
|
CapacitorLocalNotifications: 908a69a3cf7ae345426de3223d4980befb0103c6
|
||||||
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
|
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
|
||||||
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
|
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
|
||||||
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
||||||
CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e
|
CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237
|
||||||
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
|
CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af
|
||||||
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
|
CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942
|
||||||
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
||||||
CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414
|
CapgoCapacitorSocialLogin: 5ea6b14b8e9b1bd74533e580d4cf0e2b85d80793
|
||||||
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
||||||
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
||||||
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
||||||
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
|
|||||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
|
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
4068
package-lock.json
generated
4068
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "donetick",
|
"name": "donetick",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.34",
|
"version": "1.2.36",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
@@ -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-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-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",
|
"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",
|
"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-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",
|
"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"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/in-app-review": "^8.0.0",
|
||||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||||
"@capacitor-community/sqlite": "^8.0.0",
|
"@capacitor-community/sqlite": "^8.0.0",
|
||||||
"@capacitor/android": "^8.0.0",
|
"@capacitor/android": "^8.0.0",
|
||||||
@@ -71,8 +74,11 @@
|
|||||||
"@openreplay/tracker": "^14.0.4",
|
"@openreplay/tracker": "^14.0.4",
|
||||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||||
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
||||||
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@swc/core": "^1.12.5",
|
"@swc/core": "^1.12.5",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||||
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"aos": "^2.3.4",
|
"aos": "^2.3.4",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"caniuse-lite": "^1.0.30001769",
|
"caniuse-lite": "^1.0.30001769",
|
||||||
@@ -116,20 +122,20 @@
|
|||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"capacitor-set-version": "^2.2.0",
|
"capacitor-set-version": "^2.2.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.2",
|
"eslint-formatter-gha": "^2.0.1",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"eslint-plugin-sort-destructure-keys": "^1.5.0",
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||||
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
"eslint-plugin-sort-destructure-keys": "^3.0.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.13.1",
|
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
tailwindcss: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
13
prettier.config.mjs
Normal file
13
prettier.config.mjs
Normal 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',
|
||||||
|
}
|
||||||
75
public/locales/ar-SA/chores.json
Normal file
75
public/locales/ar-SA/chores.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "المهام",
|
||||||
|
"myChores": "مهامي",
|
||||||
|
"allChores": "جميع المهام",
|
||||||
|
"addChore": "إضافة مهمة",
|
||||||
|
"editChore": "تعديل مهمة",
|
||||||
|
"deleteChore": "حذف مهمة",
|
||||||
|
"completeChore": "إكمال مهمة",
|
||||||
|
"dueDate": "تاريخ الاستحقاق",
|
||||||
|
"assignedTo": "مُسند إلى",
|
||||||
|
"priority": "الأولوية",
|
||||||
|
"status": "الحالة",
|
||||||
|
"description": "الوصف",
|
||||||
|
"choreView": {
|
||||||
|
"assignment": "التعيين",
|
||||||
|
"assigned": "المعين",
|
||||||
|
"last": "الأخير",
|
||||||
|
"schedule": "الجدول",
|
||||||
|
"due": "الاستحقاق",
|
||||||
|
"statistics": "الإحصائيات",
|
||||||
|
"completed": "مكتمل",
|
||||||
|
"times": "مرات",
|
||||||
|
"details": "التفاصيل",
|
||||||
|
"createdBy": "أنشئ بواسطة",
|
||||||
|
"na": "غير متوفر",
|
||||||
|
"taskCompleted": "المهمة مكتملة",
|
||||||
|
"taskCompletedMessage": "تم وضع علامة مكتملة على مهمتك",
|
||||||
|
"taskCompletionUndone": "تم التراجع عن إكمال المهمة.",
|
||||||
|
"taskSkipUndone": "تم التراجع عن تخطي المهمة.",
|
||||||
|
"undoSuccessful": "التراجع ناجح",
|
||||||
|
"undoFailed": "فشل التراجع",
|
||||||
|
"undoFailedMessage": "تعذر التراجع عن الإجراء. يرجى المحاولة مرة أخرى.",
|
||||||
|
"resetTimer": "إعادة تعيين المؤقت",
|
||||||
|
"resetTimerConfirmation": "هل أنت متأكد من أنك تريد إعادة تعيين المؤقت؟ سيؤدي هذا إلى مسح جميع سجلات الوقت منذ بدء المهمة.",
|
||||||
|
"clearAllTimeRecords": "مسح جميع سجلات الوقت",
|
||||||
|
"clearAllTimeConfirmation": "سيؤدي هذا إلى حذف جميع المؤقتات لهذه المهمة بشكل دائم وإعادتها إلى حالة \"لم تبدأ\".",
|
||||||
|
"descriptionTitle": "الوصف",
|
||||||
|
"description": "الوصف :",
|
||||||
|
"previousNote": "الملاحظة السابقة",
|
||||||
|
"previousNoteLabel": "الملاحظة السابقة:",
|
||||||
|
"subtasksLabel": "المهام الفرعية :",
|
||||||
|
"taskActions": "إجراءات المهمة",
|
||||||
|
"addNote": "أضف ملاحظة",
|
||||||
|
"additionalNotes": "ملاحظات إضافية:",
|
||||||
|
"notePlaceholder": "أضف ملاحظة حول الإكمال...",
|
||||||
|
"setCustomCompletionTime": "تعيين وقت إكمال مخصص",
|
||||||
|
"skipTask": "تخطي المهمة",
|
||||||
|
"skipTaskConfirmation": "هل أنت متأكد من أنك تريد تخطي هذه المهمة؟",
|
||||||
|
"markComplete": "وضع علامة مكتمل",
|
||||||
|
"markAsDone": "وضع علامة كمنجز",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"archive": "أرشفة",
|
||||||
|
"unarchive": "إلغاء الأرشفة",
|
||||||
|
"viewHistory": "عرض السجل",
|
||||||
|
"history": "السجل",
|
||||||
|
"startTimer": "بدء المؤقت",
|
||||||
|
"start": "بدء",
|
||||||
|
"pauseTimer": "إيقاف المؤقت مؤقتاً",
|
||||||
|
"approve": "موافقة",
|
||||||
|
"reject": "رفض",
|
||||||
|
"pendingApproval": "في انتظار الموافقة",
|
||||||
|
"undo": "تراجع",
|
||||||
|
"skip": "تخطي",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"noPriority": "بدون أولوية",
|
||||||
|
"subtasks": "المهام الفرعية",
|
||||||
|
"noDescription": "لا يوجد وصف متاح",
|
||||||
|
"timer": {
|
||||||
|
"active": "المؤقت نشط",
|
||||||
|
"paused": "المؤقت متوقف مؤقتاً",
|
||||||
|
"reset": "إعادة تعيين المؤقت",
|
||||||
|
"delete": "حذف الجلسة"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/locales/ar-SA/common.json
Normal file
33
public/locales/ar-SA/common.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"save": "حفظ",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"delete": "حذف",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"confirm": "تأكيد",
|
||||||
|
"loading": "جارٍ التحميل...",
|
||||||
|
"error": "خطأ",
|
||||||
|
"success": "نجح",
|
||||||
|
"warning": "تحذير",
|
||||||
|
"refresh": "تحديث",
|
||||||
|
"copy": "نسخ",
|
||||||
|
"copied": "تم النسخ!",
|
||||||
|
"settings": "الإعدادات",
|
||||||
|
"yes": "نعم",
|
||||||
|
"no": "لا",
|
||||||
|
"back": "رجوع",
|
||||||
|
"backToCalendar": "العودة إلى التقويم",
|
||||||
|
"logout": "تسجيل الخروج",
|
||||||
|
"version": "النسخة",
|
||||||
|
"navigation": {
|
||||||
|
"allTasks": "جميع المهام",
|
||||||
|
"archived": "المؤرشفة",
|
||||||
|
"things": "الأشياء",
|
||||||
|
"labels": "التسميات",
|
||||||
|
"projects": "المشاريع",
|
||||||
|
"filters": "الفلاتر",
|
||||||
|
"activities": "الأنشطة",
|
||||||
|
"points": "النقاط",
|
||||||
|
"settings": "الإعدادات"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
public/locales/ar-SA/settings.json
Normal file
174
public/locales/ar-SA/settings.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"circleSettings": {
|
||||||
|
"title": "إعدادات الدائرة",
|
||||||
|
"description": "يتم ربط حسابك تلقائيًا بدائرة عند إنشاء واحدة أو الانضمام إليها. ادعُ الأصدقاء بسهولة من خلال مشاركة رمز الدائرة الفريد أو الرابط أدناه. ستتلقى إشعارًا أدناه عندما يطلب شخص ما الانضمام إلى دائرتك. إذا كنت ترغب في المغادرة، فما عليك سوى الضغط على زر 'مغادرة الدائرة'.",
|
||||||
|
"circleCode": "رمز الدائرة",
|
||||||
|
"copyCode": "نسخ الرمز",
|
||||||
|
"copyLink": "نسخ الرابط",
|
||||||
|
"codeCopied": "تم نسخ رمز الدائرة!",
|
||||||
|
"linkCopied": "تم نسخ الرابط!",
|
||||||
|
"joinCircle": "الانضمام إلى دائرة",
|
||||||
|
"joinCirclePlaceholder": "أدخل رمز الدائرة",
|
||||||
|
"join": "انضمام",
|
||||||
|
"leave": "مغادرة الدائرة",
|
||||||
|
"leaveConfirmTitle": "مغادرة الدائرة",
|
||||||
|
"leaveConfirmMessage": "هل أنت متأكد من أنك تريد مغادرة هذه الدائرة؟",
|
||||||
|
"circleMembers": "أعضاء الدائرة",
|
||||||
|
"circleMemberRequests": "طلبات انضمام الأعضاء",
|
||||||
|
"admin": "مشرف",
|
||||||
|
"member": "عضو",
|
||||||
|
"pending": "قيد الانتظار",
|
||||||
|
"accept": "قبول",
|
||||||
|
"reject": "رفض",
|
||||||
|
"makeAdmin": "جعله مشرف",
|
||||||
|
"makeMember": "جعله عضو",
|
||||||
|
"remove": "إزالة",
|
||||||
|
"webhookURL": "رابط Webhook",
|
||||||
|
"webhookDescription": "أدخل رابط webhook لتلقي إشعارات أحداث الدائرة",
|
||||||
|
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||||
|
},
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "إعدادات الحساب",
|
||||||
|
"subscription": "الاشتراك",
|
||||||
|
"subscriptionStatus": "الخطة الحالية",
|
||||||
|
"free": "مجاني",
|
||||||
|
"plus": "بلس",
|
||||||
|
"upgrade": "ترقية",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"changePassword": "تغيير كلمة المرور",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"dangerZone": "منطقة الخطر",
|
||||||
|
"dangerZoneDescription": "بمجرد حذف حسابك، لا يمكن التراجع. يرجى التأكد.",
|
||||||
|
"deleteAccount": "حذف الحساب"
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "التوطين",
|
||||||
|
"description": "تخصيص اللغة وتنسيق التاريخ والتفضيلات الإقليمية لحسابك.",
|
||||||
|
"language": "اللغة",
|
||||||
|
"languageDescription": "اختر لغتك المفضلة",
|
||||||
|
"dateFormat": "تنسيق التاريخ",
|
||||||
|
"dateFormatDescription": "اختر كيفية عرض التواريخ في التطبيق",
|
||||||
|
"timeFormat": "تنسيق الوقت",
|
||||||
|
"timeFormatDescription": "اختر تنسيق 12 أو 24 ساعة",
|
||||||
|
"12hour": "12 ساعة (ص/م)",
|
||||||
|
"24hour": "24 ساعة",
|
||||||
|
"firstDayOfWeek": "أول يوم في الأسبوع",
|
||||||
|
"firstDayOfWeekDescription": "اختر اليوم الذي يبدأ به أسبوعك",
|
||||||
|
"sunday": "الأحد",
|
||||||
|
"monday": "الاثنين",
|
||||||
|
"saturday": "السبت",
|
||||||
|
"formats": {
|
||||||
|
"mdy": "MM/DD/YYYY (الولايات المتحدة)",
|
||||||
|
"dmy": "DD/MM/YYYY (أوروبا)",
|
||||||
|
"ymd": "YYYY-MM-DD (ISO)",
|
||||||
|
"long": "تنسيق طويل (مثل 1 يناير 2024)",
|
||||||
|
"short": "تنسيق قصير (مثل 1 يناير 2024)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "تخصيص اللوحة الجانبية",
|
||||||
|
"description": "قم بتخصيص تخطيط ورؤية البطاقات في اللوحة الجانبية. هذا القسم متاح فقط على أجهزة الشاشة الكبيرة مثل الأجهزة اللوحية وأجهزة سطح المكتب."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "تفضيلات المظهر",
|
||||||
|
"description": "اختر كيف يبدو الموقع لك. حدد مظهرًا واحدًا أو قم بالمزامنة مع نظامك والتبديل تلقائيًا بين مظاهر النهار والليل.",
|
||||||
|
"themeMode": "وضع المظهر",
|
||||||
|
"light": "فاتح",
|
||||||
|
"dark": "داكن",
|
||||||
|
"system": "النظام"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"settingsSaved": "تم حفظ الإعدادات بنجاح",
|
||||||
|
"settingsSaveFailed": "فشل حفظ الإعدادات",
|
||||||
|
"invalidWebhook": "رابط webhook غير صالح"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "إعدادات الملف الشخصي",
|
||||||
|
"description": "تحديث اسم العرض وصورة الملف الشخصي.",
|
||||||
|
"photoUpdated": "تم تحديث الصورة",
|
||||||
|
"photoUpdatedMessage": "تم تحديث صورة ملفك الشخصي بنجاح!",
|
||||||
|
"uploadFailed": "فشل التحميل",
|
||||||
|
"uploadFailedMessage": "فشل تحميل صورتك. يرجى المحاولة مرة أخرى.",
|
||||||
|
"profileUpdated": "تم تحديث الملف الشخصي",
|
||||||
|
"profileUpdatedMessage": "تم حفظ معلومات ملفك الشخصي بنجاح!",
|
||||||
|
"updateFailed": "فشل التحديث",
|
||||||
|
"updateFailedMessage": "تعذر تحديث ملفك الشخصي. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
|
||||||
|
"changePhoto": "تغيير الصورة",
|
||||||
|
"displayName": "اسم العرض",
|
||||||
|
"displayNamePlaceholder": "أدخل اسم العرض الخاص بك",
|
||||||
|
"timezone": "المنطقة الزمنية",
|
||||||
|
"timezonePlaceholder": "اختر منطقتك الزمنية",
|
||||||
|
"save": "حفظ",
|
||||||
|
"cancel": "إلغاء"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"subtitle": "قم بتخصيص تجربتك وإدارة تفضيلات حسابك",
|
||||||
|
"upgrade": {
|
||||||
|
"title": "الترقية إلى بلس",
|
||||||
|
"description": "افتح ميزات قوية لتعزيز إنتاجيتك",
|
||||||
|
"button": "الترقية الآن",
|
||||||
|
"features": {
|
||||||
|
"richText": "أوصاف نصية منسقة",
|
||||||
|
"notifications": "إشعارات المهام",
|
||||||
|
"apiIntegrations": "تكاملات API",
|
||||||
|
"advancedAutomation": "أتمتة متقدمة"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"profile": {
|
||||||
|
"title": "إعدادات الملف الشخصي",
|
||||||
|
"description": "تحديث معلومات ملفك الشخصي والصورة واسم العرض وتفضيلات المنطقة الزمنية."
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"title": "إعدادات الدائرة",
|
||||||
|
"description": "إدارة دائرتك ودعوة الأعضاء والتعامل مع طلبات الانضمام."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "إعدادات الحساب",
|
||||||
|
"description": "إدارة اشتراكك وتغيير كلمة المرور وخيارات حذف الحساب."
|
||||||
|
},
|
||||||
|
"subaccounts": {
|
||||||
|
"title": "الحسابات المُدارة",
|
||||||
|
"description": "إنشاء وإدارة حسابات فرعية لتسجيل الدخول وإكمال المهام المعينة."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "الإشعارات",
|
||||||
|
"description": "تكوين الإشعارات الفورية وتنبيهات البريد الإلكتروني ووجهات الإشعارات للمهام."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "المصادقة متعددة العوامل",
|
||||||
|
"description": "إضافة طبقة إضافية من الأمان باستخدام MFA مع تطبيقات المصادقة."
|
||||||
|
},
|
||||||
|
"apitokens": {
|
||||||
|
"title": "رموز API",
|
||||||
|
"description": "إنشاء وإدارة رموز الوصول لتكاملات الطرف الثالث والوصول إلى API."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "إعدادات التخزين",
|
||||||
|
"description": "نسخ احتياطي واستعادة بياناتك وإدارة التخزين المحلي وتفضيلات المزامنة."
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "تخصيص اللوحة الجانبية",
|
||||||
|
"description": "قم بتخصيص تخطيط ورؤية البطاقات في واجهة اللوحة الجانبية."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "تفضيلات المظهر",
|
||||||
|
"description": "اختر مظهرك المفضل وقم بتكوين إعدادات الوضع الداكن/الفاتح."
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "التوطين",
|
||||||
|
"description": "تخصيص اللغة وتنسيق التاريخ وتنسيق الوقت والتفضيلات الإقليمية."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "الإعدادات المتقدمة",
|
||||||
|
"description": "تكوين webhooks والتحديثات في الوقت الفعلي وميزات متقدمة أخرى لتعزيز الإنتاجية."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"title": "إعدادات المطور",
|
||||||
|
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"resetTimer": "Timer zurücksetzen",
|
"resetTimer": "Timer zurücksetzen",
|
||||||
"resetTimerConfirmation": "Bist du sicher, dass du den Timer zurücksetzen möchtest? Dies löscht alle Zeitaufzeichnungen seit du die Aufgabe gestartet hast.",
|
"resetTimerConfirmation": "Bist du sicher, dass du den Timer zurücksetzen möchtest? Dies löscht alle Zeitaufzeichnungen seit du die Aufgabe gestartet hast.",
|
||||||
"clearAllTimeRecords": "Alle Zeitaufzeichnungen löschen",
|
"clearAllTimeRecords": "Alle Zeitaufzeichnungen löschen",
|
||||||
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf \"nicht gestartet\".",
|
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf „nicht gestartet“.",
|
||||||
"descriptionTitle": "Beschreibung",
|
"descriptionTitle": "Beschreibung",
|
||||||
"description": "Beschreibung:",
|
"description": "Beschreibung:",
|
||||||
"previousNote": "Vorherige Notiz",
|
"previousNote": "Vorherige Notiz",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"navigation": {
|
"navigation": {
|
||||||
"allTasks": "Alle Aufgaben",
|
"allTasks": "Alle Aufgaben",
|
||||||
"archived": "Archiviert",
|
"archived": "Archiviert",
|
||||||
"things": "Things",
|
"things": "Dinge",
|
||||||
"labels": "Beschriftungen",
|
"labels": "Beschriftungen",
|
||||||
"projects": "Projekte",
|
"projects": "Projekte",
|
||||||
"filters": "Filter",
|
"filters": "Filter",
|
||||||
|
|||||||
@@ -171,4 +171,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,51 @@
|
|||||||
"activities": "Activities",
|
"activities": "Activities",
|
||||||
"points": "Points",
|
"points": "Points",
|
||||||
"settings": "Settings"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@
|
|||||||
"developer": {
|
"developer": {
|
||||||
"title": "Developer Settings",
|
"title": "Developer Settings",
|
||||||
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
public/locales/es-ES/chores.json
Normal file
75
public/locales/es-ES/chores.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "Tareas",
|
||||||
|
"myChores": "Mis Tareas",
|
||||||
|
"allChores": "Todas las Tareas",
|
||||||
|
"addChore": "Agregar Tarea",
|
||||||
|
"editChore": "Editar Tarea",
|
||||||
|
"deleteChore": "Eliminar Tarea",
|
||||||
|
"completeChore": "Completar Tarea",
|
||||||
|
"dueDate": "Fecha de Vencimiento",
|
||||||
|
"assignedTo": "Asignado a",
|
||||||
|
"priority": "Prioridad",
|
||||||
|
"status": "Estado",
|
||||||
|
"description": "Descripción",
|
||||||
|
"choreView": {
|
||||||
|
"assignment": "Asignación",
|
||||||
|
"assigned": "Asignado",
|
||||||
|
"last": "Último",
|
||||||
|
"schedule": "Horario",
|
||||||
|
"due": "Vencimiento",
|
||||||
|
"statistics": "Estadísticas",
|
||||||
|
"completed": "Completado",
|
||||||
|
"times": "veces",
|
||||||
|
"details": "Detalles",
|
||||||
|
"createdBy": "Creado por",
|
||||||
|
"na": "N/D",
|
||||||
|
"taskCompleted": "Tarea Completada",
|
||||||
|
"taskCompletedMessage": "Tu tarea ha sido marcada como completada",
|
||||||
|
"taskCompletionUndone": "La finalización de la tarea ha sido deshecha.",
|
||||||
|
"taskSkipUndone": "El salto de tarea ha sido deshecho.",
|
||||||
|
"undoSuccessful": "Deshacer Exitoso",
|
||||||
|
"undoFailed": "Deshacer Fallido",
|
||||||
|
"undoFailedMessage": "No se pudo deshacer la acción. Por favor, inténtalo de nuevo.",
|
||||||
|
"resetTimer": "Reiniciar Temporizador",
|
||||||
|
"resetTimerConfirmation": "¿Estás seguro de que quieres reiniciar el temporizador? Esto borrará todos los registros de tiempo desde que iniciaste la tarea.",
|
||||||
|
"clearAllTimeRecords": "Borrar Todos los Registros de Tiempo",
|
||||||
|
"clearAllTimeConfirmation": "Esto eliminará permanentemente todos los temporizadores de esta tarea y la volverá al estado \"no iniciada\".",
|
||||||
|
"descriptionTitle": "Descripción",
|
||||||
|
"description": "Descripción :",
|
||||||
|
"previousNote": "Nota Anterior",
|
||||||
|
"previousNoteLabel": "Nota anterior:",
|
||||||
|
"subtasksLabel": "Subtareas :",
|
||||||
|
"taskActions": "Acciones de Tarea",
|
||||||
|
"addNote": "Agregar una nota",
|
||||||
|
"additionalNotes": "Notas Adicionales:",
|
||||||
|
"notePlaceholder": "Agregar una nota sobre la finalización...",
|
||||||
|
"setCustomCompletionTime": "Establecer hora de finalización personalizada",
|
||||||
|
"skipTask": "Saltar Tarea",
|
||||||
|
"skipTaskConfirmation": "¿Estás seguro de que quieres saltar esta tarea?",
|
||||||
|
"markComplete": "Marcar como Completada",
|
||||||
|
"markAsDone": "Marcar como hecha",
|
||||||
|
"edit": "Editar",
|
||||||
|
"archive": "Archivar",
|
||||||
|
"unarchive": "Desarchivar",
|
||||||
|
"viewHistory": "Ver Historial",
|
||||||
|
"history": "Historial",
|
||||||
|
"startTimer": "Iniciar Temporizador",
|
||||||
|
"start": "Iniciar",
|
||||||
|
"pauseTimer": "Pausar Temporizador",
|
||||||
|
"approve": "Aprobar",
|
||||||
|
"reject": "Rechazar",
|
||||||
|
"pendingApproval": "Pendiente de Aprobación",
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"skip": "Saltar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"noPriority": "Sin Prioridad",
|
||||||
|
"subtasks": "Subtareas",
|
||||||
|
"noDescription": "No hay descripción disponible",
|
||||||
|
"timer": {
|
||||||
|
"active": "Temporizador Activo",
|
||||||
|
"paused": "Temporizador Pausado",
|
||||||
|
"reset": "Reiniciar Temporizador",
|
||||||
|
"delete": "Eliminar Sesión"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/locales/es-ES/common.json
Normal file
33
public/locales/es-ES/common.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Éxito",
|
||||||
|
"warning": "Advertencia",
|
||||||
|
"refresh": "Actualizar",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "¡Copiado!",
|
||||||
|
"settings": "Configuración",
|
||||||
|
"yes": "Sí",
|
||||||
|
"no": "No",
|
||||||
|
"back": "Atrás",
|
||||||
|
"backToCalendar": "Volver al Calendario",
|
||||||
|
"logout": "Cerrar Sesión",
|
||||||
|
"version": "Versión",
|
||||||
|
"navigation": {
|
||||||
|
"allTasks": "Todas las Tareas",
|
||||||
|
"archived": "Archivadas",
|
||||||
|
"things": "Cosas",
|
||||||
|
"labels": "Etiquetas",
|
||||||
|
"projects": "Proyectos",
|
||||||
|
"filters": "Filtros",
|
||||||
|
"activities": "Actividades",
|
||||||
|
"points": "Puntos",
|
||||||
|
"settings": "Configuración"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
public/locales/es-ES/settings.json
Normal file
174
public/locales/es-ES/settings.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"title": "Configuración",
|
||||||
|
"circleSettings": {
|
||||||
|
"title": "Configuración del círculo",
|
||||||
|
"description": "Tu cuenta se conecta automáticamente a un Círculo cuando creas o te unes a uno. Invita fácilmente a amigos compartiendo el código único del Círculo o el enlace a continuación.",
|
||||||
|
"circleCode": "Código del Círculo",
|
||||||
|
"copyCode": "Copiar Código",
|
||||||
|
"copyLink": "Copiar Enlace",
|
||||||
|
"codeCopied": "¡Código del círculo copiado!",
|
||||||
|
"linkCopied": "¡Enlace copiado!",
|
||||||
|
"joinCircle": "Unirse a un Círculo",
|
||||||
|
"joinCirclePlaceholder": "Ingresa el Código del Círculo",
|
||||||
|
"join": "Unirse",
|
||||||
|
"leave": "Salir del Círculo",
|
||||||
|
"leaveConfirmTitle": "Salir del Círculo",
|
||||||
|
"leaveConfirmMessage": "¿Estás seguro de que quieres salir de este círculo?",
|
||||||
|
"circleMembers": "Miembros del Círculo",
|
||||||
|
"circleMemberRequests": "Solicitudes de Miembros del Círculo",
|
||||||
|
"admin": "Administrador",
|
||||||
|
"member": "Miembro",
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"accept": "Aceptar",
|
||||||
|
"reject": "Rechazar",
|
||||||
|
"makeAdmin": "Hacer Administrador",
|
||||||
|
"makeMember": "Hacer Miembro",
|
||||||
|
"remove": "Eliminar",
|
||||||
|
"webhookURL": "URL del Webhook",
|
||||||
|
"webhookDescription": "Ingresa una URL de webhook para recibir notificaciones de eventos del círculo",
|
||||||
|
"webhookPlaceholder": "https://tu-url-webhook.com"
|
||||||
|
},
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Configuración de la Cuenta",
|
||||||
|
"subscription": "Suscripción",
|
||||||
|
"subscriptionStatus": "Plan Actual",
|
||||||
|
"free": "Gratis",
|
||||||
|
"plus": "Plus",
|
||||||
|
"upgrade": "Actualizar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"changePassword": "Cambiar Contraseña",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"dangerZone": "Zona de Peligro",
|
||||||
|
"dangerZoneDescription": "Una vez que elimines tu cuenta, no hay vuelta atrás. Por favor, está seguro.",
|
||||||
|
"deleteAccount": "Eliminar Cuenta"
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localización",
|
||||||
|
"description": "Personaliza el idioma, formato de fecha y preferencias regionales para tu cuenta.",
|
||||||
|
"language": "Idioma",
|
||||||
|
"languageDescription": "Selecciona tu idioma preferido",
|
||||||
|
"dateFormat": "Formato de Fecha",
|
||||||
|
"dateFormatDescription": "Elige cómo se deben mostrar las fechas en toda la aplicación",
|
||||||
|
"timeFormat": "Formato de Hora",
|
||||||
|
"timeFormatDescription": "Selecciona formato de 12 o 24 horas",
|
||||||
|
"12hour": "12 horas (AM/PM)",
|
||||||
|
"24hour": "24 horas",
|
||||||
|
"firstDayOfWeek": "Primer Día de la Semana",
|
||||||
|
"firstDayOfWeekDescription": "Selecciona qué día comienza tu semana",
|
||||||
|
"sunday": "Domingo",
|
||||||
|
"monday": "Lunes",
|
||||||
|
"saturday": "Sábado",
|
||||||
|
"formats": {
|
||||||
|
"mdy": "MM/DD/AAAA (EE.UU.)",
|
||||||
|
"dmy": "DD/MM/AAAA (Europa)",
|
||||||
|
"ymd": "AAAA-MM-DD (ISO)",
|
||||||
|
"long": "Formato largo (ej., 1 de enero de 2024)",
|
||||||
|
"short": "Formato corto (ej., 1 ene 2024)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Personalización del Panel Lateral",
|
||||||
|
"description": "Personaliza el diseño y la visibilidad de las tarjetas en el panel lateral. Esta sección solo está disponible en dispositivos de pantalla grande como tabletas y computadoras de escritorio."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Preferencias de tema",
|
||||||
|
"description": "Elige cómo se ve el sitio para ti. Selecciona un solo tema o sincronízalo con tu sistema y cambia automáticamente entre temas de día y noche.",
|
||||||
|
"themeMode": "Modo de tema",
|
||||||
|
"light": "Claro",
|
||||||
|
"dark": "Oscuro",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"settingsSaved": "Configuración guardada con éxito",
|
||||||
|
"settingsSaveFailed": "Error al guardar la configuración",
|
||||||
|
"invalidWebhook": "URL de webhook no válida"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Configuración del Perfil",
|
||||||
|
"description": "Actualiza tu nombre para mostrar y foto de perfil.",
|
||||||
|
"photoUpdated": "Foto Actualizada",
|
||||||
|
"photoUpdatedMessage": "¡Tu foto de perfil ha sido actualizada con éxito!",
|
||||||
|
"uploadFailed": "Error al Subir",
|
||||||
|
"uploadFailedMessage": "Error al subir tu foto. Por favor, inténtalo de nuevo.",
|
||||||
|
"profileUpdated": "Perfil Actualizado",
|
||||||
|
"profileUpdatedMessage": "¡Tu información de perfil ha sido guardada con éxito!",
|
||||||
|
"updateFailed": "Error al Actualizar",
|
||||||
|
"updateFailedMessage": "No se pudo actualizar tu perfil. Por favor, verifica tu conexión e inténtalo de nuevo.",
|
||||||
|
"changePhoto": "Cambiar Foto",
|
||||||
|
"displayName": "Nombre para Mostrar",
|
||||||
|
"displayNamePlaceholder": "Ingresa tu nombre para mostrar",
|
||||||
|
"timezone": "Zona Horaria",
|
||||||
|
"timezonePlaceholder": "Selecciona tu zona horaria",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Configuración",
|
||||||
|
"subtitle": "Personaliza tu experiencia y administra las preferencias de tu cuenta",
|
||||||
|
"upgrade": {
|
||||||
|
"title": "Actualizar a Plus",
|
||||||
|
"description": "Desbloquea funciones potentes para mejorar tu productividad",
|
||||||
|
"button": "Actualizar Ahora",
|
||||||
|
"features": {
|
||||||
|
"richText": "Descripciones en texto enriquecido",
|
||||||
|
"notifications": "Notificaciones de tareas",
|
||||||
|
"apiIntegrations": "Integraciones API",
|
||||||
|
"advancedAutomation": "Automatización avanzada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"profile": {
|
||||||
|
"title": "Configuración del Perfil",
|
||||||
|
"description": "Actualiza tu información de perfil, foto, nombre para mostrar y preferencias de zona horaria."
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"title": "Configuración del Círculo",
|
||||||
|
"description": "Administra tu círculo, invita miembros y gestiona solicitudes de unión."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Configuración de la Cuenta",
|
||||||
|
"description": "Administra tu suscripción, cambia la contraseña y opciones de eliminación de cuenta."
|
||||||
|
},
|
||||||
|
"subaccounts": {
|
||||||
|
"title": "Cuentas Administradas",
|
||||||
|
"description": "Crea y administra subcuentas para iniciar sesión y completar tareas asignadas."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notificaciones",
|
||||||
|
"description": "Configura notificaciones push, alertas por correo electrónico y destinos de notificación para tareas."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "Autenticación Multifactor",
|
||||||
|
"description": "Agrega una capa adicional de seguridad con MFA usando aplicaciones de autenticación."
|
||||||
|
},
|
||||||
|
"apitokens": {
|
||||||
|
"title": "Tokens API",
|
||||||
|
"description": "Genera y administra tokens de acceso para integraciones de terceros y acceso a la API."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Configuración de Almacenamiento",
|
||||||
|
"description": "Respalda y restaura tus datos, administra el almacenamiento local y las preferencias de sincronización."
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Personalización del Panel Lateral",
|
||||||
|
"description": "Personaliza el diseño y la visibilidad de las tarjetas en la interfaz del panel lateral."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Preferencias de Tema",
|
||||||
|
"description": "Elige tu tema preferido y configura los ajustes de modo oscuro/claro."
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localización",
|
||||||
|
"description": "Personaliza el idioma, formato de fecha, formato de hora y preferencias regionales."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Configuración Avanzada",
|
||||||
|
"description": "Configura webhooks, actualizaciones en tiempo real y otras funciones avanzadas para mejorar la productividad."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"title": "Configuración de Desarrollador",
|
||||||
|
"description": "Ver información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
public/locales/fr-FR/chores.json
Normal file
75
public/locales/fr-FR/chores.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "Tâches",
|
||||||
|
"myChores": "Mes Tâches",
|
||||||
|
"allChores": "Toutes les Tâches",
|
||||||
|
"addChore": "Ajouter une Tâche",
|
||||||
|
"editChore": "Modifier la Tâche",
|
||||||
|
"deleteChore": "Supprimer la Tâche",
|
||||||
|
"completeChore": "Terminer la Tâche",
|
||||||
|
"dueDate": "Date d'Échéance",
|
||||||
|
"assignedTo": "Assigné à",
|
||||||
|
"priority": "Priorité",
|
||||||
|
"status": "Statut",
|
||||||
|
"description": "Description",
|
||||||
|
"choreView": {
|
||||||
|
"assignment": "Attribution",
|
||||||
|
"assigned": "Assigné",
|
||||||
|
"last": "Dernier",
|
||||||
|
"schedule": "Calendrier",
|
||||||
|
"due": "Échéance",
|
||||||
|
"statistics": "Statistiques",
|
||||||
|
"completed": "Terminé",
|
||||||
|
"times": "fois",
|
||||||
|
"details": "Détails",
|
||||||
|
"createdBy": "Créé par",
|
||||||
|
"na": "N/A",
|
||||||
|
"taskCompleted": "Tâche Terminée",
|
||||||
|
"taskCompletedMessage": "Votre tâche a été marquée comme terminée",
|
||||||
|
"taskCompletionUndone": "La complétion de la tâche a été annulée.",
|
||||||
|
"taskSkipUndone": "Le saut de tâche a été annulé.",
|
||||||
|
"undoSuccessful": "Annulation Réussie",
|
||||||
|
"undoFailed": "Annulation Échouée",
|
||||||
|
"undoFailedMessage": "Impossible d'annuler l'action. Veuillez réessayer.",
|
||||||
|
"resetTimer": "Réinitialiser le Minuteur",
|
||||||
|
"resetTimerConfirmation": "Êtes-vous sûr de vouloir réinitialiser le minuteur ? Cela effacera tous les enregistrements de temps depuis le début de la tâche.",
|
||||||
|
"clearAllTimeRecords": "Effacer Tous les Enregistrements de Temps",
|
||||||
|
"clearAllTimeConfirmation": "Cela supprimera définitivement tous les minuteurs de cette tâche et la remettra à l'état \"non démarrée\".",
|
||||||
|
"descriptionTitle": "Description",
|
||||||
|
"description": "Description :",
|
||||||
|
"previousNote": "Note Précédente",
|
||||||
|
"previousNoteLabel": "Note précédente :",
|
||||||
|
"subtasksLabel": "Sous-tâches :",
|
||||||
|
"taskActions": "Actions de Tâche",
|
||||||
|
"addNote": "Ajouter une note",
|
||||||
|
"additionalNotes": "Notes Supplémentaires :",
|
||||||
|
"notePlaceholder": "Ajouter une note sur la complétion...",
|
||||||
|
"setCustomCompletionTime": "Définir une heure de complétion personnalisée",
|
||||||
|
"skipTask": "Passer la Tâche",
|
||||||
|
"skipTaskConfirmation": "Êtes-vous sûr de vouloir passer cette tâche ?",
|
||||||
|
"markComplete": "Marquer comme Terminée",
|
||||||
|
"markAsDone": "Marquer comme fait",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"unarchive": "Désarchiver",
|
||||||
|
"viewHistory": "Voir l'Historique",
|
||||||
|
"history": "Historique",
|
||||||
|
"startTimer": "Démarrer le Minuteur",
|
||||||
|
"start": "Démarrer",
|
||||||
|
"pauseTimer": "Mettre en Pause le Minuteur",
|
||||||
|
"approve": "Approuver",
|
||||||
|
"reject": "Rejeter",
|
||||||
|
"pendingApproval": "En Attente d'Approbation",
|
||||||
|
"undo": "Annuler",
|
||||||
|
"skip": "Passer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"noPriority": "Aucune Priorité",
|
||||||
|
"subtasks": "Sous-tâches",
|
||||||
|
"noDescription": "Aucune description disponible",
|
||||||
|
"timer": {
|
||||||
|
"active": "Minuteur Actif",
|
||||||
|
"paused": "Minuteur en Pause",
|
||||||
|
"reset": "Réinitialiser le Minuteur",
|
||||||
|
"delete": "Supprimer la Session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/locales/fr-FR/common.json
Normal file
33
public/locales/fr-FR/common.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"close": "Fermer",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"error": "Erreur",
|
||||||
|
"success": "Succès",
|
||||||
|
"warning": "Avertissement",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié !",
|
||||||
|
"settings": "Paramètres",
|
||||||
|
"yes": "Oui",
|
||||||
|
"no": "Non",
|
||||||
|
"back": "Retour",
|
||||||
|
"backToCalendar": "Retour au Calendrier",
|
||||||
|
"logout": "Déconnexion",
|
||||||
|
"version": "Version",
|
||||||
|
"navigation": {
|
||||||
|
"allTasks": "Toutes les Tâches",
|
||||||
|
"archived": "Archivées",
|
||||||
|
"things": "Objets",
|
||||||
|
"labels": "Étiquettes",
|
||||||
|
"projects": "Projets",
|
||||||
|
"filters": "Filtres",
|
||||||
|
"activities": "Activités",
|
||||||
|
"points": "Points",
|
||||||
|
"settings": "Paramètres"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
public/locales/fr-FR/settings.json
Normal file
174
public/locales/fr-FR/settings.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"title": "Paramètres",
|
||||||
|
"circleSettings": {
|
||||||
|
"title": "Paramètres du cercle",
|
||||||
|
"description": "Votre compte est automatiquement connecté à un Cercle lorsque vous en créez un ou en rejoignez un. Invitez facilement des amis en partageant le code unique du Cercle ou le lien ci-dessous.",
|
||||||
|
"circleCode": "Code du Cercle",
|
||||||
|
"copyCode": "Copier le Code",
|
||||||
|
"copyLink": "Copier le Lien",
|
||||||
|
"codeCopied": "Code du cercle copié !",
|
||||||
|
"linkCopied": "Lien copié !",
|
||||||
|
"joinCircle": "Rejoindre un Cercle",
|
||||||
|
"joinCirclePlaceholder": "Entrer le Code du Cercle",
|
||||||
|
"join": "Rejoindre",
|
||||||
|
"leave": "Quitter le Cercle",
|
||||||
|
"leaveConfirmTitle": "Quitter le Cercle",
|
||||||
|
"leaveConfirmMessage": "Êtes-vous sûr de vouloir quitter ce cercle ?",
|
||||||
|
"circleMembers": "Membres du Cercle",
|
||||||
|
"circleMemberRequests": "Demandes de Membres du Cercle",
|
||||||
|
"admin": "Administrateur",
|
||||||
|
"member": "Membre",
|
||||||
|
"pending": "En attente",
|
||||||
|
"accept": "Accepter",
|
||||||
|
"reject": "Rejeter",
|
||||||
|
"makeAdmin": "Nommer Administrateur",
|
||||||
|
"makeMember": "Nommer Membre",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"webhookURL": "URL du Webhook",
|
||||||
|
"webhookDescription": "Entrez une URL de webhook pour recevoir des notifications pour les événements du cercle",
|
||||||
|
"webhookPlaceholder": "https://votre-url-webhook.com"
|
||||||
|
},
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Paramètres du Compte",
|
||||||
|
"subscription": "Abonnement",
|
||||||
|
"subscriptionStatus": "Plan Actuel",
|
||||||
|
"free": "Gratuit",
|
||||||
|
"plus": "Plus",
|
||||||
|
"upgrade": "Mettre à Niveau",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"changePassword": "Changer le Mot de Passe",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"dangerZone": "Zone Dangereuse",
|
||||||
|
"dangerZoneDescription": "Une fois votre compte supprimé, il n'y a pas de retour en arrière. Veuillez être certain.",
|
||||||
|
"deleteAccount": "Supprimer le Compte"
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localisation",
|
||||||
|
"description": "Personnalisez la langue, le format de date et les préférences régionales pour votre compte.",
|
||||||
|
"language": "Langue",
|
||||||
|
"languageDescription": "Sélectionnez votre langue préférée",
|
||||||
|
"dateFormat": "Format de Date",
|
||||||
|
"dateFormatDescription": "Choisissez comment les dates doivent être affichées dans l'application",
|
||||||
|
"timeFormat": "Format de l'Heure",
|
||||||
|
"timeFormatDescription": "Sélectionnez le format 12 heures ou 24 heures",
|
||||||
|
"12hour": "12 heures (AM/PM)",
|
||||||
|
"24hour": "24 heures",
|
||||||
|
"firstDayOfWeek": "Premier Jour de la Semaine",
|
||||||
|
"firstDayOfWeekDescription": "Sélectionnez quel jour commence votre semaine",
|
||||||
|
"sunday": "Dimanche",
|
||||||
|
"monday": "Lundi",
|
||||||
|
"saturday": "Samedi",
|
||||||
|
"formats": {
|
||||||
|
"mdy": "MM/JJ/AAAA (États-Unis)",
|
||||||
|
"dmy": "JJ/MM/AAAA (Europe)",
|
||||||
|
"ymd": "AAAA-MM-JJ (ISO)",
|
||||||
|
"long": "Format long (ex., 1 janvier 2024)",
|
||||||
|
"short": "Format court (ex., 1 janv. 2024)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Personnalisation du Panneau Latéral",
|
||||||
|
"description": "Personnalisez la disposition et la visibilité des cartes dans le panneau latéral. Cette section n'est disponible que sur les grands écrans comme les tablettes et les ordinateurs de bureau."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Préférences de thème",
|
||||||
|
"description": "Choisissez comment le site vous apparaît. Sélectionnez un thème unique ou synchronisez avec votre système pour basculer automatiquement entre les thèmes jour et nuit.",
|
||||||
|
"themeMode": "Mode de thème",
|
||||||
|
"light": "Clair",
|
||||||
|
"dark": "Sombre",
|
||||||
|
"system": "Système"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"settingsSaved": "Paramètres enregistrés avec succès",
|
||||||
|
"settingsSaveFailed": "Échec de l'enregistrement des paramètres",
|
||||||
|
"invalidWebhook": "URL de webhook invalide"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Paramètres du Profil",
|
||||||
|
"description": "Mettez à jour votre nom d'affichage et votre photo de profil.",
|
||||||
|
"photoUpdated": "Photo Mise à Jour",
|
||||||
|
"photoUpdatedMessage": "Votre photo de profil a été mise à jour avec succès !",
|
||||||
|
"uploadFailed": "Échec du Téléchargement",
|
||||||
|
"uploadFailedMessage": "Échec du téléchargement de votre photo. Veuillez réessayer.",
|
||||||
|
"profileUpdated": "Profil Mis à Jour",
|
||||||
|
"profileUpdatedMessage": "Vos informations de profil ont été enregistrées avec succès !",
|
||||||
|
"updateFailed": "Échec de la Mise à Jour",
|
||||||
|
"updateFailedMessage": "Impossible de mettre à jour votre profil. Veuillez vérifier votre connexion et réessayer.",
|
||||||
|
"changePhoto": "Changer la Photo",
|
||||||
|
"displayName": "Nom d'Affichage",
|
||||||
|
"displayNamePlaceholder": "Entrez votre nom d'affichage",
|
||||||
|
"timezone": "Fuseau Horaire",
|
||||||
|
"timezonePlaceholder": "Sélectionnez votre fuseau horaire",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"subtitle": "Personnalisez votre expérience et gérez les préférences de votre compte",
|
||||||
|
"upgrade": {
|
||||||
|
"title": "Passer à Plus",
|
||||||
|
"description": "Débloquez des fonctionnalités puissantes pour améliorer votre productivité",
|
||||||
|
"button": "Mettre à Niveau Maintenant",
|
||||||
|
"features": {
|
||||||
|
"richText": "Descriptions en texte enrichi",
|
||||||
|
"notifications": "Notifications de tâches",
|
||||||
|
"apiIntegrations": "Intégrations API",
|
||||||
|
"advancedAutomation": "Automatisation avancée"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"profile": {
|
||||||
|
"title": "Paramètres du Profil",
|
||||||
|
"description": "Mettez à jour vos informations de profil, photo, nom d'affichage et préférences de fuseau horaire."
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"title": "Paramètres du Cercle",
|
||||||
|
"description": "Gérez votre cercle, invitez des membres et gérez les demandes d'adhésion."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Paramètres du Compte",
|
||||||
|
"description": "Gérez votre abonnement, changez le mot de passe et les options de suppression de compte."
|
||||||
|
},
|
||||||
|
"subaccounts": {
|
||||||
|
"title": "Comptes Gérés",
|
||||||
|
"description": "Créez et gérez des sous-comptes pour vous connecter et accomplir les tâches assignées."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"description": "Configurez les notifications push, les alertes par e-mail et les cibles de notification pour les tâches."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "Authentification Multi-Facteurs",
|
||||||
|
"description": "Ajoutez une couche de sécurité supplémentaire avec MFA en utilisant des applications d'authentification."
|
||||||
|
},
|
||||||
|
"apitokens": {
|
||||||
|
"title": "Jetons API",
|
||||||
|
"description": "Générez et gérez des jetons d'accès pour les intégrations tierces et l'accès à l'API."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Paramètres de Stockage",
|
||||||
|
"description": "Sauvegardez et restaurez vos données, gérez le stockage local et les préférences de synchronisation."
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Personnalisation du Panneau Latéral",
|
||||||
|
"description": "Personnalisez la disposition et la visibilité des cartes dans l'interface du panneau latéral."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Préférences de Thème",
|
||||||
|
"description": "Choisissez votre thème préféré et configurez les paramètres de mode sombre/clair."
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localisation",
|
||||||
|
"description": "Personnalisez la langue, le format de date, le format de l'heure et les préférences régionales."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Paramètres Avancés",
|
||||||
|
"description": "Configurez les webhooks, les mises à jour en temps réel et d'autres fonctionnalités avancées pour améliorer la productivité."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"title": "Paramètres Développeur",
|
||||||
|
"description": "Consultez les informations techniques sur les jetons d'authentification, les connexions SSE et les données de débogage."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"makeMember": "Nommer Membre",
|
"makeMember": "Nommer Membre",
|
||||||
"remove": "Supprimer",
|
"remove": "Supprimer",
|
||||||
"webhookURL": "URL du Webhook",
|
"webhookURL": "URL du Webhook",
|
||||||
"webhookDescription": "Entrez une URL de webhook pour recevoir des notifications pour les événements du cercle",
|
"webhookDescription": "Entrez une URL de Webhook pour recevoir des notifications pour les événements du cercle",
|
||||||
"webhookPlaceholder": "https://votre-url-webhook.com"
|
"webhookPlaceholder": "https://votre-url-webhook.com"
|
||||||
},
|
},
|
||||||
"accountSettings": {
|
"accountSettings": {
|
||||||
|
|||||||
75
public/locales/zh/chores.json
Normal file
75
public/locales/zh/chores.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "Chores",
|
||||||
|
"myChores": "My Chores",
|
||||||
|
"allChores": "All Chores",
|
||||||
|
"addChore": "Add Chore",
|
||||||
|
"editChore": "Edit Chore",
|
||||||
|
"deleteChore": "Delete Chore",
|
||||||
|
"completeChore": "Complete Task",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"assignedTo": "Assigned To",
|
||||||
|
"priority": "Priority",
|
||||||
|
"status": "Status",
|
||||||
|
"description": "Description",
|
||||||
|
"choreView": {
|
||||||
|
"assignment": "Assignment",
|
||||||
|
"assigned": "Assigned",
|
||||||
|
"last": "Last",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"due": "Due",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"completed": "Completed",
|
||||||
|
"times": "times",
|
||||||
|
"details": "Details",
|
||||||
|
"createdBy": "Created By",
|
||||||
|
"na": "N/A",
|
||||||
|
"taskCompleted": "Task Completed",
|
||||||
|
"taskCompletedMessage": "Your task has been marked as complete",
|
||||||
|
"taskCompletionUndone": "Task completion has been undone.",
|
||||||
|
"taskSkipUndone": "Task skip has been undone.",
|
||||||
|
"undoSuccessful": "Undo Successful",
|
||||||
|
"undoFailed": "Undo Failed",
|
||||||
|
"undoFailedMessage": "Unable to undo the action. Please try again.",
|
||||||
|
"resetTimer": "Reset Timer",
|
||||||
|
"resetTimerConfirmation": "Are you sure you want to reset the timer? This will clear all time records since you started the task.",
|
||||||
|
"clearAllTimeRecords": "Clear All Time Records",
|
||||||
|
"clearAllTimeConfirmation": "This will permanently delete all timers for this task and set it back to \"not started\".",
|
||||||
|
"descriptionTitle": "Description",
|
||||||
|
"description": "Description :",
|
||||||
|
"previousNote": "Previous Note",
|
||||||
|
"previousNoteLabel": "Previous note:",
|
||||||
|
"subtasksLabel": "Subtasks :",
|
||||||
|
"taskActions": "Task Actions",
|
||||||
|
"addNote": "Add a note",
|
||||||
|
"additionalNotes": "Additional Notes:",
|
||||||
|
"notePlaceholder": "Add a note about the completion...",
|
||||||
|
"setCustomCompletionTime": "Set custom completion time",
|
||||||
|
"skipTask": "Skip Task",
|
||||||
|
"skipTaskConfirmation": "Are you sure you want to skip this task?",
|
||||||
|
"markComplete": "Mark Complete",
|
||||||
|
"markAsDone": "Mark as done",
|
||||||
|
"edit": "Edit",
|
||||||
|
"archive": "Archive",
|
||||||
|
"unarchive": "Unarchive",
|
||||||
|
"viewHistory": "View History",
|
||||||
|
"history": "History",
|
||||||
|
"startTimer": "Start Timer",
|
||||||
|
"start": "Start",
|
||||||
|
"pauseTimer": "Pause Timer",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"pendingApproval": "Pending Approval",
|
||||||
|
"undo": "Undo",
|
||||||
|
"skip": "Skip",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"noPriority": "No Priority",
|
||||||
|
"subtasks": "Subtasks",
|
||||||
|
"noDescription": "No description available",
|
||||||
|
"timer": {
|
||||||
|
"active": "Timer Active",
|
||||||
|
"paused": "Timer Paused",
|
||||||
|
"reset": "Reset Timer",
|
||||||
|
"delete": "Delete Session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/locales/zh/common.json
Normal file
33
public/locales/zh/common.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"settings": "Settings",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"back": "Back",
|
||||||
|
"backToCalendar": "Back to Calendar",
|
||||||
|
"logout": "Logout",
|
||||||
|
"version": "Version",
|
||||||
|
"navigation": {
|
||||||
|
"allTasks": "All Tasks",
|
||||||
|
"archived": "Archived",
|
||||||
|
"things": "Things",
|
||||||
|
"labels": "Labels",
|
||||||
|
"projects": "Projects",
|
||||||
|
"filters": "Filters",
|
||||||
|
"activities": "Activities",
|
||||||
|
"points": "Points",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
public/locales/zh/settings.json
Normal file
174
public/locales/zh/settings.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"title": "Settings",
|
||||||
|
"circleSettings": {
|
||||||
|
"title": "Circle settings",
|
||||||
|
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle. If you'd like to leave, simply hit the 'Leave Circle' button.",
|
||||||
|
"circleCode": "Circle Code",
|
||||||
|
"copyCode": "Copy Code",
|
||||||
|
"copyLink": "Copy Link",
|
||||||
|
"codeCopied": "Circle code copied!",
|
||||||
|
"linkCopied": "Circle link copied!",
|
||||||
|
"joinCircle": "Join a Circle",
|
||||||
|
"joinCirclePlaceholder": "Enter Circle Code",
|
||||||
|
"join": "Join",
|
||||||
|
"leave": "Leave Circle",
|
||||||
|
"leaveConfirmTitle": "Leave Circle",
|
||||||
|
"leaveConfirmMessage": "Are you sure you want to leave this circle?",
|
||||||
|
"circleMembers": "Circle Members",
|
||||||
|
"circleMemberRequests": "Circle Member Requests",
|
||||||
|
"admin": "Admin",
|
||||||
|
"member": "Member",
|
||||||
|
"pending": "Pending",
|
||||||
|
"accept": "Accept",
|
||||||
|
"reject": "Reject",
|
||||||
|
"makeAdmin": "Make Admin",
|
||||||
|
"makeMember": "Make Member",
|
||||||
|
"remove": "Remove",
|
||||||
|
"webhookURL": "Webhook URL",
|
||||||
|
"webhookDescription": "Enter a webhook URL to receive notifications for circle events",
|
||||||
|
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||||
|
},
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Account Settings",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"subscriptionStatus": "Current Plan",
|
||||||
|
"free": "Free",
|
||||||
|
"plus": "Plus",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"password": "Password",
|
||||||
|
"dangerZone": "Danger Zone",
|
||||||
|
"dangerZoneDescription": "Once you delete your account, there is no going back. Please be certain.",
|
||||||
|
"deleteAccount": "Delete Account"
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localization",
|
||||||
|
"description": "Customize language, date format, and regional preferences for your account.",
|
||||||
|
"language": "Language",
|
||||||
|
"languageDescription": "Select your preferred language",
|
||||||
|
"dateFormat": "Date Format",
|
||||||
|
"dateFormatDescription": "Choose how dates should be displayed throughout the application",
|
||||||
|
"timeFormat": "Time Format",
|
||||||
|
"timeFormatDescription": "Select 12-hour or 24-hour time format",
|
||||||
|
"12hour": "12-hour (AM/PM)",
|
||||||
|
"24hour": "24-hour",
|
||||||
|
"firstDayOfWeek": "First Day of Week",
|
||||||
|
"firstDayOfWeekDescription": "Select which day starts your week",
|
||||||
|
"sunday": "Sunday",
|
||||||
|
"monday": "Monday",
|
||||||
|
"saturday": "Saturday",
|
||||||
|
"formats": {
|
||||||
|
"mdy": "MM/DD/YYYY (US)",
|
||||||
|
"dmy": "DD/MM/YYYY (Europe)",
|
||||||
|
"ymd": "YYYY-MM-DD (ISO)",
|
||||||
|
"long": "Long format (e.g., January 1, 2024)",
|
||||||
|
"short": "Short format (e.g., Jan 1, 2024)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Sidepanel Customization",
|
||||||
|
"description": "Customize the layout and visibility of cards in the sidepanel. This section is only available on large screen devices such as tablets and desktops."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme preferences",
|
||||||
|
"description": "Choose how the site looks to you. Select a single theme, or sync with your system and automatically switch between day and night themes.",
|
||||||
|
"themeMode": "Theme mode",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"settingsSaved": "Settings saved successfully",
|
||||||
|
"settingsSaveFailed": "Failed to save settings",
|
||||||
|
"invalidWebhook": "Invalid webhook URL"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile Settings",
|
||||||
|
"description": "Update your display name and profile photo.",
|
||||||
|
"photoUpdated": "Photo Updated",
|
||||||
|
"photoUpdatedMessage": "Your profile photo has been updated successfully!",
|
||||||
|
"uploadFailed": "Upload Failed",
|
||||||
|
"uploadFailedMessage": "Failed to upload your photo. Please try again.",
|
||||||
|
"profileUpdated": "Profile Updated",
|
||||||
|
"profileUpdatedMessage": "Your profile information has been saved successfully!",
|
||||||
|
"updateFailed": "Update Failed",
|
||||||
|
"updateFailedMessage": "Unable to update your profile. Please check your connection and try again.",
|
||||||
|
"changePhoto": "Change Photo",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"displayNamePlaceholder": "Enter your display name",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"timezonePlaceholder": "Select your timezone",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Settings",
|
||||||
|
"subtitle": "Customize your experience and manage your account preferences",
|
||||||
|
"upgrade": {
|
||||||
|
"title": "Upgrade to Plus",
|
||||||
|
"description": "Unlock powerful features to enhance your productivity",
|
||||||
|
"button": "Upgrade Now",
|
||||||
|
"features": {
|
||||||
|
"richText": "Rich text descriptions",
|
||||||
|
"notifications": "Task notifications",
|
||||||
|
"apiIntegrations": "API integrations",
|
||||||
|
"advancedAutomation": "Advanced automation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile Settings",
|
||||||
|
"description": "Update your profile information, photo, display name, and timezone preferences."
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"title": "Circle Settings",
|
||||||
|
"description": "Manage your circle, invite members, and handle join requests."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Account Settings",
|
||||||
|
"description": "Manage your subscription, change password, and account deletion options."
|
||||||
|
},
|
||||||
|
"subaccounts": {
|
||||||
|
"title": "Managed Accounts",
|
||||||
|
"description": "Create and manage sub accounts to log in and complete assigned tasks."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"description": "Configure push notifications, email alerts, and notification targets for tasks."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "Multi-Factor Authentication",
|
||||||
|
"description": "Add an extra layer of security with MFA using authenticator apps."
|
||||||
|
},
|
||||||
|
"apitokens": {
|
||||||
|
"title": "API Tokens",
|
||||||
|
"description": "Generate and manage access tokens for third-party integrations and API access."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Storage Settings",
|
||||||
|
"description": "Backup and restore your data, manage local storage and sync preferences."
|
||||||
|
},
|
||||||
|
"sidepanel": {
|
||||||
|
"title": "Sidepanel Customization",
|
||||||
|
"description": "Customize the layout and visibility of cards in the sidepanel interface."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme Preferences",
|
||||||
|
"description": "Choose your preferred theme and configure dark/light mode settings."
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"title": "Localization",
|
||||||
|
"description": "Customize language, date format, time format, and regional preferences."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Advanced Settings",
|
||||||
|
"description": "Configure webhooks, real-time updates, and other advanced features for enhanced productivity."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"title": "Developer Settings",
|
||||||
|
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ PLATFORM="both"
|
|||||||
BUMP="patch"
|
BUMP="patch"
|
||||||
SKIP_BUMP=false
|
SKIP_BUMP=false
|
||||||
UPLOAD=false
|
UPLOAD=false
|
||||||
|
UPLOAD_ONLY=false
|
||||||
ANDROID_TRACK="internal"
|
ANDROID_TRACK="internal"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [--android|--ios] [--bump patch|minor|major] [--skip-bump] [--upload] [--track TRACK]
|
Usage: $(basename "$0") [--android|--ios] [--bump patch|minor|major] [--skip-bump] [--upload|--upload-only] [--track TRACK]
|
||||||
|
|
||||||
Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
|
Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
|
|||||||
--bump TYPE Version bump type before building (default: patch)
|
--bump TYPE Version bump type before building (default: patch)
|
||||||
--skip-bump Don't bump the version, build with the current one
|
--skip-bump Don't bump the version, build with the current one
|
||||||
--upload Also upload: Android to Play Store, iOS to TestFlight
|
--upload Also upload: Android to Play Store, iOS to TestFlight
|
||||||
|
--upload-only Skip bump/build entirely, just upload the artifacts already built
|
||||||
--track TRACK Play Store track for --upload (default: internal)
|
--track TRACK Play Store track for --upload (default: internal)
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--bump) BUMP="$2"; shift 2 ;;
|
--bump) BUMP="$2"; shift 2 ;;
|
||||||
--skip-bump) SKIP_BUMP=true; shift ;;
|
--skip-bump) SKIP_BUMP=true; shift ;;
|
||||||
--upload) UPLOAD=true; shift ;;
|
--upload) UPLOAD=true; shift ;;
|
||||||
|
--upload-only) UPLOAD=true; UPLOAD_ONLY=true; shift ;;
|
||||||
--track) ANDROID_TRACK="$2"; shift 2 ;;
|
--track) ANDROID_TRACK="$2"; shift 2 ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||||
@@ -51,17 +54,19 @@ if [ -f "$REPO_ROOT/.env.production" ]; then
|
|||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$SKIP_BUMP" = false ]; then
|
if [ "$UPLOAD_ONLY" = false ]; then
|
||||||
echo "→ Bumping version ($BUMP)"
|
if [ "$SKIP_BUMP" = false ]; then
|
||||||
node bump-version.js "$BUMP"
|
echo "→ Bumping version ($BUMP)"
|
||||||
|
node bump-version.js "$BUMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Building web assets"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "→ Syncing Capacitor"
|
||||||
|
npx cap sync
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "→ Building web assets"
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
echo "→ Syncing Capacitor"
|
|
||||||
npx cap sync
|
|
||||||
|
|
||||||
if [ -f Gemfile ] && command -v bundle >/dev/null 2>&1; then
|
if [ -f Gemfile ] && command -v bundle >/dev/null 2>&1; then
|
||||||
RUN="bundle exec fastlane"
|
RUN="bundle exec fastlane"
|
||||||
else
|
else
|
||||||
@@ -69,8 +74,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
|
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
|
||||||
echo "→ Building signed Android release (.aab)"
|
if [ "$UPLOAD_ONLY" = false ]; then
|
||||||
$RUN android release
|
echo "→ Building signed Android release (.aab)"
|
||||||
|
$RUN android release
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$UPLOAD" = true ]; then
|
if [ "$UPLOAD" = true ]; then
|
||||||
echo "→ Uploading Android release to Play Store ($ANDROID_TRACK track)"
|
echo "→ Uploading Android release to Play Store ($ANDROID_TRACK track)"
|
||||||
@@ -79,8 +86,10 @@ if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "both" ]; then
|
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "both" ]; then
|
||||||
echo "→ Building signed iOS release (.ipa)"
|
if [ "$UPLOAD_ONLY" = false ]; then
|
||||||
$RUN ios release
|
echo "→ Building signed iOS release (.ipa)"
|
||||||
|
$RUN ios release
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$UPLOAD" = true ]; then
|
if [ "$UPLOAD" = true ]; then
|
||||||
echo "→ Uploading iOS release to TestFlight"
|
echo "→ Uploading iOS release to TestFlight"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import PageTransition from './components/animations/PageTransition'
|
|||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||||
|
|
||||||
|
import useOnboardingGate from './hooks/useOnboardingGate'
|
||||||
import useStatusBar from './hooks/useStatusBar'
|
import useStatusBar from './hooks/useStatusBar'
|
||||||
import { useResource } from './queries/ResourceQueries'
|
import { useResource } from './queries/ResourceQueries'
|
||||||
import './styles/safe-area.css'
|
import './styles/safe-area.css'
|
||||||
@@ -33,6 +34,9 @@ const AppContent = () => {
|
|||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
useSyncOnReconnect()
|
useSyncOnReconnect()
|
||||||
|
|
||||||
|
// // First-launch native users see the onboarding flow before anything else.
|
||||||
|
useOnboardingGate()
|
||||||
|
|
||||||
// Initialize status bar with theme-aware configuration
|
// Initialize status bar with theme-aware configuration
|
||||||
useStatusBar()
|
useStatusBar()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-env node */
|
|
||||||
export const API_URL =
|
export const API_URL =
|
||||||
import.meta.env.VITE_APP_API_URL === 'AUTO'
|
import.meta.env.VITE_APP_API_URL === 'AUTO'
|
||||||
? `${window.location.hostname}/api`
|
? `${window.location.hostname}/api`
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import LogoSVG from '@/assets/logo.svg'
|
import LogoSVG from '@/assets/logo.svg'
|
||||||
const Logo = () => {
|
const Logo = ({ size = '128px' }) => {
|
||||||
return (
|
return (
|
||||||
<div className='logo'>
|
<div className='logo'>
|
||||||
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
|
<img src={LogoSVG} alt='logo' width={size} height={size} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1192
src/assets/logo.svg
1192
src/assets/logo.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 7.7 KiB |
@@ -1,16 +1,8 @@
|
|||||||
import { Check, Star } from '@mui/icons-material'
|
import { Check, Star } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Radio,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import AppModal from './common/AppModal'
|
||||||
|
import ModalActions from './common/ModalActions'
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||||
|
|
||||||
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<AppModal
|
||||||
<ModalDialog
|
open={open}
|
||||||
layout='center'
|
onClose={onClose}
|
||||||
sx={{
|
title='Upgrade to Plus'
|
||||||
width: 600,
|
description='Unlock reminders, rich task details, and advanced automation.'
|
||||||
maxWidth: '95vw',
|
size='lg'
|
||||||
maxHeight: '95vh',
|
closeOnBackdrop={!isLoading}
|
||||||
overflow: 'auto',
|
closeOnEscape={!isLoading}
|
||||||
p: 0,
|
footer={
|
||||||
}}
|
<ModalActions
|
||||||
>
|
stackOnMobile
|
||||||
<Box sx={{ p: 4 }}>
|
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||||
{/* Header */}
|
primary={{
|
||||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
label: 'Subscribe',
|
||||||
<Typography level='h3' sx={{ mb: 1 }}>
|
onClick: handleSubscribe,
|
||||||
Upgrade to Plus
|
loading: isLoading,
|
||||||
</Typography>
|
}}
|
||||||
</Box>
|
/>
|
||||||
|
}
|
||||||
{/* Features List */}
|
>
|
||||||
<Box sx={{ mb: 2 }}>
|
{/* Features List */}
|
||||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
What's included:
|
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||||
</Typography>
|
What's included:
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
</Typography>
|
||||||
{features.map((feature, index) => (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box
|
{features.map((feature, index) => (
|
||||||
key={index}
|
<Box
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
key={index}
|
||||||
>
|
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||||
<Check color='success' sx={{ fontSize: 20 }} />
|
>
|
||||||
<Typography level='body-md'>{feature}</Typography>
|
<Check color='success' sx={{ fontSize: 20 }} />
|
||||||
</Box>
|
<Typography level='body-md'>{feature}</Typography>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
))}
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
|
|
||||||
{/* Plan Selection */}
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
|
||||||
>
|
|
||||||
{Object.entries(plans).map(([key, plan]) => (
|
|
||||||
<Card
|
|
||||||
key={key}
|
|
||||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
|
||||||
onClick={() => setSelectedPlan(key)}
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: 48,
|
|
||||||
maxHeight: 64,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
mb: 0.2,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 2.5,
|
|
||||||
py: 1.2,
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'visible',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio
|
|
||||||
checked={selectedPlan === key}
|
|
||||||
onChange={() => setSelectedPlan(key)}
|
|
||||||
value={key}
|
|
||||||
name='subscription-plan'
|
|
||||||
color='primary'
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
/>
|
|
||||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
|
||||||
{plan.price}
|
|
||||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
|
||||||
{' '}
|
|
||||||
/ {plan.period}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
top: -18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<Chip
|
|
||||||
variant='solid'
|
|
||||||
color='warning'
|
|
||||||
size='sm'
|
|
||||||
startDecorator={<Star />}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1,
|
|
||||||
py: 0.1,
|
|
||||||
boxShadow: 2,
|
|
||||||
mt: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Most Popular
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
{plan.savings && (
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='success'
|
|
||||||
size='sm'
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1,
|
|
||||||
py: 0.1,
|
|
||||||
boxShadow: 2,
|
|
||||||
mt: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{plan.savings}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
onClick={handleSubscribe}
|
|
||||||
loading={isLoading}
|
|
||||||
fullWidth
|
|
||||||
size='lg'
|
|
||||||
sx={{ mb: 1 }}
|
|
||||||
>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='plain'
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Typography
|
|
||||||
level='body-xs'
|
|
||||||
color='neutral'
|
|
||||||
sx={{ textAlign: 'center', mt: 3 }}
|
|
||||||
>
|
|
||||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</Box>
|
||||||
</Modal>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ const PageTransition = ({ children }) => {
|
|||||||
location.pathname.includes('/login') ||
|
location.pathname.includes('/login') ||
|
||||||
location.pathname.includes('/signup') ||
|
location.pathname.includes('/signup') ||
|
||||||
location.pathname.includes('/landing') ||
|
location.pathname.includes('/landing') ||
|
||||||
|
location.pathname.includes('/onboarding') ||
|
||||||
|
location.pathname.includes('/get-started') ||
|
||||||
|
location.pathname.includes('/ready') ||
|
||||||
location.pathname.includes('/auth/')
|
location.pathname.includes('/auth/')
|
||||||
|
|
||||||
// Apply transition type as data attribute for CSS
|
// Apply transition type as data attribute for CSS
|
||||||
|
|||||||
230
src/components/common/AppModal.jsx
Normal file
230
src/components/common/AppModal.jsx
Normal 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
|
||||||
@@ -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
|
|
||||||
231
src/components/common/EmptyState.jsx
Normal file
231
src/components/common/EmptyState.jsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/joy'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single empty/error surface for the app.
|
||||||
|
*
|
||||||
|
* variant drives the tone, the icon tile color and the a11y role:
|
||||||
|
* - 'empty' nothing exists yet. Teach the feature, offer the way in.
|
||||||
|
* - 'no-results' something exists, the current search/filter hides it.
|
||||||
|
* - 'error' we failed to load. Say what happened, offer a retry.
|
||||||
|
*
|
||||||
|
* Actions are objects instead of nodes so every call site gets the same
|
||||||
|
* button vocabulary (solid primary lead, plain neutral follow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TONES = {
|
||||||
|
empty: {
|
||||||
|
tileBg: 'primary.softHoverBg',
|
||||||
|
halo: 'primary.softBg',
|
||||||
|
iconColor: 'primary.softColor',
|
||||||
|
role: 'status',
|
||||||
|
},
|
||||||
|
'no-results': {
|
||||||
|
tileBg: 'neutral.softHoverBg',
|
||||||
|
halo: 'neutral.softBg',
|
||||||
|
iconColor: 'neutral.softColor',
|
||||||
|
role: 'status',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
tileBg: 'danger.softHoverBg',
|
||||||
|
halo: 'danger.softBg',
|
||||||
|
iconColor: 'danger.softColor',
|
||||||
|
role: 'alert',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
sm: {
|
||||||
|
tile: 48,
|
||||||
|
icon: '1.375rem',
|
||||||
|
halo: 6,
|
||||||
|
py: 5,
|
||||||
|
title: 'title-sm',
|
||||||
|
titleSize: '1rem',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
tile: 68,
|
||||||
|
icon: '1.875rem',
|
||||||
|
halo: 10,
|
||||||
|
py: 8,
|
||||||
|
title: 'title-md',
|
||||||
|
titleSize: '1.25rem',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionButton = ({ action, ...buttonProps }) => {
|
||||||
|
const { label, to, onClick, ...rest } = action
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
{...rest}
|
||||||
|
onClick={onClick}
|
||||||
|
{...(to ? { component: Link, to } : {})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionButton.propTypes = {
|
||||||
|
action: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState = ({
|
||||||
|
variant = 'empty',
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
secondaryAction,
|
||||||
|
size = 'md',
|
||||||
|
fullHeight = false,
|
||||||
|
sx,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const tone = TONES[variant] || TONES.empty
|
||||||
|
const dimensions = SIZES[size] || SIZES.md
|
||||||
|
const buttonSize = size === 'sm' ? 'sm' : 'md'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role={tone.role}
|
||||||
|
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
px: 3,
|
||||||
|
py: dimensions.py,
|
||||||
|
...(fullHeight && { minHeight: '55vh' }),
|
||||||
|
animation: 'dt-empty-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
'@keyframes dt-empty-state-in': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||||
|
to: { opacity: 1, transform: 'none' },
|
||||||
|
},
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<Box
|
||||||
|
aria-hidden='true'
|
||||||
|
sx={{
|
||||||
|
// Two concentric tints: a wide, pale halo with a deeper medallion
|
||||||
|
// inside it, so the icon reads as an object rather than a chip.
|
||||||
|
width: dimensions.tile + dimensions.halo * 2,
|
||||||
|
height: dimensions.tile + dimensions.halo * 2,
|
||||||
|
mb: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: tone.halo,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: dimensions.tile,
|
||||||
|
height: dimensions.tile,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: tone.tileBg,
|
||||||
|
color: tone.iconColor,
|
||||||
|
'& > svg': { fontSize: dimensions.icon },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
level={dimensions.title}
|
||||||
|
sx={{
|
||||||
|
color: 'text.primary',
|
||||||
|
fontSize: dimensions.titleSize,
|
||||||
|
fontWeight: 'lg',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
textWrap: 'balance',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
maxWidth: '38ch',
|
||||||
|
lineHeight: 1.55,
|
||||||
|
textWrap: 'pretty',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(primaryAction || secondaryAction) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryAction && (
|
||||||
|
<ActionButton
|
||||||
|
action={primaryAction}
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
size={buttonSize}
|
||||||
|
sx={{ minWidth: size === 'sm' ? 0 : 148 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{secondaryAction && (
|
||||||
|
<ActionButton
|
||||||
|
action={secondaryAction}
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
size={buttonSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
EmptyState.propTypes = {
|
||||||
|
variant: PropTypes.oneOf(['empty', 'no-results', 'error']),
|
||||||
|
icon: PropTypes.node,
|
||||||
|
title: PropTypes.node.isRequired,
|
||||||
|
description: PropTypes.node,
|
||||||
|
primaryAction: PropTypes.shape({
|
||||||
|
label: PropTypes.node.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
to: PropTypes.string,
|
||||||
|
startDecorator: PropTypes.node,
|
||||||
|
}),
|
||||||
|
secondaryAction: PropTypes.shape({
|
||||||
|
label: PropTypes.node.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
to: PropTypes.string,
|
||||||
|
startDecorator: PropTypes.node,
|
||||||
|
}),
|
||||||
|
size: PropTypes.oneOf(['sm', 'md']),
|
||||||
|
fullHeight: PropTypes.bool,
|
||||||
|
sx: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState
|
||||||
@@ -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
|
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import BottomSheetModal from './BottomSheetModal'
|
import AppModal from './AppModal'
|
||||||
|
import ModalActions from './ModalActions'
|
||||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Today',
|
label: 'Today',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
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',
|
label: 'Yesterday',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
const y = new Date(t)
|
||||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
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',
|
label: 'This Week',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
const start = new Date(t)
|
||||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
start.setDate(t.getDate() - t.getDay())
|
||||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
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',
|
label: 'Last 7 Days',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
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 n = new Date()
|
||||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
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',
|
label: 'Last 30 Days',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
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',
|
label: 'Last 3 Months',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
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 activeFilterCount = filterDefs.filter(def => {
|
||||||
const value = activeFilters[def.id]
|
const value = activeFilters[def.id]
|
||||||
if (value === undefined || value === null) return false
|
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 (Array.isArray(value) && value.length === 0) return false
|
||||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||||
return true
|
return true
|
||||||
@@ -183,13 +212,18 @@ const FilterBar = ({
|
|||||||
if (value === undefined || value === null) return null
|
if (value === undefined || value === null) return null
|
||||||
|
|
||||||
if (def.type === 'single-select') {
|
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
|
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||||
}
|
}
|
||||||
|
|
||||||
if (def.type === 'boolean') return 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) {
|
if (value.length === 1) {
|
||||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||||
}
|
}
|
||||||
@@ -199,7 +233,10 @@ const FilterBar = ({
|
|||||||
if (def.type === 'date-range') {
|
if (def.type === 'date-range') {
|
||||||
if (!value?.from && !value?.to) return null
|
if (!value?.from && !value?.to) return null
|
||||||
if (value.preset) {
|
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 from = fmtDisplayDate(value.from)
|
||||||
const to = fmtDisplayDate(value.to)
|
const to = fmtDisplayDate(value.to)
|
||||||
@@ -259,7 +296,15 @@ const FilterBar = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
{/* ── 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
|
<Badge
|
||||||
badgeContent={activeFilterCount || null}
|
badgeContent={activeFilterCount || null}
|
||||||
color='primary'
|
color='primary'
|
||||||
@@ -309,37 +354,42 @@ const FilterBar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Tune sx={{ fontSize: 20 }} />
|
<Tune sx={{ fontSize: 20 }} />
|
||||||
Filters
|
Filters
|
||||||
{hasActive && (
|
{hasActive && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
sx={modalCountChipSx}
|
||||||
|
>
|
||||||
{activeFilterCount}
|
{activeFilterCount}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
<ModalActions
|
||||||
<Button
|
tertiary={{
|
||||||
variant='plain'
|
label: 'Clear all',
|
||||||
color='danger'
|
color: 'danger',
|
||||||
size='sm'
|
disabled: !hasActive,
|
||||||
disabled={!hasActive}
|
onClick: onClearAll,
|
||||||
onClick={onClearAll}
|
}}
|
||||||
>
|
primary={{
|
||||||
Clear all
|
label:
|
||||||
</Button>
|
resultCount !== undefined
|
||||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||||
{resultCount !== undefined
|
: 'Done',
|
||||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
onClick: () => setIsOpen(false),
|
||||||
: 'Done'}
|
}}
|
||||||
</Button>
|
/>
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
@@ -348,9 +398,18 @@ const FilterBar = ({
|
|||||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||||
|
|
||||||
{/* Section header */}
|
{/* 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 && (
|
{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}
|
{def.icon}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -359,21 +418,41 @@ const FilterBar = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* active badge in header */}
|
{/* active badge in header */}
|
||||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
{def.type === 'multi-select' &&
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
(activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||||
{activeFilters[def.id].length} selected
|
<Chip
|
||||||
</Chip>
|
size='sm'
|
||||||
)}
|
variant='solid'
|
||||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
color='primary'
|
||||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
sx={sectionBadgeChipSx}
|
||||||
return opt ? (
|
>
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
{activeFilters[def.id].length} selected
|
||||||
{opt.label}
|
|
||||||
</Chip>
|
</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) && (
|
{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)}
|
{getActiveChipLabel(def)}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -383,18 +462,28 @@ const FilterBar = ({
|
|||||||
{def.type === 'multi-select' && (
|
{def.type === 'multi-select' && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{def.options?.map(opt => {
|
{def.options?.map(opt => {
|
||||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
const isSelected = (activeFilters[def.id] || []).includes(
|
||||||
|
opt.value,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
color={
|
||||||
|
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||||
|
}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.avatar ? (
|
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 ? (
|
) : isSelected ? (
|
||||||
<Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
) : (opt.icon ?? null)
|
) : (
|
||||||
|
(opt.icon ?? null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
@@ -415,13 +504,21 @@ const FilterBar = ({
|
|||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
color={
|
||||||
|
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||||
|
}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.avatar ? (
|
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 ? (
|
) : isSelected ? (
|
||||||
<Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
) : (opt.icon ?? null)
|
) : (
|
||||||
|
(opt.icon ?? null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
@@ -438,7 +535,11 @@ const FilterBar = ({
|
|||||||
<Chip
|
<Chip
|
||||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
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)}
|
onClick={() => handleBoolToggle(def.id)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
@@ -447,58 +548,84 @@ const FilterBar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* date-range */}
|
{/* date-range */}
|
||||||
{def.type === 'date-range' && (() => {
|
{def.type === 'date-range' &&
|
||||||
const val = activeFilters[def.id] || {}
|
(() => {
|
||||||
return (
|
const val = activeFilters[def.id] || {}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
return (
|
||||||
{/* Preset chips */}
|
<Box
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
sx={{
|
||||||
{DATE_RANGE_PRESETS.map(preset => {
|
display: 'flex',
|
||||||
const isSelected = val.preset === preset.value
|
flexDirection: 'column',
|
||||||
return (
|
gap: 1.5,
|
||||||
<Chip
|
}}
|
||||||
key={preset.value}
|
>
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
{/* Preset chips */}
|
||||||
color={isSelected ? 'primary' : 'neutral'}
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
{DATE_RANGE_PRESETS.map(preset => {
|
||||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
const isSelected = val.preset === preset.value
|
||||||
sx={selectableChipSx}
|
return (
|
||||||
>
|
<Chip
|
||||||
{preset.label}
|
key={preset.value}
|
||||||
</Chip>
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
)
|
color={isSelected ? 'primary' : 'neutral'}
|
||||||
})}
|
startDecorator={
|
||||||
</Box>
|
isSelected ? (
|
||||||
|
<Check sx={{ fontSize: 14 }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleDateRangePreset(def.id, preset.value)
|
||||||
|
}
|
||||||
|
sx={selectableChipSx}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Custom date inputs */}
|
{/* Custom date inputs */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box
|
||||||
<Input
|
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
|
||||||
type='date'
|
>
|
||||||
size='sm'
|
<Input
|
||||||
value={toInputDate(val.from)}
|
type='date'
|
||||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
size='sm'
|
||||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
value={toInputDate(val.from)}
|
||||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
onChange={e =>
|
||||||
/>
|
handleDateRangeInput(def.id, 'from', e.target.value)
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
}
|
||||||
–
|
slotProps={{
|
||||||
</Typography>
|
input: { max: toInputDate(val.to) || undefined },
|
||||||
<Input
|
}}
|
||||||
type='date'
|
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||||
size='sm'
|
/>
|
||||||
value={toInputDate(val.to)}
|
<Typography
|
||||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
level='body-xs'
|
||||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
sx={{ color: 'text.tertiary', flexShrink: 0 }}
|
||||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
>
|
||||||
/>
|
–
|
||||||
|
</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>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/components/common/ModalActions.jsx
Normal file
61
src/components/common/ModalActions.jsx
Normal 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
|
||||||
52
src/components/common/README.md
Normal file
52
src/components/common/README.md
Normal 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`.
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Close } from '@mui/icons-material'
|
import { Add, Close } from '@mui/icons-material'
|
||||||
import { Box, Button, Chip, Typography } from '@mui/joy'
|
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||||
|
|
||||||
const ActiveFilterChips = ({
|
const ActiveFilterChips = ({
|
||||||
chips = [],
|
chips = [],
|
||||||
onOpen,
|
onOpen,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
|
onAdd,
|
||||||
|
showAddChip = false,
|
||||||
resultCount,
|
resultCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
maxVisible = 2,
|
maxVisible = 2,
|
||||||
@@ -44,13 +46,21 @@ const ActiveFilterChips = ({
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
color={color}
|
color={color}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
<Close
|
// ChipDelete rather than a bare icon: Joy's chip end decorator is
|
||||||
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
|
// `pointer-events: none`, so anything else here is swallowed by the
|
||||||
onClick={e => {
|
// chip's own click surface and can never clear the condition.
|
||||||
e.stopPropagation()
|
<ChipDelete
|
||||||
onClear?.()
|
variant='plain'
|
||||||
|
color={color}
|
||||||
|
onDelete={() => onClear?.()}
|
||||||
|
aria-label={`Remove ${label} filter`}
|
||||||
|
sx={{
|
||||||
|
'--Chip-deleteSize': chipSize === 'sm' ? '1.1rem' : '1.4rem',
|
||||||
|
'--Icon-fontSize': chipSize === 'sm' ? '12px' : '16px',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Close />
|
||||||
|
</ChipDelete>
|
||||||
}
|
}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -74,7 +84,10 @@ const ActiveFilterChips = ({
|
|||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{overflow > 0 && (
|
{/* Everything already visible → spend that slot on a "+" for appending
|
||||||
|
another condition instead. With overflow, the count chip opens the
|
||||||
|
same sheet anyway. */}
|
||||||
|
{overflow > 0 ? (
|
||||||
<Chip
|
<Chip
|
||||||
size={chipSize}
|
size={chipSize}
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -90,6 +103,30 @@ const ActiveFilterChips = ({
|
|||||||
>
|
>
|
||||||
+{overflow} more
|
+{overflow} more
|
||||||
</Chip>
|
</Chip>
|
||||||
|
) : (
|
||||||
|
showAddChip &&
|
||||||
|
(onAdd || onOpen) && (
|
||||||
|
<Chip
|
||||||
|
size={chipSize}
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
onClick={onAdd || onOpen}
|
||||||
|
aria-label='Add filter condition'
|
||||||
|
title='Add filter condition'
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
px: 0.75,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
'&:hover': { opacity: 0.85 },
|
||||||
|
...overflowChipSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Add
|
||||||
|
sx={{ fontSize: chipSize === 'sm' ? 12 : 16, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resultCount != null && totalCount != null && (
|
{resultCount != null && totalCount != null && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import resolveConfig from 'tailwindcss/resolveConfig'
|
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||||
import tailwindConfig from '/tailwind.config.js'
|
|
||||||
|
import tailwindConfig from '/tailwind.config.mjs'
|
||||||
|
|
||||||
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const AVAILABLE_LANGUAGES = [
|
|||||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
{ 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 }) => {
|
export const LocalizationProvider = ({ children }) => {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import FilterView from '../views/Filters/FilterView'
|
|||||||
import ChoreHistory from '../views/History/ChoreHistory'
|
import ChoreHistory from '../views/History/ChoreHistory'
|
||||||
import LabelView from '../views/Labels/LabelView'
|
import LabelView from '../views/Labels/LabelView'
|
||||||
import Landing from '../views/Landing/Landing'
|
import Landing from '../views/Landing/Landing'
|
||||||
|
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
||||||
|
import GetStartedView from '../views/Onboarding/GetStartedView'
|
||||||
|
import HeardAboutView from '../views/Onboarding/HeardAboutView'
|
||||||
|
import OnboardingView from '../views/Onboarding/OnboardingView'
|
||||||
|
import WorkspaceReadyView from '../views/Onboarding/WorkspaceReadyView'
|
||||||
import PaymentCancelledView from '../views/Payments/PaymentFailView'
|
import PaymentCancelledView from '../views/Payments/PaymentFailView'
|
||||||
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
||||||
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
||||||
@@ -182,6 +187,26 @@ const Router = createBrowserRouter([
|
|||||||
path: '/signup',
|
path: '/signup',
|
||||||
element: <SignupView />,
|
element: <SignupView />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/onboarding',
|
||||||
|
element: <OnboardingView />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/get-started',
|
||||||
|
element: <GetStartedView />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ready',
|
||||||
|
element: <WorkspaceReadyView />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/circle-setup',
|
||||||
|
element: <CircleSetupView />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/heard-about',
|
||||||
|
element: <HeardAboutView />,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/auth/:provider',
|
path: '/auth/:provider',
|
||||||
|
|||||||
@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
|||||||
import PropType from 'prop-types'
|
import PropType from 'prop-types'
|
||||||
|
|
||||||
const primaryColor = 'cyan'
|
const primaryColor = 'cyan'
|
||||||
|
|
||||||
const shades = [
|
const shades = [
|
||||||
'50',
|
'50',
|
||||||
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
||||||
]
|
]
|
||||||
|
|
||||||
const getPallete = (key = primaryColor) => {
|
const getPalette = (key = primaryColor) =>
|
||||||
return shades.reduce((acc, shade) => {
|
shades.reduce((palette, shade) => {
|
||||||
acc[shade] = COLORS[key][shade]
|
palette[shade] = COLORS[key][shade]
|
||||||
return acc
|
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({
|
const theme = extendTheme({
|
||||||
|
radius: {
|
||||||
|
xs: '6px',
|
||||||
|
sm: '8px',
|
||||||
|
md: '10px',
|
||||||
|
lg: '12px',
|
||||||
|
xl: '16px',
|
||||||
|
},
|
||||||
colorSchemes: {
|
colorSchemes: {
|
||||||
light: {
|
light: {
|
||||||
palette: {
|
palette: {
|
||||||
@@ -42,42 +53,100 @@ const theme = extendTheme({
|
|||||||
200: '#fbd5d5',
|
200: '#fbd5d5',
|
||||||
300: '#f9c1c1',
|
300: '#f9c1c1',
|
||||||
400: '#f6a8a8',
|
400: '#f6a8a8',
|
||||||
500: '',
|
500: '#ef4444',
|
||||||
600: '#f47272',
|
600: '#dc2626',
|
||||||
700: '#e33434',
|
700: '#b91c1c',
|
||||||
800: '#cc1f1a',
|
800: '#991b1b',
|
||||||
900: '#b91c1c',
|
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',
|
dark: {
|
||||||
100: '#fef8e1',
|
palette: {
|
||||||
200: '#fdecb2',
|
primary: primaryPalette,
|
||||||
300: '#fcd982',
|
|
||||||
400: '#fbcf52',
|
|
||||||
500: '#f9c222',
|
|
||||||
600: '#f6b81e',
|
|
||||||
700: '#f3ae1a',
|
|
||||||
800: '#f0a416',
|
|
||||||
900: '#e99b0e',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
components: {
|
||||||
palette: {
|
JoyButton: {
|
||||||
primary: primaryPalette,
|
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 }) => {
|
const ThemeContext = ({ children }) => (
|
||||||
return (
|
<CssVarsProvider theme={theme}>
|
||||||
<CssVarsProvider theme={theme}>
|
<CssBaseline />
|
||||||
<CssBaseline />
|
{children}
|
||||||
{children}
|
</CssVarsProvider>
|
||||||
</CssVarsProvider>
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeContext.propTypes = {
|
ThemeContext.propTypes = {
|
||||||
children: PropType.node,
|
children: PropType.node,
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const res = await response.json()
|
const res = await response.json()
|
||||||
return { success: false, error: res?.error || 'Login failed' }
|
// `status` is passed back so callers can tell a rejected password from
|
||||||
|
// an unreachable or broken server without parsing the message.
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
error: res?.error || 'Login failed',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
120
src/hooks/useLongPress.js
Normal file
120
src/hooks/useLongPress.js
Normal 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
|
||||||
38
src/hooks/useOnboardingGate.js
Normal file
38
src/hooks/useOnboardingGate.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||||
|
|
||||||
|
// Routes a first-run user may legitimately be on without having gone through
|
||||||
|
// onboarding: the flow itself, deep-link auth callbacks, and the legal pages
|
||||||
|
// linked from it.
|
||||||
|
const ALLOWED_PATHS = [
|
||||||
|
'/onboarding',
|
||||||
|
'/get-started',
|
||||||
|
'/login/settings',
|
||||||
|
'/privacy',
|
||||||
|
'/terms',
|
||||||
|
]
|
||||||
|
|
||||||
|
const isAllowed = pathname =>
|
||||||
|
ALLOWED_PATHS.includes(pathname) || pathname.startsWith('/auth/')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends first-launch native users to the onboarding flow. Runs on every
|
||||||
|
* navigation because an expired session hard-redirects to /login through
|
||||||
|
* `window.location`, which remounts the app.
|
||||||
|
*/
|
||||||
|
const useOnboardingGate = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNativeApp() || hasSeenOnboarding()) return
|
||||||
|
// A signed-in user upgrading from an older build has nothing to onboard to.
|
||||||
|
if (localStorage.getItem('token')) return
|
||||||
|
if (isAllowed(pathname)) return
|
||||||
|
|
||||||
|
navigate('/onboarding', { replace: true })
|
||||||
|
}, [pathname, navigate])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useOnboardingGate
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import FadeModal from '../components/common/FadeModal'
|
import { createElement } from 'react'
|
||||||
import useWindowWidth from './useWindowWidth'
|
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
|
* Backwards-compatible access to the app modal system.
|
||||||
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
|
*
|
||||||
* @returns {Object} - { Modal: Component, isMobile: boolean }
|
* New code may render AppModal directly when it already knows the desired
|
||||||
|
* presentation. Existing callers can continue using ResponsiveModal.
|
||||||
*/
|
*/
|
||||||
export const useResponsiveModal = (breakpoint = 768) => {
|
export const useResponsiveModal = (breakpoint = 768) => {
|
||||||
const windowWidth = useWindowWidth()
|
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
|
||||||
const isMobile = windowWidth <= breakpoint
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
|
||||||
isMobile,
|
isMobile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ const isNetworkError = error =>
|
|||||||
((error instanceof TypeError && error.message === 'Failed to fetch') ||
|
((error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||||
error?.name === 'AbortError')
|
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 => ({
|
const buildOfflineChore = task => ({
|
||||||
...task,
|
...task,
|
||||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||||
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
|
|||||||
try {
|
try {
|
||||||
const resp = await CreateChore(newTask)
|
const resp = await CreateChore(newTask)
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
throw new Error('Failed to create chore')
|
throw await errorFromResponse(resp, 'Failed to create chore')
|
||||||
}
|
}
|
||||||
const createdChore = await resp.json()
|
const createdChore = await resp.json()
|
||||||
if (!createdChore) {
|
if (!createdChore) {
|
||||||
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
|
|||||||
try {
|
try {
|
||||||
const resp = await SaveChore(updatedChore)
|
const resp = await SaveChore(updatedChore)
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
throw new Error('Failed to save chore')
|
throw await errorFromResponse(resp, 'Failed to save chore')
|
||||||
}
|
}
|
||||||
const updatedChoreRes = await resp.json()
|
const updatedChoreRes = await resp.json()
|
||||||
if (!updatedChoreRes) {
|
if (!updatedChoreRes) {
|
||||||
|
|||||||
475
src/service/FeedbackService.js
Normal file
475
src/service/FeedbackService.js
Normal 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',
|
||||||
|
}
|
||||||
@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: HEADERS(),
|
headers: HEADERS(),
|
||||||
body: JSON.stringify(body),
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
src/utils/Onboarding.js
Normal file
114
src/utils/Onboarding.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
|
// First-run onboarding is a native-app-only flow. On the web the user already
|
||||||
|
// chose to visit a URL, so we drop them straight on the auth screens.
|
||||||
|
const STORAGE_KEY = 'onboardingCompletedAt'
|
||||||
|
|
||||||
|
export const isNativeApp = () => {
|
||||||
|
try {
|
||||||
|
return Capacitor.isNativePlatform()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSeenOnboarding = () => {
|
||||||
|
try {
|
||||||
|
return Boolean(localStorage.getItem(STORAGE_KEY))
|
||||||
|
} catch {
|
||||||
|
// Private-mode / storage-disabled webviews: never trap the user in a loop.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markOnboardingSeen = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, new Date().toISOString())
|
||||||
|
} catch {
|
||||||
|
// ignore, worst case the flow is shown once more
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetOnboarding = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACQUISITION_SOURCE_KEY = 'acquisitionSource'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stashes the "where'd you hear about us" answer locally for now. No
|
||||||
|
* analytics pipeline is wired up yet — this is the single place that'll
|
||||||
|
* change once there is one, so the survey screen itself never has to.
|
||||||
|
*/
|
||||||
|
export const recordAcquisitionSource = source => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ACQUISITION_SOURCE_KEY, source)
|
||||||
|
} catch {
|
||||||
|
// ignore, this is best-effort telemetry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIVACY_PREFERENCES_KEY = 'privacyPreferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stashes the self-hosted privacy opt-ins locally, same stub-for-now
|
||||||
|
* treatment as recordAcquisitionSource: no crash reporter or PostHog is wired
|
||||||
|
* up yet, so this is just the one place that'll change once there is one.
|
||||||
|
*/
|
||||||
|
export const recordPrivacyPreferences = ({ crashReports, analytics }) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
PRIVACY_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({ crashReports, analytics }),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// ignore, this is best-effort telemetry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the OS for notification permission during onboarding and records the
|
||||||
|
* answer under the same `notificationPreferences` key NotificationAccessSnackbar
|
||||||
|
* reads, so a user who says yes here is never asked again after signing in.
|
||||||
|
*
|
||||||
|
* Only the permission is requested: registering the push token needs a session,
|
||||||
|
* and there isn't one yet. The snackbar picks that up once the user is in.
|
||||||
|
*/
|
||||||
|
export const requestNotificationPermission = async () => {
|
||||||
|
if (!isNativeApp()) return false
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import(
|
||||||
|
'@capacitor/local-notifications'
|
||||||
|
)
|
||||||
|
const { Preferences } = await import('@capacitor/preferences')
|
||||||
|
|
||||||
|
const result = await LocalNotifications.requestPermissions()
|
||||||
|
const granted = result?.display === 'granted'
|
||||||
|
|
||||||
|
await Preferences.set({
|
||||||
|
key: 'notificationPreferences',
|
||||||
|
value: JSON.stringify({ optOut: false, granted }),
|
||||||
|
})
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
// Permission plugins are missing or the prompt was dismissed: carry on,
|
||||||
|
// the in-app snackbar can still ask later.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const haptic = async (kind = 'light') => {
|
||||||
|
if (!isNativeApp()) return
|
||||||
|
try {
|
||||||
|
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
|
||||||
|
await Haptics.impact({
|
||||||
|
style: kind === 'medium' ? ImpactStyle.Medium : ImpactStyle.Light,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// no haptics on this platform
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/views/Authorization/AuthFields.jsx
Normal file
160
src/views/Authorization/AuthFields.jsx
Normal 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>
|
||||||
|
)
|
||||||
117
src/views/Authorization/AuthShell.jsx
Normal file
117
src/views/Authorization/AuthShell.jsx
Normal 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
|
||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import Logo from '../../Logo'
|
|
||||||
|
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
@@ -11,14 +10,16 @@ import { apiClient } from '../../utils/ApiClient'
|
|||||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||||
import { GetUserProfile } from '../../utils/Fetcher'
|
import { GetUserProfile } from '../../utils/Fetcher'
|
||||||
import { saveTokens } from '../../utils/TokenStorage'
|
import { saveTokens } from '../../utils/TokenStorage'
|
||||||
|
import AuthShell from './AuthShell'
|
||||||
|
import { authButtonSx } from './authStyles'
|
||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
const AuthenticationLoading = () => {
|
const AuthenticationLoading = () => {
|
||||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
const { refetch: refetchUserProfile } = useUserProfile()
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
const hasCalledHandleOAuth2 = useRef(false)
|
const hasCalledHandleOAuth2 = useRef(false)
|
||||||
const [message, setMessage] = useState('Authenticating')
|
const [message, setMessage] = useState('Signing you in')
|
||||||
const [subMessage, setSubMessage] = useState('Please wait')
|
const [subMessage, setSubMessage] = useState('This will only take a moment.')
|
||||||
const [status, setStatus] = useState('pending')
|
const [status, setStatus] = useState('pending')
|
||||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||||
@@ -30,14 +31,15 @@ const AuthenticationLoading = () => {
|
|||||||
// suppress a genuine session expiry later on.
|
// suppress a genuine session expiry later on.
|
||||||
handleOAuth2().finally(endOAuthExchange)
|
handleOAuth2().finally(endOAuthExchange)
|
||||||
} else if (provider !== 'oauth2') {
|
} else if (provider !== 'oauth2') {
|
||||||
setMessage('Unknown Authentication Provider')
|
setMessage('Unknown sign-in provider')
|
||||||
setSubMessage('Please contact support')
|
setSubMessage('Please contact support.')
|
||||||
|
setStatus('error')
|
||||||
}
|
}
|
||||||
return endOAuthExchange
|
return endOAuthExchange
|
||||||
}, [provider])
|
}, [provider])
|
||||||
const getUserProfileAndNavigateToHome = () => {
|
const getUserProfileAndNavigateToHome = () => {
|
||||||
GetUserProfile().then(data => {
|
GetUserProfile().then(response => {
|
||||||
data.json().then(data => {
|
response.json().then(() => {
|
||||||
refetchUserProfile().then(() => {
|
refetchUserProfile().then(() => {
|
||||||
// check if redirect url is set in cookie:
|
// check if redirect url is set in cookie:
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
@@ -69,8 +71,8 @@ const AuthenticationLoading = () => {
|
|||||||
const handleMFAClose = () => {
|
const handleMFAClose = () => {
|
||||||
setMfaModalOpen(false)
|
setMfaModalOpen(false)
|
||||||
setMfaSessionToken('')
|
setMfaSessionToken('')
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('Two-factor authentication was cancelled')
|
setSubMessage('Two-factor authentication was cancelled.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +85,8 @@ const AuthenticationLoading = () => {
|
|||||||
const storedState = localStorage.getItem('authState')
|
const storedState = localStorage.getItem('authState')
|
||||||
|
|
||||||
if (returnedState !== storedState) {
|
if (returnedState !== storedState) {
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('State does not match')
|
setSubMessage('The sign-in request could not be verified.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,8 +112,8 @@ const AuthenticationLoading = () => {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Authentication failed')
|
console.error('Authentication failed')
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('Please try again')
|
setSubMessage('Please try again.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,22 +122,22 @@ const AuthenticationLoading = () => {
|
|||||||
|
|
||||||
if (data.mfaRequired) {
|
if (data.mfaRequired) {
|
||||||
if (!data.sessionToken) {
|
if (!data.sessionToken) {
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('MFA session is missing. Please try again')
|
setSubMessage('The MFA session is missing. Please try again.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setMfaSessionToken(data.sessionToken)
|
setMfaSessionToken(data.sessionToken)
|
||||||
setMfaModalOpen(true)
|
setMfaModalOpen(true)
|
||||||
setMessage('Two-Factor Authentication Required')
|
setMessage('Two-factor authentication')
|
||||||
setSubMessage('Please verify your login to continue')
|
setSubMessage('Verify your login to continue.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.token && !data.access_token) {
|
if (!data.token && !data.access_token) {
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('No valid authentication token returned')
|
setSubMessage('No valid authentication token was returned.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -156,66 +158,52 @@ const AuthenticationLoading = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Authentication request failed', error)
|
console.error('Authentication request failed', error)
|
||||||
setMessage('Authentication failed')
|
setMessage('Sign-in failed')
|
||||||
setSubMessage('Please try again')
|
setSubMessage('Please try again.')
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className='flex h-full items-center justify-center'>
|
<AuthShell title={message} subtitle={subMessage}>
|
||||||
<Box
|
<Box
|
||||||
className='flex flex-col items-center justify-center'
|
role='status'
|
||||||
sx={{
|
aria-live='polite'
|
||||||
minHeight: '80vh',
|
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CircularProgress
|
{status === 'pending' && (
|
||||||
determinate={status === 'error'}
|
<LinearProgress
|
||||||
color={status === 'pending' ? 'primary' : 'danger'}
|
sx={{ width: '60%', '--LinearProgress-radius': '999px' }}
|
||||||
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 === 'error' && (
|
{status === 'error' && (
|
||||||
<Button
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to='/login'
|
||||||
size='lg'
|
size='lg'
|
||||||
variant='outlined'
|
variant='soft'
|
||||||
sx={{
|
color='neutral'
|
||||||
mt: 4,
|
fullWidth
|
||||||
}}
|
sx={authButtonSx}
|
||||||
>
|
>
|
||||||
<Link to='/login'>Go back Login</Link>
|
Back to sign in
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MFAVerificationModal
|
|
||||||
open={mfaModalOpen}
|
|
||||||
onClose={handleMFAClose}
|
|
||||||
sessionToken={mfaSessionToken}
|
|
||||||
onSuccess={handleMFASuccess}
|
|
||||||
onError={() => {
|
|
||||||
setMessage('Authentication failed')
|
|
||||||
setSubMessage('Two-factor authentication failed. Please try again')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,38 @@
|
|||||||
// create boilerplate for ResetPasswordView:
|
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
|
||||||
import {
|
import { Box, Button, Link, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Logo from '../../Logo'
|
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ResetPassword } from '../../utils/Fetcher'
|
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 ForgotPasswordView = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailError, setEmailError] = useState(null)
|
const [emailError, setEmailError] = useState(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const { showError, showNotification } = useNotification()
|
const { showError, showNotification } = useNotification()
|
||||||
|
|
||||||
const validateEmail = email => {
|
const handleSubmit = async e => {
|
||||||
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
e?.preventDefault()
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return setEmailError('Email is required')
|
setEmailError('Email is required')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate email:
|
if (isInvalidEmail(email)) {
|
||||||
if (validateEmail(email)) {
|
|
||||||
setEmailError('Please enter a valid email address')
|
setEmailError('Please enter a valid email address')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emailError) {
|
setIsSubmitting(true)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ResetPassword(email)
|
const response = await ResetPassword(email)
|
||||||
|
|
||||||
@@ -64,146 +56,106 @@ const ForgotPasswordView = () => {
|
|||||||
title: 'Reset Failed',
|
title: 'Reset Failed',
|
||||||
message: 'Failed to send reset email, please try again later',
|
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 => {
|
const handleEmailChange = e => {
|
||||||
setEmail(e.target.value)
|
setEmail(e.target.value)
|
||||||
if (validateEmail(e.target.value)) {
|
if (emailError) {
|
||||||
setEmailError('Please enter a valid email address')
|
|
||||||
} else {
|
|
||||||
setEmailError(null)
|
setEmailError(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleEmailBlur = () => {
|
||||||
<Container component='main' maxWidth='xs'>
|
if (email && isInvalidEmail(email)) {
|
||||||
<Box
|
setEmailError('Please enter a valid email address')
|
||||||
sx={{
|
}
|
||||||
marginTop: 4,
|
}
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
if (resetStatusOk !== null) {
|
||||||
alignItems: 'center',
|
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
|
<Box
|
||||||
component='form'
|
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1,
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
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'>
|
return (
|
||||||
Done
|
<AuthShell
|
||||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
title='Reset your password'
|
||||||
</Typography>
|
subtitle="Enter your email and we'll send you a link to get back into your account."
|
||||||
{resetStatusOk === null && (
|
footer={<LegalLinks />}
|
||||||
<>
|
logoSize={0}
|
||||||
<Typography level='body2' sx={{ mb: 3 }}>
|
>
|
||||||
Enter your email, and we'll send you a link to get into your
|
<Box
|
||||||
account.
|
component='form'
|
||||||
</Typography>
|
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}>
|
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||||
Email Address
|
Send reset link
|
||||||
</Typography>
|
</AuthSubmitButton>
|
||||||
<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>
|
|
||||||
</Box>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,16 @@ import { Preferences } from '@capacitor/preferences'
|
|||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||||
import WifiIcon from '@mui/icons-material/Wifi'
|
import WifiIcon from '@mui/icons-material/Wifi'
|
||||||
import {
|
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { API_URL } from '../../Config'
|
import { API_URL } from '../../Config'
|
||||||
import Logo from '../../Logo'
|
|
||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
import { offlineDB } from '../../utils/OfflineDB'
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||||
|
import AuthShell from './AuthShell'
|
||||||
|
import { authButtonSx } from './authStyles'
|
||||||
|
|
||||||
const CONNECTION_TIMEOUT_MS = 8000
|
const CONNECTION_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
@@ -138,7 +131,8 @@ const LoginSettings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async e => {
|
||||||
|
e.preventDefault()
|
||||||
const trimmedURL = serverURL.trim()
|
const trimmedURL = serverURL.trim()
|
||||||
|
|
||||||
if (trimmedURL === '') {
|
if (trimmedURL === '') {
|
||||||
@@ -192,138 +186,113 @@ const LoginSettings = () => {
|
|||||||
const isTesting = status === 'testing'
|
const isTesting = status === 'testing'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container component='main' maxWidth='xs'>
|
<AuthShell
|
||||||
|
title='Server settings'
|
||||||
|
subtitle='Point the app at your own self-hosted Donetick server.'
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
component='form'
|
||||||
marginTop: 4,
|
onSubmit={handleSave}
|
||||||
display: 'flex',
|
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Sheet
|
<AuthTextField
|
||||||
component='form'
|
label='Server URL'
|
||||||
sx={{
|
id='serverURL'
|
||||||
mt: 1,
|
name='serverURL'
|
||||||
width: '100%',
|
inputMode='url'
|
||||||
display: 'flex',
|
autoCapitalize='none'
|
||||||
flexDirection: 'column',
|
autoCorrect='off'
|
||||||
alignItems: 'center',
|
spellCheck='false'
|
||||||
padding: 2,
|
placeholder='https://your-server:2021'
|
||||||
borderRadius: '8px',
|
autoFocus
|
||||||
boxShadow: 'md',
|
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 />
|
Reset to default server
|
||||||
|
</Button>
|
||||||
<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>
|
|
||||||
</Box>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,10 @@ import { Capacitor } from '@capacitor/core'
|
|||||||
import { Device } from '@capacitor/device'
|
import { Device } from '@capacitor/device'
|
||||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||||
import { SocialLogin } from '@capgo/capacitor-social-login'
|
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 AppleIcon from '@mui/icons-material/Apple'
|
||||||
import GoogleIcon from '@mui/icons-material/Google'
|
import GoogleIcon from '@mui/icons-material/Google'
|
||||||
import {
|
import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Divider,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Tab,
|
|
||||||
TabList,
|
|
||||||
TabPanel,
|
|
||||||
Tabs,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -28,25 +14,83 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||||
import Logo from '../../Logo'
|
|
||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
import { saveTokens } from '../../utils/TokenStorage'
|
import { saveTokens } from '../../utils/TokenStorage'
|
||||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
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'
|
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 = () => {
|
const LoginView = () => {
|
||||||
// Use React Query client directly to invalidate the user profile query
|
// Use React Query client directly to invalidate the user profile query
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
// const [userProfile, setUserProfile] = useState(null)
|
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||||
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
// Child login state
|
// Child login state
|
||||||
const [loginType, setLoginType] = useState('primary')
|
const [loginType, setLoginType] = useState('primary')
|
||||||
@@ -54,7 +98,7 @@ const LoginView = () => {
|
|||||||
const [childName, setChildName] = useState('')
|
const [childName, setChildName] = useState('')
|
||||||
|
|
||||||
// Clear fields when switching login modes
|
// Clear fields when switching login modes
|
||||||
const handleLoginModeChange = (event, newValue) => {
|
const handleLoginModeChange = newValue => {
|
||||||
setLoginType(newValue)
|
setLoginType(newValue)
|
||||||
setUsername('')
|
setUsername('')
|
||||||
setParentUsername('')
|
setParentUsername('')
|
||||||
@@ -94,7 +138,6 @@ const LoginView = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && user) {
|
if (isAuthenticated && user) {
|
||||||
setUserProfile(user)
|
|
||||||
Navigate('/chores')
|
Navigate('/chores')
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, user, Navigate])
|
}, [isAuthenticated, user, Navigate])
|
||||||
@@ -141,7 +184,19 @@ const LoginView = () => {
|
|||||||
? buildChildUsername(parentUsername, childName)
|
? buildChildUsername(parentUsername, childName)
|
||||||
: username
|
: 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.success) {
|
||||||
if (result.data?.mfaRequired) {
|
if (result.data?.mfaRequired) {
|
||||||
@@ -352,473 +407,262 @@ const LoginView = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const displayName = userProfile?.displayName || userProfile?.username
|
||||||
<Container
|
const showSocialLogin = import.meta.env.VITE_IS_SELF_HOSTED !== 'true'
|
||||||
component='main'
|
const hasSocialOptions =
|
||||||
maxWidth='xs'
|
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
|
{userProfile ? (
|
||||||
sx={{
|
<Box
|
||||||
marginTop: 4,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sheet
|
|
||||||
component='form'
|
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1,
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 2,
|
gap: 1.5,
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: 'md',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Capacitor.isNativePlatform() && (
|
<Avatar
|
||||||
<IconButton
|
src={userProfile?.image}
|
||||||
// on top right of the screen:
|
alt={displayName}
|
||||||
sx={{ position: 'absolute', top: 2, right: 2, color: 'black' }}
|
sx={{ width: 88, height: 88 }}
|
||||||
onClick={() => {
|
/>
|
||||||
Navigate('/login/settings')
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
}}
|
<Typography level='title-md'>{displayName}</Typography>
|
||||||
>
|
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||||
{' '}
|
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||||
<Settings />
|
Sub Account
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</Typography>
|
</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>
|
</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>
|
</Box>
|
||||||
|
|
||||||
|
{!userProfile && !resource?.is_user_creation_disabled && (
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link
|
||||||
|
component='button'
|
||||||
|
type='button'
|
||||||
|
level='body-sm'
|
||||||
|
fontWeight={600}
|
||||||
|
underline='hover'
|
||||||
|
onClick={() => Navigate('/signup')}
|
||||||
|
>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<MFAVerificationModal
|
<MFAVerificationModal
|
||||||
open={mfaModalOpen}
|
open={mfaModalOpen}
|
||||||
onClose={handleMFAClose}
|
onClose={handleMFAClose}
|
||||||
@@ -826,7 +670,7 @@ const LoginView = () => {
|
|||||||
onSuccess={handleMFASuccess}
|
onSuccess={handleMFASuccess}
|
||||||
onError={handleMFAError}
|
onError={handleMFAError}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { Security, Smartphone } from '@mui/icons-material'
|
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Link,
|
|
||||||
ModalClose,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { VerifyMFA } from '../../utils/Fetcher'
|
import { VerifyMFA } from '../../utils/Fetcher'
|
||||||
|
import { authInputSx } from './authStyles'
|
||||||
|
|
||||||
const MFAVerificationModal = ({
|
const MFAVerificationModal = ({
|
||||||
open,
|
open,
|
||||||
@@ -26,6 +18,7 @@ const MFAVerificationModal = ({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
|
||||||
const handleVerify = async () => {
|
const handleVerify = async () => {
|
||||||
if (!verificationCode.trim()) {
|
if (!verificationCode.trim()) {
|
||||||
setError('Please enter a verification code')
|
setError('Please enter a verification code')
|
||||||
@@ -43,12 +36,17 @@ const MFAVerificationModal = ({
|
|||||||
onSuccess(data)
|
onSuccess(data)
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
setError(
|
const message =
|
||||||
errorData.message || 'Invalid verification code. Please try again.',
|
errorData.message || 'Invalid verification code. Please try again.'
|
||||||
)
|
setError(message)
|
||||||
|
onError?.(message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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)
|
console.error('MFA verification error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -63,8 +61,9 @@ const MFAVerificationModal = ({
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyPress = e => {
|
const handleKeyDown = e => {
|
||||||
if (e.key === 'Enter' && !loading) {
|
if (e.key === 'Enter' && !loading) {
|
||||||
|
e.preventDefault()
|
||||||
handleVerify()
|
handleVerify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,86 +72,104 @@ const MFAVerificationModal = ({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
size='lg'
|
size='md'
|
||||||
fullWidth={true}
|
title='Two-factor authentication'
|
||||||
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 />
|
<Stack spacing={2.5}>
|
||||||
|
|
||||||
<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}>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
<Typography
|
||||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
component='label'
|
||||||
|
htmlFor='mfa-code'
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
|
||||||
|
>
|
||||||
|
{isBackupCode ? 'Backup code' : 'Verification code'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
placeholder={
|
id='mfa-code'
|
||||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
size='lg'
|
||||||
}
|
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={e => setVerificationCode(e.target.value)}
|
onChange={e => setVerificationCode(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyDown={handleKeyDown}
|
||||||
|
error={Boolean(error)}
|
||||||
|
autoFocus
|
||||||
sx={{
|
sx={{
|
||||||
textAlign: 'center',
|
...authInputSx,
|
||||||
fontSize: '1.1em',
|
// Targets the inner <input>; styling the root leaves the text
|
||||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
// itself unaligned.
|
||||||
|
'& input': {
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: isBackupCode ? 'normal' : '0.4em',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
maxLength: isBackupCode ? 50 : 6,
|
maxLength: isBackupCode ? 50 : 6,
|
||||||
|
inputMode: isBackupCode ? 'text' : 'numeric',
|
||||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||||
|
autoComplete: isBackupCode ? 'off' : 'one-time-code',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
startDecorator={<Smartphone />}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert color='danger' size='sm'>
|
<Alert color='danger' variant='soft' sx={{ borderRadius: '12px' }}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
color='primary'
|
|
||||||
loading={loading}
|
|
||||||
onClick={handleVerify}
|
|
||||||
disabled={!verificationCode.trim()}
|
|
||||||
size='lg'
|
|
||||||
>
|
|
||||||
Verify & Sign In
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box className='text-center'>
|
|
||||||
<Link
|
<Link
|
||||||
component='button'
|
component='button'
|
||||||
type='button'
|
type='button'
|
||||||
|
level='body-sm'
|
||||||
|
underline='hover'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsBackupCode(!isBackupCode)
|
setIsBackupCode(!isBackupCode)
|
||||||
setVerificationCode('')
|
setVerificationCode('')
|
||||||
setError('')
|
setError('')
|
||||||
}}
|
}}
|
||||||
sx={{ fontSize: 'sm' }}
|
|
||||||
>
|
>
|
||||||
{isBackupCode
|
{isBackupCode
|
||||||
? 'Use authenticator app instead'
|
? 'Use authenticator app instead'
|
||||||
: "Can't access your authenticator? Use a backup code"}
|
: 'Use a backup code instead'}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Alert color='neutral' size='sm'>
|
{isBackupCode && (
|
||||||
<Typography level='body-xs'>
|
<Typography
|
||||||
Having trouble? Make sure your authenticator app is synced and try
|
level='body-xs'
|
||||||
again. Each backup code can only be used once.
|
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
Each backup code can only be used once.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import {
|
import { Box, Link, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Divider,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Logo from '../../Logo'
|
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { login, signUp } from '../../utils/Fetcher'
|
import { login, signUp } from '../../utils/Fetcher'
|
||||||
|
import {
|
||||||
|
AuthPasswordField,
|
||||||
|
AuthSubmitButton,
|
||||||
|
AuthTextField,
|
||||||
|
LegalLinks,
|
||||||
|
} from './AuthFields'
|
||||||
|
import AuthShell from './AuthShell'
|
||||||
|
|
||||||
const SignupView = () => {
|
const SignupView = () => {
|
||||||
const [username, setUsername] = React.useState('')
|
const [username, setUsername] = React.useState('')
|
||||||
@@ -27,6 +23,7 @@ const SignupView = () => {
|
|||||||
const [passwordError, setPasswordError] = React.useState('')
|
const [passwordError, setPasswordError] = React.useState('')
|
||||||
const [emailError, setEmailError] = React.useState('')
|
const [emailError, setEmailError] = React.useState('')
|
||||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||||
const { showError } = useNotification()
|
const { showError } = useNotification()
|
||||||
const handleLogin = (username, password) => {
|
const handleLogin = (username, password) => {
|
||||||
login(username, password).then(response => {
|
login(username, password).then(response => {
|
||||||
@@ -38,7 +35,11 @@ const SignupView = () => {
|
|||||||
// Invalidate user profile queries to ensure fresh data
|
// Invalidate user profile queries to ensure fresh data
|
||||||
queryClient.invalidateQueries(['userProfile'])
|
queryClient.invalidateQueries(['userProfile'])
|
||||||
|
|
||||||
Navigate('/chores')
|
// The "how did you hear about us" step (/heard-about) is
|
||||||
|
// temporarily skipped; new accounts go straight to circle setup.
|
||||||
|
// Re-enable by navigating to '/heard-about' again — that view
|
||||||
|
// already forwards to '/circle-setup' when done.
|
||||||
|
Navigate('/circle-setup', { replace: true })
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log('Login failed', response)
|
console.log('Login failed', response)
|
||||||
@@ -90,10 +91,10 @@ const SignupView = () => {
|
|||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// username should only contain lowercase letters, dot and dash:
|
// username should only contain lowercase letters, numbers, dot and dash:
|
||||||
if (!/^[a-z.-]+$/.test(username)) {
|
if (!/^[a-z0-9.-]+$/.test(username)) {
|
||||||
setUsernameError(
|
setUsernameError(
|
||||||
'Username can only contain lowercase letters, dot and dash',
|
'Username can only contain lowercase letters, numbers, dot and dash',
|
||||||
)
|
)
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
@@ -105,208 +106,129 @@ const SignupView = () => {
|
|||||||
if (!handleSignUpValidation()) {
|
if (!handleSignUpValidation()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
signUp(username, password, displayName, email).then(response => {
|
setIsSubmitting(true)
|
||||||
if (response.status === 201) {
|
signUp(username, password, displayName, email)
|
||||||
handleLogin(username, password)
|
.then(response => {
|
||||||
} else if (response.status === 403) {
|
if (response.status === 201) {
|
||||||
showError({
|
handleLogin(username, password)
|
||||||
title: 'Signup Failed',
|
} else if (response.status === 403) {
|
||||||
message: 'Signup disabled, please contact admin',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.log('Signup failed')
|
|
||||||
response.json().then(res => {
|
|
||||||
showError({
|
showError({
|
||||||
title: 'Signup Failed',
|
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 (
|
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
|
<Box
|
||||||
sx={{
|
component='form'
|
||||||
display: 'flex',
|
onSubmit={handleSubmit}
|
||||||
flexDirection: 'column',
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Sheet
|
<AuthTextField
|
||||||
component='form'
|
label='Display name'
|
||||||
sx={{
|
id='displayName'
|
||||||
mt: 1,
|
name='displayName'
|
||||||
width: '100%',
|
autoComplete='name'
|
||||||
display: 'flex',
|
placeholder='How others see your name'
|
||||||
flexDirection: 'column',
|
autoFocus
|
||||||
// alignItems: 'center',
|
value={displayName}
|
||||||
padding: 2,
|
error={displayNameError}
|
||||||
borderRadius: '8px',
|
onChange={e => {
|
||||||
boxShadow: 'md',
|
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
|
<AuthTextField
|
||||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
label='Username'
|
||||||
>
|
id='username'
|
||||||
<Button
|
name='username'
|
||||||
variant='plain'
|
autoComplete='username'
|
||||||
size='sm'
|
placeholder='lowercase letters, numbers, dot and dash'
|
||||||
onClick={() => {
|
value={username}
|
||||||
window.open('https://donetick.com/privacy-policy', '_blank')
|
error={usernameError}
|
||||||
}}
|
onChange={e => {
|
||||||
>
|
setUsernameError(null)
|
||||||
Privacy Policy
|
setUsername(e.target.value.trim())
|
||||||
</Button>
|
}}
|
||||||
<Button
|
/>
|
||||||
variant='plain'
|
|
||||||
size='sm'
|
<AuthTextField
|
||||||
onClick={() => {
|
label='Email'
|
||||||
window.open('https://donetick.com/terms', '_blank')
|
id='email'
|
||||||
}}
|
name='email'
|
||||||
>
|
type='email'
|
||||||
Terms of Use
|
autoComplete='email'
|
||||||
</Button>
|
placeholder='you@example.com'
|
||||||
</Box>
|
value={email}
|
||||||
</Sheet>
|
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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,68 @@
|
|||||||
// create boilerplate for ResetPasswordView:
|
import { Box, Button } from '@mui/joy'
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import Logo from '../../Logo'
|
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ChangePassword } from '../../utils/Fetcher'
|
import { ChangePassword } from '../../utils/Fetcher'
|
||||||
|
import { AuthPasswordField, AuthSubmitButton, LegalLinks } from './AuthFields'
|
||||||
|
import AuthShell from './AuthShell'
|
||||||
|
import { authButtonSx } from './authStyles'
|
||||||
|
|
||||||
const UpdatePasswordView = () => {
|
const UpdatePasswordView = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||||
const [passwordError, setPasswordError] = useState(null)
|
const [passwordError, setPasswordError] = useState(null)
|
||||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
const [passwordConfirmationError, setPasswordConfirmationError] =
|
||||||
useState(null)
|
useState(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const { showError, showNotification } = useNotification()
|
const { showError, showNotification } = useNotification()
|
||||||
|
|
||||||
const verifiticationCode = searchParams.get('c')
|
const verificationCode = searchParams.get('c')
|
||||||
|
|
||||||
const handlePasswordChange = e => {
|
const handlePasswordChange = e => {
|
||||||
const password = e.target.value
|
setPassword(e.target.value)
|
||||||
setPassword(password)
|
if (passwordError) {
|
||||||
if (password.length < 8 || password.length > 64) {
|
|
||||||
setPasswordError('Password must be between 8 and 64 characters')
|
|
||||||
} else {
|
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasswordConfirmChange = e => {
|
const handlePasswordConfirmChange = e => {
|
||||||
setPasswordConfirm(e.target.value)
|
setPasswordConfirm(e.target.value)
|
||||||
if (e.target.value !== password) {
|
if (passwordConfirmationError) {
|
||||||
setPasswordConfirmationError('Passwords do not match')
|
|
||||||
} else {
|
|
||||||
setPasswordConfirmationError(null)
|
setPasswordConfirmationError(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const validate = () => {
|
||||||
if (passwordError != null || passworConfirmationError != null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const response = await ChangePassword(verifiticationCode, password)
|
const response = await ChangePassword(verificationCode, password)
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
@@ -60,7 +71,6 @@ const UpdatePasswordView = () => {
|
|||||||
message:
|
message:
|
||||||
'Your password has been updated successfully. Redirecting to login...',
|
'Your password has been updated successfully. Redirecting to login...',
|
||||||
})
|
})
|
||||||
// wait 3 seconds and then redirect to login:
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}, 3000)
|
}, 3000)
|
||||||
@@ -75,111 +85,99 @@ const UpdatePasswordView = () => {
|
|||||||
title: 'Password Update Failed',
|
title: 'Password Update Failed',
|
||||||
message: 'Failed to update password, please try again later',
|
message: 'Failed to update password, please try again later',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<Container component='main' maxWidth='xs'>
|
if (!verificationCode) {
|
||||||
<Box
|
return (
|
||||||
sx={{
|
<AuthShell
|
||||||
display: 'flex',
|
title='This link is not valid'
|
||||||
flexDirection: 'column',
|
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
|
||||||
alignItems: 'center',
|
footer={<LegalLinks />}
|
||||||
marginTop: 4,
|
showLogo
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Sheet
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
component='form'
|
<Button
|
||||||
sx={{
|
fullWidth
|
||||||
mt: 1,
|
size='lg'
|
||||||
width: '100%',
|
variant='solid'
|
||||||
display: 'flex',
|
sx={authButtonSx}
|
||||||
flexDirection: 'column',
|
onClick={() => navigate('/forgot-password')}
|
||||||
// alignItems: 'center',
|
>
|
||||||
padding: 2,
|
Request a new link
|
||||||
borderRadius: '8px',
|
</Button>
|
||||||
boxShadow: 'md',
|
<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
|
Cancel
|
||||||
sx={{
|
</Button>
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
src/views/Authorization/authStyles.js
Normal file
13
src/views/Authorization/authStyles.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -415,7 +415,9 @@ const ChoreEdit = () => {
|
|||||||
console.error('Failed to save chore:', error)
|
console.error('Failed to save chore:', error)
|
||||||
showError({
|
showError({
|
||||||
title: 'Save Failed',
|
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])
|
}, [frequencyType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignees.length === 0) {
|
if (anyone || assignableTo.length === 0) {
|
||||||
setAssignStrategy('no_assignee')
|
setAssignStrategy('no_assignee')
|
||||||
setAssignedTo(null)
|
setAssignedTo(null)
|
||||||
} else {
|
} else if (assignStrategy === 'no_assignee') {
|
||||||
if (!assignees.some(a => a.userId === assignedTo)) {
|
// user explicitly picked no_assignee while having assignees, keep it
|
||||||
setAssignedTo(assignees[0].userId)
|
// but there is nobody currently assigned
|
||||||
}
|
if (assignedTo !== null) {
|
||||||
if (assignStrategy === 'no_assignee') {
|
setAssignedTo(null)
|
||||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
|
||||||
}
|
}
|
||||||
|
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||||
|
setAssignedTo(assignableTo[0].userId)
|
||||||
}
|
}
|
||||||
}, [assignStrategy, assignedTo, assignees])
|
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||||
@@ -1257,9 +1260,14 @@ const ChoreEdit = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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='h4'>Currently Assigned To</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Who is assigned the next due?
|
Who is assigned the next due?
|
||||||
@@ -1795,13 +1803,13 @@ const ChoreEdit = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Radio
|
<Radio
|
||||||
overlay
|
overlay
|
||||||
disabled={assignees.length === 0}
|
disabled={anyone || assignableTo.length === 0}
|
||||||
value={true}
|
value={true}
|
||||||
label='Limited'
|
label='Limited'
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
You and others that are assigned to the task
|
You and others that are assigned to the task
|
||||||
{assignees.length === 0
|
{anyone || assignableTo.length === 0
|
||||||
? ' (No assignees selected, Limited option is disabled)'
|
? ' (No assignees selected, Limited option is disabled)'
|
||||||
: ''}
|
: ''}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
PlayArrow,
|
PlayArrow,
|
||||||
} from '@mui/icons-material'
|
} 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'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
const TimerSplitButton = ({
|
const TimerSplitButton = ({
|
||||||
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{/* Main action button */}
|
{/* Main action button */}
|
||||||
<IconButton
|
<Button
|
||||||
onClick={handleMainAction}
|
onClick={handleMainAction}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='md'
|
size='md'
|
||||||
|
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||||
sx={{
|
sx={{
|
||||||
px: 3,
|
|
||||||
py: 1,
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
minWidth: fullWidth ? 'auto' : 120,
|
minWidth: fullWidth ? 'auto' : 120,
|
||||||
flex: fullWidth ? 1 : 'none',
|
flex: fullWidth ? 1 : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
|
||||||
{chore.status === 1 ? 'Pause' : 'Resume'}
|
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||||
</IconButton>
|
</Button>
|
||||||
|
|
||||||
{/* Dropdown arrow button */}
|
{/* Dropdown arrow button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='lg'
|
size='md'
|
||||||
sx={{
|
sx={{ px: 1, minWidth: 'auto' }}
|
||||||
px: 1,
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderLeft: '1px solid',
|
|
||||||
borderLeftColor: 'divider',
|
|
||||||
minWidth: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowDropDown />
|
<ArrowDropDown />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Person,
|
Person,
|
||||||
PriorityHigh,
|
PriorityHigh,
|
||||||
|
SearchOff,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
Unarchive,
|
Unarchive,
|
||||||
ViewAgenda,
|
ViewAgenda,
|
||||||
@@ -27,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import FilterBar from '../../components/common/FilterBar'
|
import FilterBar from '../../components/common/FilterBar'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
@@ -432,6 +434,16 @@ const ArchivedTasks = () => {
|
|||||||
setSelectedChores(newSelection)
|
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 = () => {
|
const selectAllVisibleChores = () => {
|
||||||
if (finalChores.length > 0) {
|
if (finalChores.length > 0) {
|
||||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||||
@@ -706,7 +718,7 @@ const ArchivedTasks = () => {
|
|||||||
}}
|
}}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
|
||||||
}
|
}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
searchTerm && (
|
searchTerm && (
|
||||||
@@ -994,45 +1006,37 @@ const ArchivedTasks = () => {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{finalChores.length === 0 ? (
|
{finalChores.length === 0 ? (
|
||||||
<Box
|
searchTerm || hasActiveFilters ? (
|
||||||
sx={{
|
<EmptyState
|
||||||
display: 'flex',
|
variant='no-results'
|
||||||
justifyContent: 'center',
|
fullHeight
|
||||||
alignItems: 'center',
|
icon={<SearchOff />}
|
||||||
flexDirection: 'column',
|
title='No archived tasks match'
|
||||||
height: '50vh',
|
description={
|
||||||
}}
|
searchTerm
|
||||||
>
|
? `Nothing in the archive matches "${searchTerm}".`
|
||||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
: 'There are archived tasks, but none fit the filters that are currently on.'
|
||||||
<Typography level='title-md' gutterBottom>
|
}
|
||||||
{searchTerm || hasActiveFilters
|
primaryAction={
|
||||||
? 'No archived tasks found'
|
searchTerm
|
||||||
: 'No archived tasks'}
|
? { label: 'Clear search', onClick: handleSearchClose }
|
||||||
</Typography>
|
: { label: 'Clear filters', onClick: clearAll }
|
||||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
}
|
||||||
{searchTerm || hasActiveFilters
|
secondaryAction={
|
||||||
? 'Try adjusting your search or filters'
|
searchTerm && hasActiveFilters
|
||||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
? { label: 'Clear filters', onClick: clearAll }
|
||||||
</Typography>
|
: undefined
|
||||||
{(searchTerm || hasActiveFilters) && (
|
}
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
/>
|
||||||
{searchTerm && (
|
) : (
|
||||||
<Button
|
<EmptyState
|
||||||
onClick={handleSearchClose}
|
fullHeight
|
||||||
variant='outlined'
|
icon={<Archive />}
|
||||||
color='neutral'
|
title='Nothing archived'
|
||||||
>
|
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
||||||
Clear search
|
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||||
</Button>
|
/>
|
||||||
)}
|
)
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||||
@@ -1051,6 +1055,7 @@ const ArchivedTasks = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -108,27 +108,29 @@ const ChoreCard = ({
|
|||||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
<Chip
|
{!['once', 'no_repeat'].includes(chore.frequencyType) && (
|
||||||
variant='soft'
|
<Chip
|
||||||
sx={{
|
variant='soft'
|
||||||
position: 'relative',
|
sx={{
|
||||||
top: 10,
|
position: 'relative',
|
||||||
zIndex: 3,
|
top: 10,
|
||||||
ml: 0.4,
|
zIndex: 3,
|
||||||
left: 10,
|
ml: 0.4,
|
||||||
}}
|
left: 10,
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getFrequencyIcon(chore)}
|
<div
|
||||||
{getRecurrentChipText(chore)}
|
style={{
|
||||||
</div>
|
display: 'flex',
|
||||||
</Chip>
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getFrequencyIcon(chore)}
|
||||||
|
{getRecurrentChipText(chore)}
|
||||||
|
</div>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||||
<PendingBadge commands={pendingCmds} />
|
<PendingBadge commands={pendingCmds} />
|
||||||
@@ -398,8 +400,10 @@ const ChoreCard = ({
|
|||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
width: 50,
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
flexShrink: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -447,8 +451,10 @@ const ChoreCard = ({
|
|||||||
disabled={true}
|
disabled={true}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
width: 50,
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
flexShrink: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}}
|
}}
|
||||||
@@ -479,8 +485,10 @@ const ChoreCard = ({
|
|||||||
disabled={notInCompletionWindow(chore)}
|
disabled={notInCompletionWindow(chore)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
width: 50,
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
flexShrink: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
|
|||||||
@@ -18,9 +18,60 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Typography } from '@mui/joy'
|
import { Box, Typography } from '@mui/joy'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useLongPress } from '../../hooks/useLongPress'
|
||||||
import ChoreCard from './ChoreCard'
|
import ChoreCard from './ChoreCard'
|
||||||
import CompactChoreCard from './CompactChoreCard'
|
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 = ({
|
const ChoreListView = ({
|
||||||
chores,
|
chores,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -34,6 +85,7 @@ const ChoreListView = ({
|
|||||||
userProfile,
|
userProfile,
|
||||||
isOfficialInstance,
|
isOfficialInstance,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
|
onLongPressChore,
|
||||||
showActions = true,
|
showActions = true,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -248,7 +300,7 @@ const ChoreListView = ({
|
|||||||
return (
|
return (
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{chores.map(chore => (
|
{chores.map(chore => (
|
||||||
<SwipeableListItem
|
<ChoreSwipeableItem
|
||||||
key={chore.id}
|
key={chore.id}
|
||||||
trailingActions={getTrailingActions(chore)}
|
trailingActions={getTrailingActions(chore)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -258,9 +310,11 @@ const ChoreListView = ({
|
|||||||
navigate(`/chores/${chore.id}`)
|
navigate(`/chores/${chore.id}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
longPressEnabled={Boolean(onLongPressChore)}
|
||||||
|
onLongPress={() => onLongPressChore?.(chore.id)}
|
||||||
>
|
>
|
||||||
{renderChoreCard(chore)}
|
{renderChoreCard(chore)}
|
||||||
</SwipeableListItem>
|
</ChoreSwipeableItem>
|
||||||
))}
|
))}
|
||||||
</SwipeableList>
|
</SwipeableList>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ const CompactChoreCard = ({
|
|||||||
const parts = []
|
const parts = []
|
||||||
|
|
||||||
// Frequency
|
// Frequency
|
||||||
parts.push(getRecurrentChipText(chore))
|
if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
|
||||||
|
parts.push(getRecurrentChipText(chore))
|
||||||
|
}
|
||||||
|
|
||||||
// Assignee
|
// Assignee
|
||||||
if (chore.assignedTo) {
|
if (chore.assignedTo) {
|
||||||
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
|
|||||||
|
|
||||||
{/* Line 2: Metadata */}
|
{/* Line 2: Metadata */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
{getFrequencyIcon(chore)}
|
{!['once', 'no_repeat'].includes(chore.frequencyType) &&
|
||||||
|
getFrequencyIcon(chore)}
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
color='text.secondary'
|
color='text.secondary'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
import { HelpOutline } from '@mui/icons-material'
|
||||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||||
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
boxShadow: 'lg',
|
boxShadow: 'lg',
|
||||||
}}
|
}}
|
||||||
|
aria-label='Show keyboard shortcuts'
|
||||||
title='Show keyboard shortcuts'
|
title='Show keyboard shortcuts'
|
||||||
>
|
>
|
||||||
<HelpOutline />
|
<HelpOutline />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Help Modal */}
|
{/* Help Modal */}
|
||||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
<ResponsiveModal
|
||||||
<Box
|
open={isHelpOpen}
|
||||||
sx={{
|
onClose={() => setIsHelpOpen(false)}
|
||||||
display: 'flex',
|
title='Multi-select Mode'
|
||||||
alignItems: 'center',
|
description='Use these keyboard shortcuts to work more efficiently.'
|
||||||
justifyContent: 'space-between',
|
footer={
|
||||||
mb: 2,
|
<ModalActions
|
||||||
}}
|
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{/* Selection shortcuts */}
|
{/* Selection shortcuts */}
|
||||||
<Card variant='soft' sx={{ p: 2 }}>
|
<Card variant='soft' sx={{ p: 2 }}>
|
||||||
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</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>
|
</ResponsiveModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import {
|
|||||||
Add,
|
Add,
|
||||||
Bolt,
|
Bolt,
|
||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
|
CloudOff,
|
||||||
EditCalendar,
|
EditCalendar,
|
||||||
|
SearchOff,
|
||||||
ExpandCircleDown,
|
ExpandCircleDown,
|
||||||
PriorityHigh,
|
PriorityHigh,
|
||||||
Style,
|
Style,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import Logo from '../../Logo'
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
AccordionGroup,
|
AccordionGroup,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -33,6 +33,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
|||||||
|
|
||||||
import { useMediaQuery } from '@mui/material'
|
import { useMediaQuery } from '@mui/material'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import { useFilter } from '../../hooks/useFilter'
|
import { useFilter } from '../../hooks/useFilter'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
@@ -47,10 +48,15 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
|||||||
import TaskInput from '../components/AddTaskModal'
|
import TaskInput from '../components/AddTaskModal'
|
||||||
import CalendarDual from '../components/CalendarDual'
|
import CalendarDual from '../components/CalendarDual'
|
||||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||||
|
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
|
||||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||||
import ChoreListView from './ChoreListView.jsx'
|
import ChoreListView from './ChoreListView.jsx'
|
||||||
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
||||||
|
import {
|
||||||
|
conditionsToSelections,
|
||||||
|
selectionsToConditions,
|
||||||
|
} from './components/FilterBuilderContent'
|
||||||
import ChoreModals from './components/ChoreModals'
|
import ChoreModals from './components/ChoreModals'
|
||||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||||
import MyChoreHeader from './components/MyChoreHeader'
|
import MyChoreHeader from './components/MyChoreHeader'
|
||||||
@@ -121,7 +127,7 @@ const MyChores = () => {
|
|||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
|
|
||||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||||
useProjectFilter(projects)
|
useProjectFilter(projects, !projectsLoading)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
@@ -143,6 +149,7 @@ const MyChores = () => {
|
|||||||
selectedChores,
|
selectedChores,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
toggleChoreSelection,
|
toggleChoreSelection,
|
||||||
|
enterMultiSelectWithChore,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
getSelectedChoresData,
|
getSelectedChoresData,
|
||||||
@@ -211,8 +218,7 @@ const MyChores = () => {
|
|||||||
)
|
)
|
||||||
case 'Due Later':
|
case 'Due Later':
|
||||||
return (
|
return (
|
||||||
d !== null &&
|
d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
|
||||||
)
|
)
|
||||||
case 'No Due Date':
|
case 'No Due Date':
|
||||||
return item.nextDueDate === null
|
return item.nextDueDate === null
|
||||||
@@ -366,6 +372,7 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processEffectAsync()
|
processEffectAsync()
|
||||||
|
// throw new Error('Fake Error to test posthog')
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
membersLoading,
|
membersLoading,
|
||||||
@@ -570,6 +577,7 @@ const MyChores = () => {
|
|||||||
handleBulkArchive,
|
handleBulkArchive,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
|
handleBulkMoveToProject,
|
||||||
} = useChoreActions({
|
} = useChoreActions({
|
||||||
chores,
|
chores,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
@@ -629,7 +637,8 @@ const MyChores = () => {
|
|||||||
selectedChores,
|
selectedChores,
|
||||||
addTaskModalOpen,
|
addTaskModalOpen,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
searchFilter:
|
||||||
|
hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||||
filteredChores: getFilteredChores,
|
filteredChores: getFilteredChores,
|
||||||
choreSections,
|
choreSections,
|
||||||
openChoreSections,
|
openChoreSections,
|
||||||
@@ -679,14 +688,46 @@ const MyChores = () => {
|
|||||||
setAnchorEl(null)
|
setAnchorEl(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clicking a label / priority chip on a task card feeds the advanced filter
|
||||||
|
// (as a temp filter) rather than the legacy quick filters. Clicking the same
|
||||||
|
// chip again removes that value, so chips toggle.
|
||||||
const handleLabelFiltering = chipClicked => {
|
const handleLabelFiltering = chipClicked => {
|
||||||
clearActiveFilter()
|
const type = chipClicked.label ? 'label' : 'priority'
|
||||||
if (chipClicked.label) {
|
const value = chipClicked.label
|
||||||
setQuickFilter('label', [chipClicked.label.id])
|
? chipClicked.label.id
|
||||||
} else if (chipClicked.priority) {
|
: chipClicked.priority
|
||||||
setQuickFilter('priority', [chipClicked.priority])
|
if (value === undefined || value === null) return
|
||||||
|
|
||||||
|
const selections = conditionsToSelections(tempFilter?.conditions)
|
||||||
|
const currentValues = selections[type].values || []
|
||||||
|
const isActive = currentValues.some(v => String(v) === String(value))
|
||||||
|
selections[type] = {
|
||||||
|
operator: selections[type].operator || 'is',
|
||||||
|
values: isActive
|
||||||
|
? currentValues.filter(v => String(v) !== String(value))
|
||||||
|
: [...currentValues, value],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditions = selectionsToConditions(selections)
|
||||||
|
|
||||||
|
clearQuickFilters()
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
clearTempFilter()
|
||||||
|
clearActiveFilter()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipName = chipClicked.label
|
||||||
|
? chipClicked.label.name
|
||||||
|
: `P${chipClicked.priority}`
|
||||||
|
applyTempFilter(
|
||||||
|
{ conditions, operator: 'AND' },
|
||||||
|
// Keep whatever the temp filter was already labelled as (e.g. an
|
||||||
|
// in-progress saved-filter edit); only name it when starting fresh.
|
||||||
|
tempFilterMeta ?? { name: chipName },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to update URL with filter parameters
|
// Helper to update URL with filter parameters
|
||||||
@@ -786,10 +827,12 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleViewMode = value => {
|
const toggleViewMode = value => {
|
||||||
const newMode = value ?? (() => {
|
const newMode =
|
||||||
const modes = ['default', 'compact', 'calendar']
|
value ??
|
||||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
(() => {
|
||||||
})()
|
const modes = ['default', 'compact', 'calendar']
|
||||||
|
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||||
|
})()
|
||||||
setViewMode(newMode)
|
setViewMode(newMode)
|
||||||
localStorage.setItem('choreCardViewMode', newMode)
|
localStorage.setItem('choreCardViewMode', newMode)
|
||||||
if (newMode !== 'calendar') {
|
if (newMode !== 'calendar') {
|
||||||
@@ -865,8 +908,27 @@ const MyChores = () => {
|
|||||||
[getFilteredChores],
|
[getFilteredChores],
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateChores = newChore => {
|
|
||||||
let newChores = [...chores, newChore]
|
// "Narrowed" means the user actively cut the list down (search, quick
|
||||||
|
// filters, a saved filter). Picking a project is not narrowing: an empty
|
||||||
|
// project is an empty place, not a filtered-away result.
|
||||||
|
const isNarrowed = Boolean(
|
||||||
|
searchTerm?.length > 0 || hasQuickFilters || activeFilterId,
|
||||||
|
)
|
||||||
|
const isCustomProjectSelected = Boolean(
|
||||||
|
selectedProject && selectedProject.id !== 'default',
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearNarrowing = () => {
|
||||||
|
clearQuickFilters()
|
||||||
|
setSearchTerm('')
|
||||||
|
clearActiveFilter()
|
||||||
|
updateFilterUrl(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendChore = (prev, newChore) => {
|
||||||
|
let newChores = [...prev, newChore]
|
||||||
|
|
||||||
|
|
||||||
if (impersonatedUser) {
|
if (impersonatedUser) {
|
||||||
newChores = newChores.filter(
|
newChores = newChores.filter(
|
||||||
@@ -874,8 +936,15 @@ const MyChores = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setChores(newChores)
|
return newChores
|
||||||
setFilteredChores(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()
|
clearQuickFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,40 +952,23 @@ const MyChores = () => {
|
|||||||
if (choresError || membersError) {
|
if (choresError || membersError) {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth='md'>
|
<Container maxWidth='md'>
|
||||||
<Box
|
<EmptyState
|
||||||
sx={{
|
variant='error'
|
||||||
display: 'flex',
|
fullHeight
|
||||||
justifyContent: 'center',
|
icon={<CloudOff />}
|
||||||
alignItems: 'center',
|
title={"Can't reach Donetick"}
|
||||||
flexDirection: 'column',
|
description={
|
||||||
height: '70vh',
|
choresErrorDetails?.message ||
|
||||||
gap: 2,
|
'Your tasks are safe. We just could not load them right now, check your connection and try again.'
|
||||||
}}
|
}
|
||||||
>
|
primaryAction={{
|
||||||
<Box sx={{ mb: 2, opacity: 0.7 }}>
|
label: 'Try again',
|
||||||
<Logo />
|
onClick: () => {
|
||||||
</Box>
|
|
||||||
<Typography level='h4' color='danger'>
|
|
||||||
Unable to communicate with server
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level='body-md'
|
|
||||||
sx={{ textAlign: 'center', maxWidth: 400 }}
|
|
||||||
>
|
|
||||||
{choresErrorDetails?.message ||
|
|
||||||
'The server is currently unavailable. Please check your connection and try again.'}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
onClick={() => {
|
|
||||||
refetchChores()
|
refetchChores()
|
||||||
queryClient.invalidateQueries(['circleMembers'])
|
queryClient.invalidateQueries(['circleMembers'])
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
Retry Connection
|
/>
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1046,12 +1098,22 @@ const MyChores = () => {
|
|||||||
<MultiSelectToolbar
|
<MultiSelectToolbar
|
||||||
isVisible={isMultiSelectMode}
|
isVisible={isMultiSelectMode}
|
||||||
selectedCount={selectedChores.size}
|
selectedCount={selectedChores.size}
|
||||||
onSelectAll={selectAllVisibleChores}
|
onSelectAll={() =>
|
||||||
|
selectAllVisibleChores(
|
||||||
|
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||||
|
? getFilteredChores
|
||||||
|
: null,
|
||||||
|
choreSections,
|
||||||
|
openChoreSections,
|
||||||
|
)
|
||||||
|
}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
onComplete={handleBulkComplete}
|
onComplete={handleBulkComplete}
|
||||||
onSkip={handleBulkSkip}
|
onSkip={handleBulkSkip}
|
||||||
onArchive={handleBulkArchive}
|
onArchive={handleBulkArchive}
|
||||||
onDelete={handleBulkDelete}
|
onDelete={handleBulkDelete}
|
||||||
|
onMoveToProject={handleBulkMoveToProject}
|
||||||
|
projects={projects}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
selectAllDisabled={
|
selectAllDisabled={
|
||||||
searchTerm?.length > 0 || hasQuickFilters
|
searchTerm?.length > 0 || hasQuickFilters
|
||||||
@@ -1061,50 +1123,79 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
{/* Empty state. Three different situations, three different messages:
|
||||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
nothing created yet, nothing left after narrowing, or an empty
|
||||||
|
project. Only the middle one is about filters. */}
|
||||||
|
{(isNarrowed
|
||||||
? getFilteredChores.length === 0
|
? getFilteredChores.length === 0
|
||||||
: projectFilteredChores.length === 0) &&
|
: projectFilteredChores.length === 0) &&
|
||||||
// only if not in calendar view:
|
// only if not in calendar view:
|
||||||
viewMode !== 'calendar' && (
|
viewMode !== 'calendar' &&
|
||||||
<Box
|
(chores.length === 0 ? (
|
||||||
sx={{
|
<EmptyState
|
||||||
display: 'flex',
|
variant='empty'
|
||||||
justifyContent: 'center',
|
fullHeight
|
||||||
alignItems: 'center',
|
icon={<EditCalendar />}
|
||||||
flexDirection: 'column',
|
title='No tasks yet'
|
||||||
height: '50vh',
|
description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.'
|
||||||
|
primaryAction={{
|
||||||
|
label: 'Create a task',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
>
|
secondaryAction={{
|
||||||
<EditCalendar
|
label: 'More options',
|
||||||
sx={{
|
onClick: () => Navigate('/chores/create'),
|
||||||
fontSize: '4rem',
|
}}
|
||||||
// color: 'text.disabled',
|
/>
|
||||||
mb: 1,
|
) : isNarrowed ? (
|
||||||
}}
|
<EmptyState
|
||||||
/>
|
variant='no-results'
|
||||||
<Typography level='title-md' gutterBottom>
|
fullHeight
|
||||||
Nothing scheduled
|
icon={<SearchOff />}
|
||||||
</Typography>
|
title='No tasks match this view'
|
||||||
{chores.length > 0 && (
|
description={
|
||||||
<>
|
searchTerm?.length > 0
|
||||||
<Button
|
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
||||||
onClick={() => {
|
: 'You have tasks, but none of them fit the filters that are currently on.'
|
||||||
clearQuickFilters()
|
}
|
||||||
setSearchTerm('')
|
primaryAction={{
|
||||||
clearActiveFilter()
|
label:
|
||||||
setSelectedProjectWithCache(null)
|
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
|
||||||
updateFilterUrl(null, null)
|
onClick: clearNarrowing,
|
||||||
}}
|
}}
|
||||||
variant='outlined'
|
/>
|
||||||
color='neutral'
|
) : isCustomProjectSelected ? (
|
||||||
>
|
<EmptyState
|
||||||
Reset filters
|
variant='empty'
|
||||||
</Button>
|
fullHeight
|
||||||
</>
|
icon={<EditCalendar />}
|
||||||
)}
|
title={`Nothing in ${selectedProject.name} yet`}
|
||||||
</Box>
|
description='Tasks you add to this project show up here. Your other tasks are still where you left them.'
|
||||||
)}
|
primaryAction={{
|
||||||
|
label: 'Add a task here',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
|
}}
|
||||||
|
secondaryAction={{
|
||||||
|
label: 'See tasks outside projects',
|
||||||
|
onClick: () => setSelectedProjectWithCache(null),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
variant='empty'
|
||||||
|
fullHeight
|
||||||
|
icon={<EditCalendar />}
|
||||||
|
title='No tasks here yet'
|
||||||
|
description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.'
|
||||||
|
primaryAction={{
|
||||||
|
label: 'Create a task',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||||
<ChoreListView
|
<ChoreListView
|
||||||
chores={getFilteredChores}
|
chores={getFilteredChores}
|
||||||
@@ -1116,6 +1207,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === 'calendar' && (
|
{viewMode === 'calendar' && (
|
||||||
@@ -1272,16 +1364,17 @@ const MyChores = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
||||||
<Typography
|
<EmptyState
|
||||||
level='body-sm'
|
size='sm'
|
||||||
sx={{
|
icon={<EditCalendar />}
|
||||||
textAlign: 'center',
|
title='Nothing scheduled'
|
||||||
py: 2,
|
description='This day is free. Add a task if you want something to land here.'
|
||||||
color: 'text.tertiary',
|
primaryAction={{
|
||||||
|
label: 'Add task',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
No tasks scheduled for this date
|
|
||||||
</Typography>
|
|
||||||
) : (
|
) : (
|
||||||
<ChoreListView
|
<ChoreListView
|
||||||
chores={getChoresForDate(selectedCalendarDate)}
|
chores={getChoresForDate(selectedCalendarDate)}
|
||||||
@@ -1293,6 +1386,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1373,6 +1467,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -1456,6 +1551,7 @@ const MyChores = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationAccessSnackbar />
|
<NotificationAccessSnackbar />
|
||||||
|
<FeedbackPrompt />
|
||||||
{addTaskModalOpen && (
|
{addTaskModalOpen && (
|
||||||
<TaskInput
|
<TaskInput
|
||||||
autoFocus={taskInputFocus}
|
autoFocus={taskInputFocus}
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ const NotificationAccessSnackbar = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run the effect on native platforms
|
// Only run the effect on native platforms
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
getNotificationPreferences().then(data => {
|
getNotificationPreferences().then(async data => {
|
||||||
// if optOut is true then don't show the snackbar
|
// if optOut is true then don't show the snackbar
|
||||||
if (data?.optOut === true || data?.granted === true) {
|
if (data?.optOut === true || data?.granted === true) {
|
||||||
|
// Onboarding (and the system settings screen) can grant permission
|
||||||
|
// while no session exists, so the push token still needs registering.
|
||||||
|
if (data?.granted === true) {
|
||||||
|
await registerPushNotifications().catch(error =>
|
||||||
|
console.error('Error registering push notifications:', error),
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
@@ -69,7 +76,7 @@ const NotificationAccessSnackbar = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting up notifications:', error)
|
console.error('Error setting up notifications:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
await Preferences.set({
|
await Preferences.set({
|
||||||
key: 'notificationPreferences',
|
key: 'notificationPreferences',
|
||||||
value: JSON.stringify(notificationPreferences),
|
value: JSON.stringify(notificationPreferences),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BarChart, Person } from '@mui/icons-material'
|
import { BarChart, Person } from '@mui/icons-material'
|
||||||
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { TASK_COLOR } from '../../utils/Colors'
|
import { TASK_COLOR } from '../../utils/Colors'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
@@ -127,10 +128,13 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
|||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
|
<EmptyState
|
||||||
<Typography level='body-sm' color='neutral'>
|
variant='no-results'
|
||||||
No assigned tasks found
|
size='sm'
|
||||||
</Typography>
|
icon={<Person />}
|
||||||
|
title='No one has tasks yet'
|
||||||
|
description='Assign a task to someone in your circle and their workload shows up here.'
|
||||||
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import DateModal from '../../Modals/Inputs/DateModal'
|
import DateModal from '../../Modals/Inputs/DateModal'
|
||||||
|
import DueDatePickerModal, {
|
||||||
|
combineDueDate,
|
||||||
|
splitDueDate,
|
||||||
|
} from '../../components/DueDatePickerModal'
|
||||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||||
import TextModal from '../../Modals/Inputs/TextModal'
|
import TextModal from '../../Modals/Inputs/TextModal'
|
||||||
@@ -24,13 +28,16 @@ const ChoreModals = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{activeModal === 'changeDueDate' && modalChore && (
|
{activeModal === 'changeDueDate' && modalChore && (
|
||||||
<DateModal
|
<DueDatePickerModal
|
||||||
isOpen={true}
|
open={true}
|
||||||
key={'changeDueDate' + modalChore.id}
|
key={'changeDueDate' + modalChore.id}
|
||||||
current={modalChore.nextDueDate}
|
|
||||||
title='Change due date'
|
title='Change due date'
|
||||||
|
{...splitDueDate(modalChore.nextDueDate)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSave={onChangeDueDate}
|
onApply={parts =>
|
||||||
|
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
|
||||||
|
}
|
||||||
|
onRemove={() => onChangeDueDate(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||||
import { Z_INDEX } from '../../../constants/zIndex'
|
import { Z_INDEX } from '../../../constants/zIndex'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
|||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.icon != null
|
opt.icon != null ? (
|
||||||
? isSelected
|
isSelected ? (
|
||||||
? <Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
: opt.icon
|
) : (
|
||||||
: undefined
|
opt.icon
|
||||||
|
)
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
onClick={() => onToggle(opt.value)}
|
onClick={() => onToggle(opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
|
|||||||
return member?.displayName || member?.username || String(value)
|
return member?.displayName || member?.username || String(value)
|
||||||
}
|
}
|
||||||
if (condition.type === 'status') {
|
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') {
|
if (condition.type === 'priority') {
|
||||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||||
@@ -313,27 +317,6 @@ const ChoreToolbar = ({
|
|||||||
return `${prefix}${typeLabel} (${rawValues.length})`
|
return `${prefix}${typeLabel} (${rawValues.length})`
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearConditionAtIndex = index => {
|
|
||||||
const nextConditions = (tempFilter?.conditions || []).filter(
|
|
||||||
(_condition, conditionIndex) => conditionIndex !== index,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (nextConditions.length === 0) {
|
|
||||||
setLocalSelections(defaultSelections())
|
|
||||||
clearTempFilter?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextFilter = {
|
|
||||||
...tempFilter,
|
|
||||||
operator: tempFilter?.operator || 'AND',
|
|
||||||
conditions: nextConditions,
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalSelections(conditionsToSelections(nextConditions))
|
|
||||||
applyTempFilter?.(nextFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSavedFilter = savedFilterActive
|
const activeSavedFilter = savedFilterActive
|
||||||
? savedFilters.find(f => f.id === activeFilterId)
|
? savedFilters.find(f => f.id === activeFilterId)
|
||||||
: null
|
: null
|
||||||
@@ -342,17 +325,61 @@ const ChoreToolbar = ({
|
|||||||
? activeSavedFilter?.conditions || []
|
? activeSavedFilter?.conditions || []
|
||||||
: tempFilter?.conditions || []
|
: tempFilter?.conditions || []
|
||||||
|
|
||||||
|
const savedFilterEditMeta = activeSavedFilter
|
||||||
|
? {
|
||||||
|
name: activeSavedFilter.name,
|
||||||
|
description: activeSavedFilter.description,
|
||||||
|
sourceFilterId: activeSavedFilter.id,
|
||||||
|
sourceFilterName: activeSavedFilter.name,
|
||||||
|
sourceFilterDescription: activeSavedFilter.description,
|
||||||
|
sourceFilterColor: activeSavedFilter.color,
|
||||||
|
isEditingSavedFilter: true,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const clearConditionAtIndex = index => {
|
||||||
|
const nextConditions = activeChipConditions.filter(
|
||||||
|
(_condition, conditionIndex) => conditionIndex !== index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (nextConditions.length === 0) {
|
||||||
|
setLocalSelections(defaultSelections())
|
||||||
|
clearTempFilter?.()
|
||||||
|
// A saved filter still owns the view until it's toggled back off.
|
||||||
|
if (savedFilterActive) onSavedFilterClick?.(activeFilterId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalSelections(conditionsToSelections(nextConditions))
|
||||||
|
|
||||||
|
// Dropping a condition from a saved filter detaches it into a temp filter
|
||||||
|
// that remembers its source, rather than editing the saved filter itself.
|
||||||
|
if (savedFilterActive) {
|
||||||
|
applyTempFilter?.(
|
||||||
|
{
|
||||||
|
conditions: nextConditions,
|
||||||
|
operator: activeSavedFilter?.operator || 'AND',
|
||||||
|
},
|
||||||
|
savedFilterEditMeta,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTempFilter?.(
|
||||||
|
{
|
||||||
|
...tempFilter,
|
||||||
|
operator: tempFilter?.operator || 'AND',
|
||||||
|
conditions: nextConditions,
|
||||||
|
},
|
||||||
|
tempFilterMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
activeChipConditions.forEach((condition, index) => {
|
activeChipConditions.forEach((condition, index) => {
|
||||||
inlineChips.push({
|
inlineChips.push({
|
||||||
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||||
label: getConditionChipLabel(condition),
|
label: getConditionChipLabel(condition),
|
||||||
onClear: () => {
|
onClear: () => clearConditionAtIndex(index),
|
||||||
if (savedFilterActive) {
|
|
||||||
onSavedFilterClick?.(activeFilterId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearConditionAtIndex(index)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -363,8 +390,7 @@ const ChoreToolbar = ({
|
|||||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||||
if (tempFilterMeta?.sourceFilterId) {
|
if (tempFilterMeta?.sourceFilterId) {
|
||||||
const sourceFilter =
|
const sourceFilter =
|
||||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
|
||||||
null
|
|
||||||
setEditingSavedFilter(
|
setEditingSavedFilter(
|
||||||
sourceFilter ||
|
sourceFilter ||
|
||||||
(tempFilterMeta.sourceFilterId
|
(tempFilterMeta.sourceFilterId
|
||||||
@@ -444,7 +470,13 @@ const ChoreToolbar = ({
|
|||||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||||
FILTER_COLORS[0].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 })
|
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||||
onFilterSaved?.(name)
|
onFilterSaved?.(name)
|
||||||
})
|
})
|
||||||
@@ -460,16 +492,13 @@ const ChoreToolbar = ({
|
|||||||
const conditions = selectionsToConditions(localSelections)
|
const conditions = selectionsToConditions(localSelections)
|
||||||
if (conditions.length === 0) return
|
if (conditions.length === 0) return
|
||||||
|
|
||||||
updateFilter(
|
updateFilter(editingSavedFilter.id, {
|
||||||
editingSavedFilter.id,
|
name: editingSavedFilter.name,
|
||||||
{
|
description: editingSavedFilter.description || '',
|
||||||
name: editingSavedFilter.name,
|
color: editingSavedFilter.color,
|
||||||
description: editingSavedFilter.description || '',
|
conditions,
|
||||||
color: editingSavedFilter.color,
|
operator: 'AND',
|
||||||
conditions,
|
})?.then?.(() => {
|
||||||
operator: 'AND',
|
|
||||||
},
|
|
||||||
)?.then?.(() => {
|
|
||||||
clearTempFilter?.()
|
clearTempFilter?.()
|
||||||
onSavedFilterClick?.(editingSavedFilter.id)
|
onSavedFilterClick?.(editingSavedFilter.id)
|
||||||
onFilterSaved?.(editingSavedFilter.name)
|
onFilterSaved?.(editingSavedFilter.name)
|
||||||
@@ -498,9 +527,21 @@ const ChoreToolbar = ({
|
|||||||
]
|
]
|
||||||
|
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
{
|
||||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
value: 'default',
|
||||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
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 (
|
return (
|
||||||
@@ -536,6 +577,7 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={openFilterSheet}
|
onClick={openFilterSheet}
|
||||||
|
aria-label='Filters'
|
||||||
title='Filters'
|
title='Filters'
|
||||||
>
|
>
|
||||||
<FilterList />
|
<FilterList />
|
||||||
@@ -543,13 +585,14 @@ const ChoreToolbar = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Project selector */}
|
{/* Project selector */}
|
||||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
{!filterActive &&
|
||||||
<ProjectSelector
|
projects.filter(p => p.id !== 'default').length > 0 && (
|
||||||
selectedProject={selectedProject?.name || 'Default Project'}
|
<ProjectSelector
|
||||||
onProjectSelect={onProjectSelect}
|
selectedProject={selectedProject?.name || 'Default Project'}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
onProjectSelect={onProjectSelect}
|
||||||
/>
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Display button — View + Group combined */}
|
{/* Display button — View + Group combined */}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -558,6 +601,7 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={() => setDisplaySheetOpen(true)}
|
onClick={() => setDisplaySheetOpen(true)}
|
||||||
|
aria-label='View and group options'
|
||||||
title='View & Group'
|
title='View & Group'
|
||||||
>
|
>
|
||||||
{viewMode === 'calendar' ? (
|
{viewMode === 'calendar' ? (
|
||||||
@@ -577,6 +621,9 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={onToggleMultiSelect}
|
onClick={onToggleMultiSelect}
|
||||||
|
aria-label={
|
||||||
|
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
isMultiSelectMode
|
isMultiSelectMode
|
||||||
? 'Exit multi-select (Ctrl+S)'
|
? 'Exit multi-select (Ctrl+S)'
|
||||||
@@ -614,6 +661,8 @@ const ChoreToolbar = ({
|
|||||||
<ActiveFilterChips
|
<ActiveFilterChips
|
||||||
chips={inlineChips}
|
chips={inlineChips}
|
||||||
onOpen={openFilterSheet}
|
onOpen={openFilterSheet}
|
||||||
|
showAddChip
|
||||||
|
onAdd={openFilterSheet}
|
||||||
onClearAll={() => {
|
onClearAll={() => {
|
||||||
setLocalSelections(defaultSelections())
|
setLocalSelections(defaultSelections())
|
||||||
onClearAllFilters?.()
|
onClearAllFilters?.()
|
||||||
@@ -628,8 +677,9 @@ const ChoreToolbar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={filterSheetOpen}
|
open={filterSheetOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSaveMenuAnchorEl(null)
|
setSaveMenuAnchorEl(null)
|
||||||
setFilterSheetOpen(false)
|
setFilterSheetOpen(false)
|
||||||
@@ -649,7 +699,12 @@ const ChoreToolbar = ({
|
|||||||
footer={
|
footer={
|
||||||
savingFilter ? (
|
savingFilter ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -710,12 +765,11 @@ const ChoreToolbar = ({
|
|||||||
}}
|
}}
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
{resultCount != null
|
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||||
? `Show ${resultCount}`
|
|
||||||
: 'Done'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={saveMenuRef}
|
ref={saveMenuRef}
|
||||||
|
aria-label='More save options'
|
||||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||||
>
|
>
|
||||||
<ArrowDropDown />
|
<ArrowDropDown />
|
||||||
@@ -825,11 +879,12 @@ const ChoreToolbar = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
|
|
||||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={displaySheetOpen}
|
open={displaySheetOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => setDisplaySheetOpen(false)}
|
onClose={() => setDisplaySheetOpen(false)}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -838,7 +893,10 @@ const ChoreToolbar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
<Button
|
||||||
|
onClick={() => setDisplaySheetOpen(false)}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -853,9 +911,11 @@ const ChoreToolbar = ({
|
|||||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
viewMode === opt.value
|
viewMode === opt.value ? (
|
||||||
? <Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
: opt.icon
|
) : (
|
||||||
|
opt.icon
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => onToggleViewMode?.(opt.value)}
|
onClick={() => onToggleViewMode?.(opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -910,7 +970,8 @@ const ChoreToolbar = ({
|
|||||||
label='Show tasks for'
|
label='Show tasks for'
|
||||||
badge={
|
badge={
|
||||||
selectedAssigneeFilter !== 'anyone'
|
selectedAssigneeFilter !== 'anyone'
|
||||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||||
|
?.label
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -920,9 +981,8 @@ const ChoreToolbar = ({
|
|||||||
multi={false}
|
multi={false}
|
||||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,39 @@ import {
|
|||||||
Close,
|
Close,
|
||||||
Delete,
|
Delete,
|
||||||
Done,
|
Done,
|
||||||
|
DriveFileMove,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
SkipNext,
|
SkipNext,
|
||||||
} from '@mui/icons-material'
|
} 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 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 = ({
|
const MultiSelectToolbar = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
|
|||||||
onSkip,
|
onSkip,
|
||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMoveToProject,
|
||||||
|
projects = [],
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
selectAllDisabled,
|
selectAllDisabled,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||||
|
const projectMenuRef = useRef(null)
|
||||||
|
|
||||||
|
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||||
|
|
||||||
|
const handleMoveToProject = project => {
|
||||||
|
closeProjectMenu()
|
||||||
|
onMoveToProject?.(project)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
|
|||||||
@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig,
|
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 {
|
return {
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
handleChangeDueDate,
|
handleChangeDueDate,
|
||||||
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
|
|||||||
handleBulkArchive,
|
handleBulkArchive,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
|
handleBulkMoveToProject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
|
|||||||
[selectedChores],
|
[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(
|
const selectAllVisibleChores = useCallback(
|
||||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||||
let choresToSelect = []
|
let choresToSelect = []
|
||||||
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
|
|||||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||||
|
|
||||||
if (allExpandedSelected) {
|
if (allExpandedSelected) {
|
||||||
choresToSelect = choreSections.flatMap(section => section.content || [])
|
choresToSelect = choreSections.flatMap(
|
||||||
|
section => section.content || [],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
choresToSelect = expandedChores
|
choresToSelect = expandedChores
|
||||||
}
|
}
|
||||||
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
|
|||||||
selectedChores,
|
selectedChores,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
toggleChoreSelection,
|
toggleChoreSelection,
|
||||||
|
enterMultiSelectWithChore,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
getSelectedChoresData,
|
getSelectedChoresData,
|
||||||
|
|||||||
@@ -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 [selectedProject, setSelectedProject] = useState(() => {
|
||||||
const saved = localStorage.getItem('selectedProject')
|
const saved = localStorage.getItem('selectedProject')
|
||||||
return saved ? JSON.parse(saved) : null
|
return saved ? JSON.parse(saved) : null
|
||||||
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
|
|||||||
window.history.replaceState({}, '', newUrl)
|
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 {
|
return {
|
||||||
selectedProject,
|
selectedProject,
|
||||||
projectsWithDefault,
|
projectsWithDefault,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
StarBorder,
|
StarBorder,
|
||||||
Task,
|
Task,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import { useChores } from '../../queries/ChoreQueries'
|
import { useChores } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||||
@@ -417,29 +418,17 @@ const FilterView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{savedFilters.length === 0 ? (
|
{savedFilters.length === 0 ? (
|
||||||
<Box
|
<EmptyState
|
||||||
sx={{
|
fullHeight
|
||||||
p: 4,
|
icon={<FilterAlt />}
|
||||||
textAlign: 'center',
|
title='No saved filters yet'
|
||||||
|
description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.'
|
||||||
|
primaryAction={{
|
||||||
|
label: 'Create a filter',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: handleAddFilter,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<FilterAlt
|
|
||||||
sx={{
|
|
||||||
fontSize: 48,
|
|
||||||
color: 'neutral.300',
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
level='title-lg'
|
|
||||||
sx={{ mb: 1, color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
No saved filters yet
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 2 }}>
|
|
||||||
Create custom filters to quickly access your most used chore
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{savedFilters.map(filter => {
|
{savedFilters.map(filter => {
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import FilterBar from '../../components/common/FilterBar'
|
import FilterBar from '../../components/common/FilterBar'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||||
@@ -303,36 +304,14 @@ const ChoreHistory = () => {
|
|||||||
}
|
}
|
||||||
if (!choreHistory.length) {
|
if (!choreHistory.length) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container maxWidth='md'>
|
||||||
maxWidth='md'
|
<EmptyState
|
||||||
sx={{
|
fullHeight
|
||||||
textAlign: 'center',
|
icon={<EventBusy />}
|
||||||
display: 'flex',
|
title='No history yet'
|
||||||
// make sure the content is centered vertically:
|
description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.'
|
||||||
alignItems: 'center',
|
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '50vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EventBusy
|
|
||||||
sx={{
|
|
||||||
fontSize: '6rem',
|
|
||||||
// color: 'text.disabled',
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography level='h3' gutterBottom>
|
|
||||||
No History Yet
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body1'>
|
|
||||||
You haven't completed any tasks. Once you start finishing tasks,
|
|
||||||
they'll show up here.
|
|
||||||
</Typography>
|
|
||||||
<Button variant='soft' sx={{ mt: 2 }}>
|
|
||||||
<Link to='/chores'>Go back to chores</Link>
|
|
||||||
</Button>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -441,27 +420,13 @@ const ChoreHistory = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{sortedHistory.length === 0 && activeFilterCount > 0 && (
|
{sortedHistory.length === 0 && activeFilterCount > 0 && (
|
||||||
<Box
|
<EmptyState
|
||||||
sx={{
|
variant='no-results'
|
||||||
textAlign: 'center',
|
icon={<FilterList />}
|
||||||
py: 6,
|
title='No history matches these filters'
|
||||||
display: 'flex',
|
description='There is history here, but none of it fits the filters that are currently on.'
|
||||||
flexDirection: 'column',
|
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
|
||||||
alignItems: 'center',
|
/>
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
|
||||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
|
||||||
No results match your filters
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
|
||||||
Try adjusting or clearing the active filters.
|
|
||||||
</Typography>
|
|
||||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedHistory.length > 0 && (
|
{sortedHistory.length > 0 && (
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
TrailingActions,
|
TrailingActions,
|
||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
import { Add, MoreVert } from '@mui/icons-material'
|
import { Add, MoreVert, Style } from '@mui/icons-material'
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||||
@@ -258,19 +259,17 @@ const LabelView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{userLabels.length === 0 && (
|
{userLabels.length === 0 && (
|
||||||
<Box
|
<EmptyState
|
||||||
sx={{
|
fullHeight
|
||||||
display: 'flex',
|
icon={<Style />}
|
||||||
justifyContent: 'center',
|
title='No labels yet'
|
||||||
alignItems: 'center',
|
description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.'
|
||||||
flexDirection: 'column',
|
primaryAction={{
|
||||||
height: '50vh',
|
label: 'Create a label',
|
||||||
|
startDecorator: <Add />,
|
||||||
|
onClick: handleAddLabel,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Typography level='title-md' gutterBottom>
|
|
||||||
No labels available. Add a new label to get started.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{userLabels.map(label => (
|
{userLabels.map(label => (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Box, Button, FormLabel, Input } from '@mui/joy'
|
import { FormLabel, Input } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import ConfirmationModal from './Inputs/ConfirmationModal'
|
import ConfirmationModal from './Inputs/ConfirmationModal'
|
||||||
|
|
||||||
@@ -41,31 +42,19 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
// fullWidth={true}
|
// fullWidth={true}
|
||||||
title='Edit History'
|
title='Edit History'
|
||||||
footer={
|
footer={
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
<ModalActions
|
||||||
<Button
|
secondary={{ label: 'Cancel', onClick: config.onClose }}
|
||||||
size='lg'
|
primary={{
|
||||||
onClick={() =>
|
label: 'Save',
|
||||||
|
onClick: () =>
|
||||||
config.onSave({
|
config.onSave({
|
||||||
id: historyRecord.id,
|
id: historyRecord.id,
|
||||||
performedAt: moment(completedDate).toISOString(),
|
performedAt: moment(completedDate).toISOString(),
|
||||||
dueDate: moment(dueDate).toISOString(),
|
dueDate: moment(dueDate).toISOString(),
|
||||||
notes,
|
notes,
|
||||||
})
|
}),
|
||||||
}
|
}}
|
||||||
fullWidth
|
/>
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size='lg'
|
|
||||||
onClick={config.onClose}
|
|
||||||
variant='outlined'
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormLabel>Due Date</FormLabel>
|
<FormLabel>Due Date</FormLabel>
|
||||||
@@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
message: 'Are you sure you want to delete this history?',
|
message: 'Are you sure you want to delete this history?',
|
||||||
confirmText: 'Delete',
|
confirmText: 'Delete',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
|
color: 'danger',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
|
|||||||
324
src/views/Modals/FeedbackModal.jsx
Normal file
324
src/views/Modals/FeedbackModal.jsx
Normal 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
|
||||||
@@ -15,28 +15,40 @@ import {
|
|||||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
const STATUS_CONFIG = {
|
||||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
const DetailRow = ({ icon, label, value, children }) => (
|
const DetailRow = ({ icon, label, value, children }) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
<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 }}>
|
<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 ?? (
|
{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>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
|
|||||||
const gracePeriod = 6 * 60 * 60 * 1000
|
const gracePeriod = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
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)) {
|
} else if (performedAt.isBefore(dueDate)) {
|
||||||
const abs = Math.abs(diffHours)
|
const abs = Math.abs(diffHours)
|
||||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
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 {
|
} else {
|
||||||
const abs = Math.abs(diffHours)
|
const abs = Math.abs(diffHours)
|
||||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
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 statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
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
|
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||||
const showUpdatedAt =
|
const showUpdatedAt =
|
||||||
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
|
|||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={config?.onClose}
|
onClose={config?.onClose}
|
||||||
title='Activity Detail'
|
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 */}
|
{/* 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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||||
{statusCfg.icon}
|
{statusCfg.icon}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
<Typography
|
||||||
|
level='title-md'
|
||||||
|
fontWeight='lg'
|
||||||
|
sx={{ color: `${statusCfg.color}.plainColor` }}
|
||||||
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
|
|||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
{/* Who performed it */}
|
{/* Who performed it */}
|
||||||
{performer && (
|
{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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
<Avatar
|
||||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
src={performer.image}
|
||||||
|
alt={performer.displayName}
|
||||||
|
size='sm'
|
||||||
|
sx={{ width: 20, height: 20 }}
|
||||||
|
/>
|
||||||
|
<Typography level='body-sm' fontWeight='md'>
|
||||||
|
{performer.displayName}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assigned to (only if different) */}
|
{/* Assigned to (only if different) */}
|
||||||
{isDifferentAssignee && assignedTo && (
|
{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 />
|
<Divider />
|
||||||
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
|
|||||||
{entry.performedAt && (
|
{entry.performedAt && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
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)}
|
value={fmt.dateTime(entry.performedAt)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
|
|||||||
{entry.dueDate && (
|
{entry.dueDate && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
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)}
|
value={fmt.dateTime(entry.dueDate)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ pt: 1 }}>
|
<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'}
|
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function AcknowledgmentModal({ config }) {
|
function AcknowledgmentModal({ config }) {
|
||||||
@@ -11,42 +12,24 @@ function AcknowledgmentModal({ config }) {
|
|||||||
config.onClose()
|
config.onClose()
|
||||||
}, [config])
|
}, [config])
|
||||||
|
|
||||||
// Keyboard shortcuts for acknowledgment modal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
if (!config?.isOpen) return
|
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 (
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
|
||||||
|
event.key === 'Escape' ||
|
||||||
|
event.key === 'Enter'
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction()
|
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 => {
|
const handleKeyUp = event => {
|
||||||
if (!event.ctrlKey && !event.metaKey) {
|
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||||
setShowKeyboardShortcuts(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config?.isOpen) {
|
if (config?.isOpen) {
|
||||||
@@ -63,43 +46,33 @@ function AcknowledgmentModal({ config }) {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={config?.onClose}
|
onClose={handleAction}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
unmountDelay={250}
|
|
||||||
title={config?.title}
|
title={config?.title}
|
||||||
>
|
showCloseButton={false}
|
||||||
<Box
|
footer={
|
||||||
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
|
<ModalActions
|
||||||
>
|
primary={{
|
||||||
|
label: config?.acknowledgeText,
|
||||||
<Typography
|
color: config?.color || 'primary',
|
||||||
level='body-md'
|
onClick: handleAction,
|
||||||
mb={3}
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
sx={{
|
<KeyboardShortcutHint shortcut='Y' />
|
||||||
lineHeight: 1.6,
|
) : undefined,
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{config?.message}
|
}
|
||||||
</Typography>
|
>
|
||||||
|
<Typography
|
||||||
<Box display={'flex'} justifyContent={'center'} mt={2}>
|
level='body-md'
|
||||||
<Button
|
sx={{
|
||||||
size='lg'
|
lineHeight: 1.6,
|
||||||
onClick={handleAction}
|
whiteSpace: 'pre-wrap',
|
||||||
color={config?.color || 'primary'}
|
wordBreak: 'break-word',
|
||||||
fullWidth
|
}}
|
||||||
endDecorator={
|
>
|
||||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
{config?.message}
|
||||||
}
|
</Typography>
|
||||||
sx={{ minWidth: '120px' }}
|
|
||||||
>
|
|
||||||
{config?.acknowledgeText}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Save } from '@mui/icons-material'
|
import { Save } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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, {
|
import FilterBuilderContent, {
|
||||||
conditionsToSelections,
|
conditionsToSelections,
|
||||||
defaultSelections,
|
defaultSelections,
|
||||||
@@ -18,6 +12,8 @@ import { FILTER_COLORS } from '../../../utils/Colors'
|
|||||||
import { applyFilter } from '../../../utils/FilterEngine'
|
import { applyFilter } from '../../../utils/FilterEngine'
|
||||||
import { useFilters } from '../../Filters/FilterQueries'
|
import { useFilters } from '../../Filters/FilterQueries'
|
||||||
|
|
||||||
|
const EMPTY_FILTERS = []
|
||||||
|
|
||||||
const AdvancedFilterBuilder = ({
|
const AdvancedFilterBuilder = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -33,7 +29,7 @@ const AdvancedFilterBuilder = ({
|
|||||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||||
const [selections, setSelections] = useState(defaultSelections())
|
const [selections, setSelections] = useState(defaultSelections())
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const { data: existedFilters = [] } = useFilters()
|
const { data: existedFilters = EMPTY_FILTERS } = useFilters()
|
||||||
|
|
||||||
const filterNameExists = (name, excludeId = null) =>
|
const filterNameExists = (name, excludeId = null) =>
|
||||||
existedFilters.some(
|
existedFilters.some(
|
||||||
@@ -55,9 +51,12 @@ const AdvancedFilterBuilder = ({
|
|||||||
setSelections(defaultSelections())
|
setSelections(defaultSelections())
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
}, [editingFilter, isOpen])
|
}, [editingFilter, existedFilters, isOpen])
|
||||||
|
|
||||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
const conditions = useMemo(
|
||||||
|
() => selectionsToConditions(selections),
|
||||||
|
[selections],
|
||||||
|
)
|
||||||
|
|
||||||
const previewChores = useMemo(() => {
|
const previewChores = useMemo(() => {
|
||||||
if (conditions.length === 0) return []
|
if (conditions.length === 0) return []
|
||||||
@@ -100,8 +99,9 @@ const AdvancedFilterBuilder = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
isMobile
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxHeight='92vh'
|
maxHeight='92vh'
|
||||||
title={
|
title={
|
||||||
@@ -109,7 +109,8 @@ const AdvancedFilterBuilder = ({
|
|||||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||||
{activeConditionCount > 0 && (
|
{activeConditionCount > 0 && (
|
||||||
<Chip size='sm' variant='solid' color='primary'>
|
<Chip size='sm' variant='solid' color='primary'>
|
||||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
{activeConditionCount} condition
|
||||||
|
{activeConditionCount !== 1 ? 's' : ''}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<ModalActions>
|
||||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='solid'
|
variant='solid'
|
||||||
color='primary'
|
color='primary'
|
||||||
size='sm'
|
|
||||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save Filter
|
Save Filter
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</ModalActions>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
import { AttachFile, Image } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import EmptyState from '../../../components/common/EmptyState'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||||
@@ -87,16 +88,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title='Attachments'
|
title='Attachments'
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||||
<Button
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<Close />}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -104,12 +96,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
|||||||
<CircularProgress size='md' />
|
<CircularProgress size='md' />
|
||||||
</Box>
|
</Box>
|
||||||
) : attachments.length === 0 ? (
|
) : attachments.length === 0 ? (
|
||||||
<Typography
|
<EmptyState
|
||||||
level='body-sm'
|
size='sm'
|
||||||
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
|
icon={<AttachFile />}
|
||||||
>
|
title='No attachments'
|
||||||
No attachments found.
|
description='Photos and files added to this task will show up here.'
|
||||||
</Typography>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
||||||
{attachments.map((attachment, index) => (
|
{attachments.map((attachment, index) => (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Browser } from '@capacitor/browser'
|
import { Browser } from '@capacitor/browser'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { Close, Download } from '@mui/icons-material'
|
import { Download } from '@mui/icons-material'
|
||||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const openUrl = async url => {
|
const openUrl = async url => {
|
||||||
@@ -47,25 +48,15 @@ function AttachmentViewerModal({ config }) {
|
|||||||
title={fileName || 'Attachment'}
|
title={fileName || 'Attachment'}
|
||||||
maxHeight='92vh'
|
maxHeight='92vh'
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
<ModalActions
|
||||||
<Button
|
secondary={{ label: 'Close', onClick: handleClose }}
|
||||||
variant='plain'
|
primary={{
|
||||||
color='neutral'
|
label: 'Download',
|
||||||
startDecorator={<Close />}
|
startDecorator: <Download />,
|
||||||
onClick={handleClose}
|
onClick: () => downloadUrl(url, fileName),
|
||||||
>
|
disabled: !url,
|
||||||
Close
|
}}
|
||||||
</Button>
|
/>
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<Download />}
|
|
||||||
onClick={() => downloadUrl(url, fileName)}
|
|
||||||
disabled={!url}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -78,10 +69,7 @@ function AttachmentViewerModal({ config }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imgLoaded && !imgError && (
|
{!imgLoaded && !imgError && (
|
||||||
<CircularProgress
|
<CircularProgress sx={{ position: 'absolute' }} size='md' />
|
||||||
sx={{ position: 'absolute' }}
|
|
||||||
size='md'
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{imgError ? (
|
{imgError ? (
|
||||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
||||||
|
|
||||||
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
const response = await RestoreBackup(restoreEncryptionKey, backupData)
|
const response = await RestoreBackup(restoreEncryptionKey, backupData)
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Backup restored successfully. Please refresh the page.',
|
message: 'Backup restored successfully. Please refresh the page.',
|
||||||
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
placeholder='Enter a strong encryption key'
|
placeholder='Enter a strong encryption key'
|
||||||
/>
|
/>
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Keep this key safe - you'll need it to restore your backup
|
Keep this key safe—you'll need it to restore your backup
|
||||||
</Typography>
|
</Typography>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
unmountDelay={250}
|
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 ? (
|
{loading ? (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function ConfirmationModal({ config }) {
|
function ConfirmationModal({ config }) {
|
||||||
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
|
|||||||
[config],
|
[config],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keyboard shortcuts for confirmation modal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
if (!config?.isOpen) return
|
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') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(true)
|
handleAction(true)
|
||||||
return
|
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + X for cancel
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(false)
|
handleAction(false)
|
||||||
return
|
} else if (event.key === 'Escape') {
|
||||||
}
|
|
||||||
|
|
||||||
// Escape key for cancel
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(false)
|
handleAction(false)
|
||||||
return
|
} else if (event.key === 'Enter' && config?.color !== 'danger') {
|
||||||
}
|
|
||||||
|
|
||||||
// Enter key for confirm
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(true)
|
handleAction(true)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyUp = event => {
|
const handleKeyUp = event => {
|
||||||
if (!event.ctrlKey && !event.metaKey) {
|
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||||
setShowKeyboardShortcuts(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config?.isOpen) {
|
if (config?.isOpen) {
|
||||||
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
|
|||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
document.removeEventListener('keyup', handleKeyUp)
|
document.removeEventListener('keyup', handleKeyUp)
|
||||||
}
|
}
|
||||||
}, [config?.isOpen, handleAction])
|
}, [config?.isOpen, config?.color, handleAction])
|
||||||
|
|
||||||
|
const isDestructive = config?.color === 'danger'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={() => handleAction(false)}
|
onClose={() => handleAction(false)}
|
||||||
size='sm'
|
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}>
|
<Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{config?.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-md' gutterBottom>
|
|
||||||
{config?.message}
|
{config?.message}
|
||||||
</Typography>
|
</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>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConfirmationModal
|
export default ConfirmationModal
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import {
|
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||||
@@ -104,16 +98,30 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
password === confirmPassword
|
password === confirmPassword
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal open={isOpen} onClose={handleClose}>
|
<ResponsiveModal
|
||||||
<Typography level='h4' mb={2}>
|
open={isOpen}
|
||||||
Create Sub Account
|
onClose={handleClose}
|
||||||
</Typography>
|
title='Create Sub Account'
|
||||||
|
description='Create a login that can complete tasks assigned to this account.'
|
||||||
<Typography level='body-md' mb={3}>
|
size='md'
|
||||||
Create a new sub account. The user will be able to log in using their
|
closeOnBackdrop={!isSubmitting}
|
||||||
combined username and complete tasks assigned to them.
|
closeOnEscape={!isSubmitting}
|
||||||
</Typography>
|
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 }}>
|
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
|
||||||
<Typography level='body2' mb={1}>
|
<Typography level='body2' mb={1}>
|
||||||
Sub Account Name *
|
Sub Account Name *
|
||||||
@@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
<FormHelperText>{errors.confirmPassword}</FormHelperText>
|
<FormHelperText>{errors.confirmPassword}</FormHelperText>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</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>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
FormControl,
|
||||||
Button,
|
FormHelperText,
|
||||||
FormControl,
|
Input,
|
||||||
FormHelperText,
|
Option,
|
||||||
Input,
|
Select,
|
||||||
Option,
|
Textarea,
|
||||||
Select,
|
Typography,
|
||||||
Textarea,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||||
@@ -29,7 +28,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
setState(0)
|
setState(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type, state])
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
const newErrors = {}
|
const newErrors = {}
|
||||||
@@ -63,9 +62,20 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='md'
|
||||||
fullWidth={true}
|
|
||||||
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
|
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: onClose,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: currentThing?.id ? 'Update' : 'Create',
|
||||||
|
onClick: handleSave,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Name</Typography>
|
<Typography>Name</Typography>
|
||||||
@@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Type</Typography>
|
<Typography>Type</Typography>
|
||||||
<Select value={type} sx={{ minWidth: 300 }}>
|
<Select value={type} onChange={(_, value) => setType(value)}>
|
||||||
{['text', 'number', 'boolean'].map(type => (
|
{['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)}
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
@@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
{type === 'boolean' && (
|
{type === 'boolean' && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Value</Typography>
|
<Typography>Value</Typography>
|
||||||
<Select sx={{ minWidth: 300 }} value={state}>
|
<Select value={state} onChange={(_, value) => setState(value)}>
|
||||||
{['true', 'false'].map(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)}
|
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user