8 Commits

Author SHA1 Message Date
Mo Tarbin
778ae72777 Release 1.2.36
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-02 23:30:42 -04:00
Mohamad Tarbin
dc88fe9704 unifying the empty state across components (#184) 2026-08-02 23:16:32 -04:00
Mohamad Tarbin
5da8724a7b Update release script to add --upload-only option and adjust build conditions; enhance ChoreCard styles for better layout (#188) 2026-08-02 23:16:23 -04:00
Mohamad Tarbin
f57c24e312 New Crowdin updates (#150)
* New translations chores.json (French)

[ci skip]

* New translations chores.json (Spanish)

[ci skip]

* New translations chores.json (Arabic)

[ci skip]

* New translations common.json (French)

[ci skip]

* New translations common.json (Spanish)

[ci skip]

* New translations common.json (Arabic)

[ci skip]

* New translations settings.json (French)

[ci skip]

* New translations settings.json (Spanish)

[ci skip]

* New translations settings.json (Arabic)

[ci skip]

* New translations settings.json (French)

[ci skip]

* New translations chores.json (German)

[ci skip]

* New translations settings.json (German)

[ci skip]

* New translations chores.json (Portuguese)

[ci skip]

* New translations common.json (Portuguese)

[ci skip]

* New translations settings.json (Portuguese)

[ci skip]

* New translations chores.json (Chinese Simplified)

[ci skip]

* New translations common.json (Chinese Simplified)

[ci skip]

* New translations settings.json (Chinese Simplified)

[ci skip]

* New translations chores.json (Portuguese, Brazilian)

[ci skip]

* New translations common.json (Portuguese, Brazilian)

[ci skip]

* New translations settings.json (Portuguese, Brazilian)

[ci skip]

* New translations common.json (German)

[ci skip]
2026-08-02 22:37:51 -04:00
Mo Tarbin
a077afb291 Release 1.2.35 2026-08-02 21:13:15 -04:00
Mohamad Tarbin
66d742d091 Remove gray panel in add task modal and update label interaction (#187)
* Remove the gray panel in add task modal

* migration clicking label to the new advance filtered.

* Fix: Clicking x on filter chip remove the filter condition
2026-08-02 20:53:41 -04:00
Mohamad Tarbin
d641710a41 Reshape onboarding v1.3 (#185)
* Initialize Onboarding Flow

* Shorten Onboarding

* Add Setup circle  and what are the current way to add tasks to the onboarding.
update donetick logo

* improve wording

* Skip onboarding flow for now

* Enable onboarding flow
2026-08-02 20:53:28 -04:00
Mohamad Tarbin
46fb2d5475 Add support for in app feedback (#176) 2026-08-02 16:36:51 -04:00
68 changed files with 6302 additions and 1696 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 55
versionName "1.2.34"
versionCode 57
versionName "1.2.36"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

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

View File

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

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 57;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
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_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -485,12 +485,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 57;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
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_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -504,12 +504,12 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 57;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
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_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -527,12 +527,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 57;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
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_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'

View File

@@ -12,11 +12,13 @@ PODS:
- PromisesObjC (~> 2.4)
- PromisesSwift (~> 2.4)
- RecaptchaInterop (~> 101.0)
- Capacitor (8.4.1):
- Capacitor (8.4.2):
- CapacitorCordova
- CapacitorApp (8.1.0):
- CapacitorApp (8.1.1):
- Capacitor
- CapacitorBrowser (8.0.3):
- CapacitorBrowser (8.0.4):
- Capacitor
- CapacitorCommunityInAppReview (8.0.0):
- Capacitor
- CapacitorCommunitySpeechRecognition (7.0.1):
- Capacitor
@@ -24,14 +26,14 @@ PODS:
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (8.4.1)
- CapacitorDevice (8.0.2):
- CapacitorCordova (8.4.2)
- CapacitorDevice (8.0.3):
- Capacitor
- CapacitorHaptics (8.0.2):
- Capacitor
- CapacitorLocalLlm (1.0.0):
- Capacitor
- CapacitorLocalNotifications (8.2.0):
- CapacitorLocalNotifications (8.2.1):
- Capacitor
- CapacitorNetwork (8.0.1):
- Capacitor
@@ -39,15 +41,15 @@ PODS:
- Capacitor
- CapacitorPreferences (8.0.1):
- Capacitor
- CapacitorPushNotifications (8.1.1):
- CapacitorPushNotifications (8.1.2):
- Capacitor
- CapacitorStatusBar (8.0.2):
- CapacitorStatusBar (8.0.3):
- Capacitor
- CapgoCapacitorDocumentScanner (8.4.0):
- CapgoCapacitorDocumentScanner (8.4.2):
- Capacitor
- CapgoCapacitorNfc (8.2.2):
- Capacitor
- CapgoCapacitorSocialLogin (8.3.38):
- CapgoCapacitorSocialLogin (8.3.39):
- Alamofire (~> 5.10.2)
- Capacitor
- GoogleSignIn (~> 9.0.0)
@@ -143,6 +145,7 @@ DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/app"
CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser"
CapacitorCommunityInAppReview:
:path: "../../node_modules/@capacitor-community/in-app-review"
CapacitorCommunitySpeechRecognition:
:path: "../../node_modules/@capacitor-community/speech-recognition"
CapacitorCommunitySqlite:
@@ -236,24 +241,25 @@ SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3
CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06
CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4
CapacitorDevice: 708e742b60a61572cd593e44c809592e8f31da14
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
CapacitorLocalNotifications: 908a69a3cf7ae345426de3223d4980befb0103c6
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237
CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af
CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414
CapgoCapacitorSocialLogin: 5ea6b14b8e9b1bd74533e580d4cf0e2b85d80793
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
COCOAPODS: 1.16.2

10
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.2.33",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",
@@ -1495,6 +1496,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@capacitor-community/in-app-review": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/in-app-review/-/in-app-review-8.0.0.tgz",
"integrity": "sha512-AYpEkIjdv27WGS3hnlc7xAQ1BhaBvD2svgQBfHF4P2Olw1N6a7MSCOQC6O4SuxmdBdJ2fM6uFeo1jyNjKLtIPw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor-community/speech-recognition": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.34",
"version": "1.2.36",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -41,6 +41,7 @@
"postinstall": "patch-package"
},
"dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",

View 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": "حذف الجلسة"
}
}
}

View 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": "الإعدادات"
}
}

View 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 وبيانات التصحيح."
}
}
}
}

View File

@@ -33,7 +33,7 @@
"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.",
"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",
"description": "Beschreibung:",
"previousNote": "Vorherige Notiz",

View File

@@ -22,7 +22,7 @@
"navigation": {
"allTasks": "Alle Aufgaben",
"archived": "Archiviert",
"things": "Things",
"things": "Dinge",
"labels": "Beschriftungen",
"projects": "Projekte",
"filters": "Filter",

View File

@@ -171,4 +171,4 @@
}
}
}
}
}

View File

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

View File

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

View File

@@ -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"
}
}
}

View 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"
}
}

View 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."
}
}
}
}

View 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"
}
}
}

View 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"
}
}

View 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."
}
}
}
}

View File

@@ -25,7 +25,7 @@
"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",
"webhookDescription": "Entrez une URL de Webhook pour recevoir des notifications pour les événements du cercle",
"webhookPlaceholder": "https://votre-url-webhook.com"
},
"accountSettings": {

View 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"
}
}
}

View 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"
}
}

View 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."
}
}
}
}

View File

@@ -8,11 +8,12 @@ PLATFORM="both"
BUMP="patch"
SKIP_BUMP=false
UPLOAD=false
UPLOAD_ONLY=false
ANDROID_TRACK="internal"
usage() {
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).
@@ -21,6 +22,7 @@ Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
--bump TYPE Version bump type before building (default: patch)
--skip-bump Don't bump the version, build with the current one
--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)
-h, --help Show this help
@@ -38,6 +40,7 @@ while [[ $# -gt 0 ]]; do
--bump) BUMP="$2"; shift 2 ;;
--skip-bump) SKIP_BUMP=true; shift ;;
--upload) UPLOAD=true; shift ;;
--upload-only) UPLOAD=true; UPLOAD_ONLY=true; shift ;;
--track) ANDROID_TRACK="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
@@ -51,17 +54,19 @@ if [ -f "$REPO_ROOT/.env.production" ]; then
set +a
fi
if [ "$SKIP_BUMP" = false ]; then
echo "→ Bumping version ($BUMP)"
node bump-version.js "$BUMP"
if [ "$UPLOAD_ONLY" = false ]; then
if [ "$SKIP_BUMP" = false ]; then
echo "→ Bumping version ($BUMP)"
node bump-version.js "$BUMP"
fi
echo "→ Building web assets"
npm run build
echo "→ Syncing Capacitor"
npx cap sync
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
RUN="bundle exec fastlane"
else
@@ -69,8 +74,10 @@ else
fi
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
echo "→ Building signed Android release (.aab)"
$RUN android release
if [ "$UPLOAD_ONLY" = false ]; then
echo "→ Building signed Android release (.aab)"
$RUN android release
fi
if [ "$UPLOAD" = true ]; then
echo "→ Uploading Android release to Play Store ($ANDROID_TRACK track)"
@@ -79,8 +86,10 @@ if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
fi
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "both" ]; then
echo "→ Building signed iOS release (.ipa)"
$RUN ios release
if [ "$UPLOAD_ONLY" = false ]; then
echo "→ Building signed iOS release (.ipa)"
$RUN ios release
fi
if [ "$UPLOAD" = true ]; then
echo "→ Uploading iOS release to TestFlight"

View File

@@ -8,6 +8,7 @@ import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { AuthProvider } from './hooks/useAuth.jsx'
import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
import { useResource } from './queries/ResourceQueries'
import './styles/safe-area.css'
@@ -33,6 +34,9 @@ const AppContent = () => {
const { showNotification } = useNotification()
useSyncOnReconnect()
// // First-launch native users see the onboarding flow before anything else.
useOnboardingGate()
// Initialize status bar with theme-aware configuration
useStatusBar()

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -85,6 +85,9 @@ const PageTransition = ({ children }) => {
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing') ||
location.pathname.includes('/onboarding') ||
location.pathname.includes('/get-started') ||
location.pathname.includes('/ready') ||
location.pathname.includes('/auth/')
// Apply transition type as data attribute for CSS

View 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

View File

@@ -1,10 +1,12 @@
import { Close } from '@mui/icons-material'
import { Box, Button, Chip, Typography } from '@mui/joy'
import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
onAdd,
showAddChip = false,
resultCount,
totalCount,
maxVisible = 2,
@@ -44,13 +46,21 @@ const ActiveFilterChips = ({
variant='soft'
color={color}
endDecorator={
<Close
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
onClick={e => {
e.stopPropagation()
onClear?.()
// ChipDelete rather than a bare icon: Joy's chip end decorator is
// `pointer-events: none`, so anything else here is swallowed by the
// chip's own click surface and can never clear the condition.
<ChipDelete
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}
sx={{
@@ -74,7 +84,10 @@ const ActiveFilterChips = ({
</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
size={chipSize}
variant='soft'
@@ -90,6 +103,30 @@ const ActiveFilterChips = ({
>
+{overflow} more
</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 && (

View File

@@ -26,6 +26,11 @@ import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
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 PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
@@ -182,6 +187,26 @@ const Router = createBrowserRouter([
path: '/signup',
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',

View File

@@ -59,7 +59,13 @@ export const AuthProvider = ({ children }) => {
if (!response.ok) {
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()

View 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

View File

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

View File

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

114
src/utils/Onboarding.js Normal file
View 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
}
}

View File

@@ -35,7 +35,11 @@ const SignupView = () => {
// Invalidate user profile queries to ensure fresh data
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 {
console.log('Login failed', response)
@@ -130,7 +134,7 @@ const SignupView = () => {
title='Create your account'
subtitle='Track chores and tasks together, in one shared place.'
footer={<LegalLinks />}
logoSize={0}
logoSize={0}
>
<Box
component='form'

View File

@@ -7,6 +7,7 @@ import {
Label,
Person,
PriorityHigh,
SearchOff,
SelectAll,
Unarchive,
ViewAgenda,
@@ -27,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
@@ -1004,45 +1006,37 @@ const ArchivedTasks = () => {
{/* Content */}
{finalChores.length === 0 ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
<Typography level='title-md' gutterBottom>
{searchTerm || hasActiveFilters
? 'No archived tasks found'
: 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: 'Archived tasks will appear here when you archive them from the main task list'}
</Typography>
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
)}
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
searchTerm || hasActiveFilters ? (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title='No archived tasks match'
description={
searchTerm
? `Nothing in the archive matches "${searchTerm}".`
: 'There are archived tasks, but none fit the filters that are currently on.'
}
primaryAction={
searchTerm
? { label: 'Clear search', onClick: handleSearchClose }
: { label: 'Clear filters', onClick: clearAll }
}
secondaryAction={
searchTerm && hasActiveFilters
? { label: 'Clear filters', onClick: clearAll }
: undefined
}
/>
) : (
<EmptyState
fullHeight
icon={<Archive />}
title='Nothing archived'
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
/>
)
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>

View File

@@ -400,8 +400,10 @@ const ChoreCard = ({
}}
sx={{
borderRadius: '50%',
width: 50,
minWidth: 50,
height: 50,
flexShrink: 0,
zIndex: 1,
transition: 'all 0.2s ease',
'&:hover': {
@@ -449,8 +451,10 @@ const ChoreCard = ({
disabled={true}
sx={{
borderRadius: '50%',
width: 50,
minWidth: 50,
height: 50,
flexShrink: 0,
zIndex: 1,
opacity: 0.5,
}}
@@ -481,8 +485,10 @@ const ChoreCard = ({
disabled={notInCompletionWindow(chore)}
sx={{
borderRadius: '50%',
width: 50,
minWidth: 50,
height: 50,
flexShrink: 0,
zIndex: 1,
transition: 'all 0.2s ease',
'&:hover': {

View File

@@ -2,18 +2,18 @@ import {
Add,
Bolt,
CalendarMonth,
CloudOff,
EditCalendar,
SearchOff,
ExpandCircleDown,
PriorityHigh,
Style,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
AccordionGroup,
Box,
Button,
Chip,
Container,
Divider,
@@ -33,6 +33,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import EmptyState from '../../components/common/EmptyState'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
@@ -47,10 +48,15 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import {
conditionsToSelections,
selectionsToConditions,
} from './components/FilterBuilderContent'
import ChoreModals from './components/ChoreModals'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
@@ -212,8 +218,7 @@ const MyChores = () => {
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
@@ -632,7 +637,8 @@ const MyChores = () => {
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
searchFilter:
hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
choreSections,
openChoreSections,
@@ -682,14 +688,46 @@ const MyChores = () => {
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 => {
clearActiveFilter()
if (chipClicked.label) {
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
setQuickFilter('priority', [chipClicked.priority])
const type = chipClicked.label ? 'label' : 'priority'
const value = chipClicked.label
? chipClicked.label.id
: 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)
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
@@ -789,10 +827,12 @@ const MyChores = () => {
}
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
const newMode =
value ??
(() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
@@ -868,9 +908,28 @@ const MyChores = () => {
[getFilteredChores],
)
// "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) {
newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
@@ -893,40 +952,23 @@ const MyChores = () => {
if (choresError || membersError) {
return (
<Container maxWidth='md'>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '70vh',
gap: 2,
}}
>
<Box sx={{ mb: 2, opacity: 0.7 }}>
<Logo />
</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={() => {
<EmptyState
variant='error'
fullHeight
icon={<CloudOff />}
title={"Can't reach Donetick"}
description={
choresErrorDetails?.message ||
'Your tasks are safe. We just could not load them right now, check your connection and try again.'
}
primaryAction={{
label: 'Try again',
onClick: () => {
refetchChores()
queryClient.invalidateQueries(['circleMembers'])
}}
>
Retry Connection
</Button>
</Box>
},
}}
/>
</Container>
)
}
@@ -1081,50 +1123,79 @@ const MyChores = () => {
}
/>
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
{/* Empty state. Three different situations, three different messages:
nothing created yet, nothing left after narrowing, or an empty
project. Only the middle one is about filters. */}
{(isNarrowed
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
viewMode !== 'calendar' && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
viewMode !== 'calendar' &&
(chores.length === 0 ? (
<EmptyState
variant='empty'
fullHeight
icon={<EditCalendar />}
title='No tasks yet'
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),
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
secondaryAction={{
label: 'More options',
onClick: () => Navigate('/chores/create'),
}}
/>
) : isNarrowed ? (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title='No tasks match this view'
description={
searchTerm?.length > 0
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
: 'You have tasks, but none of them fit the filters that are currently on.'
}
primaryAction={{
label:
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
onClick: clearNarrowing,
}}
/>
) : isCustomProjectSelected ? (
<EmptyState
variant='empty'
fullHeight
icon={<EditCalendar />}
title={`Nothing in ${selectedProject.name} yet`}
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' && (
<ChoreListView
chores={getFilteredChores}
@@ -1293,16 +1364,17 @@ const MyChores = () => {
}}
>
{getChoresForDate(selectedCalendarDate).length === 0 ? (
<Typography
level='body-sm'
sx={{
textAlign: 'center',
py: 2,
color: 'text.tertiary',
<EmptyState
size='sm'
icon={<EditCalendar />}
title='Nothing scheduled'
description='This day is free. Add a task if you want something to land here.'
primaryAction={{
label: 'Add task',
startDecorator: <Add />,
onClick: () => setAddTaskModalOpen(true),
}}
>
No tasks scheduled for this date
</Typography>
/>
) : (
<ChoreListView
chores={getChoresForDate(selectedCalendarDate)}
@@ -1479,6 +1551,7 @@ const MyChores = () => {
/>
</Box>
<NotificationAccessSnackbar />
<FeedbackPrompt />
{addTaskModalOpen && (
<TaskInput
autoFocus={taskInputFocus}

View File

@@ -17,9 +17,16 @@ const NotificationAccessSnackbar = () => {
useEffect(() => {
// Only run the effect on native platforms
if (Capacitor.isNativePlatform()) {
getNotificationPreferences().then(data => {
getNotificationPreferences().then(async data => {
// if optOut is true then don't show the snackbar
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
}
setOpen(true)
@@ -69,7 +76,7 @@ const NotificationAccessSnackbar = () => {
} catch (error) {
console.error('Error setting up notifications:', error)
}
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),

View File

@@ -1,6 +1,7 @@
import { BarChart, Person } from '@mui/icons-material'
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import EmptyState from '../../components/common/EmptyState'
import { useCircleMembers } from '../../queries/UserQueries'
import { TASK_COLOR } from '../../utils/Colors'
import { resolvePhotoURL } from '../../utils/Helpers'
@@ -127,10 +128,13 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
mb: 1,
}}
>
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
<Typography level='body-sm' color='neutral'>
No assigned tasks found
</Typography>
<EmptyState
variant='no-results'
size='sm'
icon={<Person />}
title='No one has tasks yet'
description='Assign a task to someone in your circle and their workload shows up here.'
/>
</Sheet>
)
}

View File

@@ -317,27 +317,6 @@ const ChoreToolbar = ({
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
? savedFilters.find(f => f.id === activeFilterId)
: null
@@ -346,17 +325,61 @@ const ChoreToolbar = ({
? activeSavedFilter?.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) => {
inlineChips.push({
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: getConditionChipLabel(condition),
onClear: () => {
if (savedFilterActive) {
onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
},
onClear: () => clearConditionAtIndex(index),
})
})
@@ -638,6 +661,8 @@ const ChoreToolbar = ({
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}
showAddChip
onAdd={openFilterSheet}
onClearAll={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()

View File

@@ -29,6 +29,7 @@ import {
StarBorder,
Task,
} from '@mui/icons-material'
import EmptyState from '../../components/common/EmptyState'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
@@ -417,29 +418,17 @@ const FilterView = () => {
}}
>
{savedFilters.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: 'center',
<EmptyState
fullHeight
icon={<FilterAlt />}
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}>
{savedFilters.map(filter => {

View File

@@ -28,10 +28,11 @@ import {
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
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 { 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 { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
@@ -303,36 +304,14 @@ const ChoreHistory = () => {
}
if (!choreHistory.length) {
return (
<Container
maxWidth='md'
sx={{
textAlign: 'center',
display: 'flex',
// make sure the content is centered vertically:
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EventBusy
sx={{
fontSize: '6rem',
// color: 'text.disabled',
mb: 1,
}}
<Container maxWidth='md'>
<EmptyState
fullHeight
icon={<EventBusy />}
title='No history yet'
description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.'
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
/>
<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>
)
}
@@ -441,27 +420,13 @@ const ChoreHistory = () => {
/>
</Box>
{sortedHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
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>
<EmptyState
variant='no-results'
icon={<FilterList />}
title='No history matches these filters'
description='There is history here, but none of it fits the filters that are currently on.'
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
/>
)}
{sortedHistory.length > 0 && (

View File

@@ -21,7 +21,8 @@ import {
TrailingActions,
} from '@meauxt/react-swipeable-list'
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 { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
@@ -258,19 +259,17 @@ const LabelView = () => {
}}
>
{userLabels.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
<EmptyState
fullHeight
icon={<Style />}
title='No labels yet'
description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.'
primaryAction={{
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}>
{userLabels.map(label => (

View File

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

View File

@@ -8,6 +8,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import EmptyState from '../../../components/common/EmptyState'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher'
@@ -95,12 +96,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
<CircularProgress size='md' />
</Box>
) : attachments.length === 0 ? (
<Typography
level='body-sm'
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
>
No attachments found.
</Typography>
<EmptyState
size='sm'
icon={<AttachFile />}
title='No attachments'
description='Photos and files added to this task will show up here.'
/>
) : (
<List sx={{ '--ListItem-paddingX': '0px' }}>
{attachments.map((attachment, index) => (

View File

@@ -0,0 +1,325 @@
import {
ContentCopyRounded,
GroupAddRounded,
LinkRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useNotification } from '../../service/NotificationProvider'
import { GetUserCircle, JoinCircle } from '../../utils/Fetcher'
import { haptic } from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
import { CircleVignette } from './OnboardingVignettes'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `circleSetupIn 520ms ${EASE} ${delay}ms both`,
'@keyframes circleSetupIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
/**
* A halo'd icon standing in for the vignette on the join path — that side has
* no living preview to show (there's nothing to illustrate about someone
* else's circle yet), so it borrows the same glow treatment WorkspaceReadyView
* puts behind its logo instead of inventing a third visual language.
*/
const IconHalo = ({ icon }) => (
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
width: 84,
height: 84,
'&::before': {
content: '""',
position: 'absolute',
width: 140,
height: 140,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(28px)',
},
'& > *': { position: 'relative' },
}}
>
<Box
sx={{
width: 72,
height: 72,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
color: 'primary.plainColor',
'& svg': { fontSize: '2rem' },
}}
>
{icon}
</Box>
</Box>
)
/**
* Right after the account (and its solo Circle) is created: the moment to
* either bring the household in or hop into one that already exists. Skipping
* via Continue is always valid — chores work fine solo, this is an offer, not
* a gate.
*/
const CircleSetupView = () => {
const navigate = useNavigate()
const { showNotification } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [mode, setMode] = useState('invite')
const [inviteCode, setInviteCode] = useState(null)
const [joinCode, setJoinCode] = useState('')
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
GetUserCircle()
.then(resp => resp.json())
.then(data => setInviteCode(data.res?.[0]?.invite_code ?? null))
.catch(() => setInviteCode(null))
}, [])
const finish = () => navigate('/ready', { replace: true })
const copyCode = () => {
navigator.clipboard.writeText(inviteCode)
showNotification({ type: 'success', message: 'Code copied to clipboard' })
}
const copyLink = () => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/circle/join?code=${inviteCode}`,
)
showNotification({ type: 'success', message: 'Link copied to clipboard' })
}
const joinCircle = async () => {
if (!joinCode.trim()) return
setIsJoining(true)
haptic()
try {
const resp = await JoinCircle(joinCode.trim())
if (resp.ok) {
showAcknowledgment(
"Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
'Request Sent',
finish,
)
} else {
showNotification({
type: 'error',
message:
resp.status === 409
? 'You are already a member of this circle'
: 'Failed to join circle',
})
}
} finally {
setIsJoining(false)
}
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ mb: 2, ...enter(0) }}>
{mode === 'invite' ? (
<CircleVignette />
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconHalo icon={<GroupAddRounded />} />
</Box>
)}
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
...enter(60),
}}
>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
{mode === 'invite' ? 'Bring your squad in' : 'Join a circle'}
</Typography>
<Typography
level='body-md'
sx={{
color: 'text.secondary',
maxWidth: '32ch',
textWrap: 'pretty',
}}
>
{mode === 'invite'
? 'Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.'
: "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it."}
</Typography>
</Box>
{mode === 'invite' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box
sx={{
...enter(120),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 2,
py: 1.5,
borderRadius: '16px',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography
sx={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '1.375rem',
fontWeight: 700,
letterSpacing: '0.08em',
color: inviteCode ? 'text.primary' : 'text.tertiary',
}}
>
{inviteCode ?? 'Loading…'}
</Typography>
<IconButton
variant='soft'
color='primary'
disabled={!inviteCode}
onClick={copyCode}
aria-label='Copy circle code'
>
<ContentCopyRounded />
</IconButton>
</Box>
<Box sx={{ textAlign: 'center', ...enter(150) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<LinkRounded fontSize='small' />}
disabled={!inviteCode}
onClick={copyLink}
>
Copy invite link instead
</Link>
</Box>
<Box sx={{ mt: 1, ...enter(190) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
<Box sx={{ textAlign: 'center', ...enter(230) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<GroupAddRounded fontSize='small' />}
onClick={() => setMode('join')}
>
Join an existing circle instead
</Link>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ ...enter(120) }}>
<Input
placeholder='Enter code'
value={joinCode}
onChange={e => setJoinCode(e.target.value)}
size='lg'
fullWidth
autoFocus
/>
</Box>
<Box sx={{ ...enter(160) }}>
<Button
size='lg'
fullWidth
loading={isJoining}
disabled={!joinCode.trim()}
onClick={joinCircle}
sx={authButtonSx}
>
Join Circle
</Button>
</Box>
<Box sx={{ textAlign: 'center', ...enter(200) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={() => setMode('invite')}
>
Back
</Link>
</Box>
</Box>
)}
</Box>
<AcknowledgmentModal config={ackModalConfig} />
</Box>
)
}
export default CircleSetupView

View File

@@ -0,0 +1,175 @@
import { DnsOutlined } from '@mui/icons-material'
import { Box, Button, Link, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { haptic, isNativeApp, markOnboardingSeen } from '../../utils/Onboarding'
import { LegalLinks } from '../Authorization/AuthFields'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `getStartedIn 520ms ${EASE} ${delay}ms both`,
'@keyframes getStartedIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
/**
* The fork at the end of onboarding: create an account or sign in. Kept
* deliberately thin — one decision, no form — so the actual auth screens stay
* the only place credentials are handled.
*/
const GetStartedView = () => {
const navigate = useNavigate()
const { data: resource } = useResource()
const signupDisabled = Boolean(resource?.is_user_creation_disabled)
const go = path => {
// Reaching this screen means onboarding is done, even if the user came
// here from a deep link rather than the carousel.
markOnboardingSeen()
haptic()
navigate(path)
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
...enter(0),
}}
>
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
// Same primary wash the onboarding vignettes sit on.
'&::before': {
content: '""',
position: 'absolute',
width: 190,
height: 190,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(30px)',
},
'& > *': { position: 'relative' },
}}
>
<Logo size='96px' />
</Box>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Done
<Box component='span' sx={{ color: 'primary.500' }}>
tick
</Box>
</Typography>
<Typography
level='body-md'
sx={{
color: 'text.secondary',
maxWidth: '30ch',
textWrap: 'pretty',
}}
>
{signupDisabled
? 'Sign in to your account to pick up where you left off.'
: 'Create an account to sync everywhere, or sign in and pick up where you left off.'}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
...enter(90),
}}
>
{!signupDisabled && (
<Button
size='lg'
fullWidth
onClick={() => go('/signup')}
sx={authButtonSx}
>
Create an account
</Button>
)}
<Button
size='lg'
fullWidth
variant={signupDisabled ? 'solid' : 'outlined'}
color={signupDisabled ? 'primary' : 'neutral'}
onClick={() => go('/login')}
sx={authButtonSx}
>
{signupDisabled ? 'Sign in' : 'I already have an account'}
</Button>
</Box>
{isNativeApp() && (
<Box sx={{ mt: 2.5, textAlign: 'center', ...enter(160) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
startDecorator={<DnsOutlined fontSize='small' />}
onClick={() => navigate('/login/settings')}
>
Connect to a self-hosted server
</Link>
</Box>
)}
</Box>
<Box sx={{ width: '100%', maxWidth: 420, ...enter(220) }}>
<LegalLinks />
</Box>
</Box>
)
}
export default GetStartedView

View File

@@ -0,0 +1,343 @@
import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
import {
haptic,
recordAcquisitionSource,
recordPrivacyPreferences,
} from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `heardAboutIn 520ms ${EASE} ${delay}ms both`,
'@keyframes heardAboutIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
const SOURCES = [
'App Store / Google Play',
'Reddit or a forum',
'Friend or family',
'YouTube or TikTok',
'Search engine',
'Something else',
]
const OTHER = 'Something else'
const Shell = ({ children }) => (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{children}
</Box>
</Box>
)
/**
* A one-question attribution survey dropped right after account creation,
* while the "why did I click install" is still fresh. Answering is optional
* blocking a brand-new user on a marketing question would cost more than the
* data is worth so Continue is always enabled. Shown only on the official
* donetick.com instance: a self-hosted server has no marketing funnel to
* attribute, so it gets the privacy prompt below instead.
*/
const AcquisitionSurvey = ({ onDone }) => {
const [selected, setSelected] = useState(null)
const [detail, setDetail] = useState('')
const select = source => {
haptic()
setSelected(current => (current === source ? null : source))
}
const finish = () => {
const answer = selected === OTHER ? detail.trim() || null : selected
if (answer) recordAcquisitionSource(answer)
onDone()
}
return (
<>
<Box sx={{ textAlign: 'center', mb: 3, ...enter(0) }}>
<Typography
level='h1'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Where&apos;d you hear about Donetick?
</Typography>
<Typography
level='body-md'
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
>
Helps us know what&apos;s working. Totally optional.
</Typography>
</Box>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...enter(60) }}
>
{SOURCES.map(source => {
const active = selected === source
return (
<Box
key={source}
component='button'
type='button'
onClick={() => select(source)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
px: 2,
py: 1.25,
borderRadius: '14px',
border: '1px solid',
borderColor: active ? 'primary.500' : 'divider',
bgcolor: active ? 'primary.softBg' : 'background.surface',
color: 'text.primary',
font: 'inherit',
fontWeight: 600,
fontSize: '0.9rem',
textAlign: 'left',
cursor: 'pointer',
transition: `border-color 200ms ${EASE}, background-color 200ms ${EASE}`,
}}
>
{source}
<Box
sx={{
flex: '0 0 auto',
width: 22,
height: 22,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
border: '1.5px solid',
borderColor: active
? 'primary.500'
: 'neutral.outlinedBorder',
bgcolor: active ? 'primary.500' : 'transparent',
color: 'common.white',
'& svg': { fontSize: '0.9rem' },
}}
>
{active && <CheckRounded />}
</Box>
</Box>
)
})}
{selected === OTHER && (
<Box sx={{ mt: 0.5, ...enter(0) }}>
<Input
placeholder='Tell us where'
value={detail}
onChange={e => setDetail(e.target.value)}
size='lg'
fullWidth
autoFocus
/>
</Box>
)}
</Box>
<Box sx={{ mt: 3, ...enter(120) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
<Box sx={{ textAlign: 'center', mt: 1.5, ...enter(150) }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={finish}
>
Skip
</Link>
</Box>
</>
)
}
const PRIVACY_TOGGLES = [
{
key: 'crashReports',
label: 'Share crash reports',
description:
"When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
},
{
key: 'analytics',
label: 'Share anonymous usage analytics',
description:
'Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you.',
},
]
/**
* The self-hosted counterpart to the attribution survey: since a self-hosted
* server is its own data boundary, what to share back to us is a consent
* question, not a marketing one. Both toggles default off this screen is
* an opt-in, not an opt-out.
*/
const PrivacyPreferences = ({ onDone }) => {
const [crashReports, setCrashReports] = useState(false)
const [analytics, setAnalytics] = useState(false)
const toggle = key => {
haptic()
if (key === 'crashReports') setCrashReports(value => !value)
if (key === 'analytics') setAnalytics(value => !value)
}
const values = { crashReports, analytics }
const finish = () => {
recordPrivacyPreferences(values)
onDone()
}
return (
<>
<Box sx={{ textAlign: 'center', mb: 3, ...enter(0) }}>
<Typography
level='h1'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
Help us improve Donetick
</Typography>
<Typography
level='body-md'
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
>
Your server, your data. Both of these are off from this point unless
you turn them on.
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
...enter(60),
}}
>
{PRIVACY_TOGGLES.map(({ key, label, description }) => (
<Box
key={key}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1.5,
px: 2,
py: 1.5,
borderRadius: '14px',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.surface',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0.25, color: 'text.secondary' }}
>
{description}
</Typography>
</Box>
<Switch
checked={values[key]}
onClick={() => toggle(key)}
sx={{ flex: '0 0 auto', mt: 0.25 }}
/>
</Box>
))}
</Box>
<Box sx={{ mt: 3, ...enter(120) }}>
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
Continue
</Button>
</Box>
</>
)
}
const HeardAboutView = () => {
const navigate = useNavigate()
const [isOfficial, setIsOfficial] = useState(null)
useEffect(() => {
let cancelled = false
isOfficialDonetickInstance().then(result => {
if (!cancelled) setIsOfficial(result)
})
return () => {
cancelled = true
}
}, [])
const onDone = () => navigate('/circle-setup', { replace: true })
// Nothing to render mid-check: the read is a local preferences lookup, so
// this resolves before the entrance animation would even be noticed.
if (isOfficial === null) return <Shell />
return (
<Shell>
{isOfficial ? (
<AcquisitionSurvey onDone={onDone} />
) : (
<PrivacyPreferences onDone={onDone} />
)}
</Shell>
)
}
export default HeardAboutView

View File

@@ -0,0 +1,395 @@
import {
ArrowForwardRounded,
NotificationsActiveRounded,
} from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import {
haptic,
markOnboardingSeen,
requestNotificationPermission,
} from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
import {
CaptureVignette,
ProblemVignette,
RemindersVignette,
ScheduleVignette,
TakesTurnsVignette,
} from './OnboardingVignettes'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const SWIPE_THRESHOLD = 56
/**
* Five messages, in the order that sells: the problem first, then the two
* things Donetick does better than a reminders app, then the reasons to come
* back. Every title is an outcome — nobody buys "reschedule from completion
* date", they buy never arguing about trash day again.
*/
const SLIDES = [
{
key: 'problem',
title: 'Stop forgetting the little things',
body: "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
Visual: ProblemVignette,
},
{
key: 'capture',
title: 'Add it before you forget it',
body: "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
Visual: CaptureVignette,
},
// {
// key: 'schedule',
// title: 'It comes back exactly when it should',
// body: "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
// Visual: ScheduleVignette,
// },
{
key: 'circle',
title: 'Everyone gets a turn, No more arguing',
body: 'Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.',
Visual: TakesTurnsVignette,
},
{
key: 'reminders',
title: 'A nudge right when it matters & Widgets!',
body: "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too.",
Visual: RemindersVignette,
permission: true,
},
]
const Dots = ({ count, activeIndex, onSelect }) => (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
{Array.from({ length: count }, (_, index) => {
const active = index === activeIndex
return (
<Box
key={index}
component='button'
type='button'
aria-label={`Go to step ${index + 1}`}
aria-current={active ? 'step' : undefined}
onClick={() => onSelect(index)}
sx={{
border: 'none',
p: 0,
cursor: 'pointer',
height: 6,
width: active ? 22 : 6,
borderRadius: '999px',
bgcolor: active ? 'primary.500' : 'neutral.softBg',
transition: `width 280ms ${EASE}, background-color 280ms ease`,
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.500',
outlineOffset: '3px',
},
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
/>
)
})}
</Box>
)
const OnboardingView = () => {
const navigate = useNavigate()
const [activeIndex, setActiveIndex] = useState(0)
const [drag, setDrag] = useState(0)
const [asking, setAsking] = useState(false)
const pointerRef = useRef(null)
const viewportRef = useRef(null)
const isLast = activeIndex === SLIDES.length - 1
const asksPermission = Boolean(SLIDES[activeIndex].permission)
const finish = useCallback(() => {
markOnboardingSeen()
navigate('/get-started', { replace: true })
}, [navigate])
const goTo = useCallback(index => {
setActiveIndex(current => {
const next = Math.min(Math.max(index, 0), SLIDES.length - 1)
if (next !== current) haptic()
return next
})
}, [])
const enableNotifications = async () => {
setAsking(true)
try {
await requestNotificationPermission()
} finally {
setAsking(false)
}
haptic('medium')
finish()
}
const handleNext = () => {
if (isLast) {
haptic('medium')
finish()
return
}
goTo(activeIndex + 1)
}
// Arrow keys for keyboard/desktop parity with swiping.
useEffect(() => {
const onKeyDown = event => {
if (event.key === 'ArrowRight') goTo(activeIndex + 1)
if (event.key === 'ArrowLeft') goTo(activeIndex - 1)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [activeIndex, goTo])
const onPointerDown = event => {
if (event.pointerType === 'mouse' && event.button !== 0) return
pointerRef.current = { id: event.pointerId, startX: event.clientX }
}
const onPointerMove = event => {
const pointer = pointerRef.current
if (!pointer || pointer.id !== event.pointerId) return
const delta = event.clientX - pointer.startX
const atEdge = (activeIndex === 0 && delta > 0) || (isLast && delta < 0)
// Rubber-band past the first and last slide instead of hard-stopping.
setDrag(atEdge ? delta * 0.35 : delta)
}
const endDrag = event => {
const pointer = pointerRef.current
if (!pointer || pointer.id !== event.pointerId) return
const delta = event.clientX - pointer.startX
pointerRef.current = null
setDrag(0)
if (delta <= -SWIPE_THRESHOLD) goTo(activeIndex + 1)
else if (delta >= SWIPE_THRESHOLD) goTo(activeIndex - 1)
}
const width = viewportRef.current?.offsetWidth || 1
const dragPercent = (drag / width) * 100
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.body',
overflow: 'hidden',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Logo size='26px' />
<Typography
level='title-md'
sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}
>
Done
<Box component='span' sx={{ color: 'primary.500' }}>
tick
</Box>
</Typography>
</Box>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={finish}
sx={{ fontWeight: 600, borderRadius: '999px' }}
>
Skip
</Button>
</Box>
<Box
ref={viewportRef}
role='group'
aria-roledescription='carousel'
aria-label='What you can do with Donetick'
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
overflow: 'hidden',
touchAction: 'pan-y',
userSelect: 'none',
}}
>
<Box
sx={{
flex: 1,
display: 'flex',
// Without an explicit min-width the 300%-wide track would size to
// its content and push the slides past the viewport.
minWidth: 0,
minHeight: 0,
transform: `translate3d(calc(${-activeIndex * 100}% + ${dragPercent}%), 0, 0)`,
transition: pointerRef.current ? 'none' : `transform 360ms ${EASE}`,
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
{SLIDES.map((slide, index) => {
const active = index === activeIndex
const { Visual } = slide
return (
<Box
key={slide.key}
role='group'
aria-roledescription='slide'
aria-label={`${index + 1} of ${SLIDES.length}`}
aria-hidden={!active}
sx={{
flex: '0 0 100%',
minWidth: 0,
display: 'flex',
flexDirection: 'column',
// Visual and copy travel together as one centred group;
// pinning the copy to the bottom leaves them disconnected.
justifyContent: 'center',
gap: 4,
px: 3,
py: 2,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Remounting on activation replays the vignette's
entrance instead of it playing off-screen once. */}
<Visual key={active ? 'active' : 'idle'} />
</Box>
<Box
key={active ? 'copy-active' : 'copy-idle'}
sx={{
textAlign: 'center',
'@media (prefers-reduced-motion: reduce)': {
'& > *': { animation: 'none' },
},
}}
>
<Typography
level='h2'
sx={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
animation: `slideCopyIn 460ms ${EASE} 60ms both`,
'@keyframes slideCopyIn': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'none' },
},
}}
>
{slide.title}
</Typography>
<Typography
level='body-md'
sx={{
mt: 1,
mx: 'auto',
maxWidth: '34ch',
color: 'text.secondary',
textWrap: 'pretty',
animation: `slideCopyIn 460ms ${EASE} 140ms both`,
}}
>
{slide.body}
</Typography>
</Box>
</Box>
)
})}
</Box>
</Box>
<Box
sx={{
px: 3,
pt: 1,
pb: 3,
display: 'flex',
flexDirection: 'column',
gap: 2.5,
}}
>
<Dots count={SLIDES.length} activeIndex={activeIndex} onSelect={goTo} />
{asksPermission ? (
// The permission ask gets its own pair of choices: a system prompt
// is a decision, not a "Next".
<>
<Button
size='lg'
fullWidth
loading={asking}
onClick={enableNotifications}
startDecorator={<NotificationsActiveRounded />}
sx={authButtonSx}
>
Turn on reminders
</Button>
<Button
variant='plain'
color='neutral'
size='lg'
fullWidth
disabled={asking}
// Deliberately not recorded as an opt-out: the in-app prompt can
// still ask once the user has tasks that would benefit from it.
onClick={finish}
sx={{ ...authButtonSx, mt: -1.5 }}
>
Not now
</Button>
</>
) : (
<Button
size='lg'
fullWidth
onClick={handleNext}
endDecorator={!isLast ? <ArrowForwardRounded /> : null}
sx={authButtonSx}
>
{isLast ? 'Get started' : 'Next'}
</Button>
)}
</Box>
</Box>
)
}
export default OnboardingView

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import { Capacitor } from '@capacitor/core'
import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { haptic } from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const READY = [
'Shared chores for the whole house',
'Recurring schedules that survive real life',
'Capture by voice, photo or text',
'Reminders and home-screen widgets',
]
/**
* The beat between signing up and landing in an empty task list: the account is
* made, so say so. Everything listed here is already switched on for a free
* account — the upgrade offer comes after, never in place of it.
*/
const WorkspaceReadyView = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const [busy, setBusy] = useState(false)
const enterApp = () => navigate('/chores', { replace: true })
/**
* Shows the configured RevenueCat offering, then continues into the app
* whatever the outcome — dismissing the paywall is the "continue free" path,
* and a missing offering or a store hiccup must never trap a new user here.
*/
const showPaywall = async () => {
if (!Capacitor.isNativePlatform() || !userProfile?.id) return
const { Purchases } = await import('@revenuecat/purchases-capacitor')
const { RevenueCatUI } = await import('@revenuecat/purchases-capacitor-ui')
await Purchases.configure({
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile.id),
})
const offerings = await Purchases.getOfferings()
if (!offerings?.current) return
await RevenueCatUI.presentPaywall({ offering: offerings.current })
const { customerInfo } = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['Donetick Plus']) {
queryClient.invalidateQueries(['userProfile'])
}
}
const handleContinue = async () => {
setBusy(true)
haptic('medium')
try {
await showPaywall()
} catch (error) {
console.log('Paywall skipped:', error)
} finally {
setBusy(false)
enterApp()
}
}
return (
<Box
component='main'
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 3,
bgcolor: 'background.body',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 1.5,
mb: 4,
animation: `readyIn 520ms ${EASE} both`,
'@keyframes readyIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
>
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
'&::before': {
content: '""',
position: 'absolute',
width: 170,
height: 170,
borderRadius: '50%',
bgcolor: 'primary.softBg',
opacity: 0.6,
filter: 'blur(30px)',
},
'& > *': { position: 'relative' },
}}
>
<Logo size='84px' />
</Box>
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
{userProfile?.displayName
? `You're all set, ${userProfile.displayName.split(' ')[0]}`
: "You're all set"}
</Typography>
<Typography
level='body-md'
sx={{ color: 'text.secondary', maxWidth: '30ch' }}
>
Your workspace is ready. Here&apos;s what&apos;s waiting inside.
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{READY.map((item, index) => (
<Box
key={item}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.75,
py: 1.25,
borderRadius: '14px',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
// Each line checks itself off in turn: the small "it's done"
// beat this screen exists for.
animation: `readyTick 460ms ${EASE} ${180 + index * 120}ms both`,
'@keyframes readyTick': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
}}
>
<Box
sx={{
flex: '0 0 auto',
width: 24,
height: 24,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
bgcolor: 'success.500',
color: 'common.white',
'& svg': { fontSize: '1rem' },
}}
>
<CheckRounded />
</Box>
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Box sx={{ width: '100%', maxWidth: 420 }}>
<Button
size='lg'
fullWidth
loading={busy}
onClick={handleContinue}
sx={authButtonSx}
>
Continue
</Button>
</Box>
</Box>
)
}
export default WorkspaceReadyView

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import {
Analytics,
BarChart,
CallReceived,
CloudOff,
EventBusy,
Schedule,
Speed,
@@ -25,7 +26,7 @@ import {
} from '@mui/joy'
import { useTheme } from '@mui/joy/styles'
import moment from 'moment'
import { Link, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import {
Line,
@@ -35,6 +36,7 @@ import {
XAxis,
YAxis,
} from 'recharts'
import EmptyState from '../../components/common/EmptyState'
import { useThingHistory } from '../../queries/ThingQueries'
import LoadingComponent from '../components/Loading'
@@ -49,6 +51,7 @@ const ThingsHistory = () => {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useThingHistory(id)
// Flatten all pages of history data
@@ -152,35 +155,23 @@ const ThingsHistory = () => {
if (error || !thingsHistory || thingsHistory.length === 0) {
return (
<Container
maxWidth='md'
sx={{
textAlign: 'center',
display: 'flex',
// make sure the content is centered vertically:
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EventBusy
sx={{
fontSize: '6rem',
// color: 'text.disabled',
mb: 1,
}}
<Container maxWidth='md'>
<EmptyState
variant={error ? 'error' : 'empty'}
fullHeight
icon={error ? <CloudOff /> : <EventBusy />}
title={error ? "Couldn't load this history" : 'No history yet'}
description={
error
? 'We could not reach the server. Check your connection and try again.'
: "Each time this thing's value changes, the change is recorded here."
}
primaryAction={
error
? { label: 'Try again', onClick: () => refetch() }
: { label: 'Back to things', to: '/things' }
}
/>
<Typography level='h3' gutterBottom>
No history found
</Typography>
<Typography level='body1'>
It looks like there is no history for this thing yet.
</Typography>
<Button variant='soft' sx={{ mt: 2 }}>
<Link to='/things'>Go back to things</Link>
</Button>
</Container>
)
}

View File

@@ -28,6 +28,7 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateThing,
@@ -405,25 +406,20 @@ const ThingsView = () => {
}}
>
{things.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
<EmptyState
fullHeight
icon={<Widgets />}
title='No things yet'
description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.'
primaryAction={{
label: 'Create a thing',
startDecorator: <Add />,
onClick: () => {
setCreateModalThing(null)
setIsShowCreateThingModal(true)
},
}}
>
<Widgets
sx={{
fontSize: '4rem',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
No things has been created/found
</Typography>
</Box>
/>
)}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{things.map(thing => (

View File

@@ -38,6 +38,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import { useLocalization } from '../../contexts/LocalizationContext'
import {
useChoreTimer,
@@ -1204,9 +1205,12 @@ const TimerDetails = () => {
)}
{(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
<Alert color='neutral'>
No work sessions found for this timer.
</Alert>
<EmptyState
size='sm'
icon={<AccessTime />}
title='No work sessions yet'
description='Start the timer on this task and each session lands here.'
/>
)}
</Box>
) : (

View File

@@ -20,17 +20,16 @@ import {
import {
Avatar,
Box,
Button,
Card,
Chip,
Container,
Divider,
Grid,
Link,
Stack,
Typography,
} from '@mui/joy'
import React, { useEffect, useMemo, useState } from 'react'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter'
@@ -992,54 +991,21 @@ const UserActivites = () => {
{/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
<Container
maxWidth='md'
sx={{
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EventBusy
sx={{
fontSize: '6rem',
mb: 1,
}}
/>
<Typography level='h3' gutterBottom>
No activities found
</Typography>
<Typography level='body1' sx={{ mb: 1 }}>
No activities found for{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{selectedUser === undefined || selectedUser === 'all'
? 'All Users'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
</Typography>{' '}
in the{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
</Typography>
.
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary', mb: 2 }}>
Try selecting a different time period or user filter above.
</Typography>
<Button variant='soft' sx={{ mt: 2 }}>
<Link to='/chores'>Go back to chores</Link>
</Button>
</Container>
<EmptyState
variant='no-results'
fullHeight
icon={<EventBusy />}
title='No activity in this range'
description={`Nothing was completed by ${
selectedUser === undefined || selectedUser === 'all'
? 'anyone in your circle'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'this member'
} ${
tabValue === 365 ? 'so far' : `in the last ${tabValue} days`
}. Try a wider time range or a different member.`}
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
/>
) : (
<>
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}

View File

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

View File

@@ -45,7 +45,7 @@ const NavBar = () => {
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
{
to: '/chores',
@@ -128,7 +128,9 @@ const NavBar = () => {
}
}}
title={
searchParams.get('from') === 'calendar' ? t('backToCalendar') : t('back')
searchParams.get('from') === 'calendar'
? t('backToCalendar')
: t('back')
}
>
<ArrowBack />
@@ -145,6 +147,9 @@ const NavBar = () => {
'/password/update',
'/login/settings',
'/welcome',
'/onboarding',
'/get-started',
'/ready',
].includes(location.pathname)
) {
return (

View File

@@ -1,61 +1,23 @@
import { HomeRounded, Login } from '@mui/icons-material'
import { Box, Button, CircularProgress, Container } from '@mui/joy'
import { Typography } from '@mui/material'
import { Link } from 'react-router-dom' // Assuming you are using React Router
import Logo from '../../Logo'
import { Explore, HomeRounded } from '@mui/icons-material'
import { Container } from '@mui/joy'
import EmptyState from '../../components/common/EmptyState'
const NotFound = () => {
return (
<Container className='flex h-full items-center justify-center'>
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
<Container maxWidth='sm'>
<EmptyState
variant='no-results'
fullHeight
icon={<Explore />}
title='Page not found'
description='This link does not lead anywhere. It may have moved, or the address has a typo in it.'
primaryAction={{
label: 'Go to my tasks',
to: '/chores',
startDecorator: <HomeRounded />,
}}
>
<CircularProgress
value={100}
color='danger' // Set the color to 'error' for danger color
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
}}
>
Page Not Found
</Box>
<Typography level='h2' fontWeight={500} textAlign={'center'}>
Sorry, I could be wrong but I think you are lost.
</Typography>
<Button
component={Link}
to='/chores'
variant='outlined'
color='primary'
sx={{ mt: 4 }}
size='lg'
startDecorator={<HomeRounded />}
>
Home
</Button>
<Button
component={Link}
to='/login'
variant='outlined'
color='primary'
sx={{ mt: 1 }}
size='lg'
startDecorator={<Login />}
>
Login
</Button>
</Box>
secondaryAction={{ label: 'Log in', to: '/login' }}
/>
</Container>
)
}

View File

@@ -138,15 +138,7 @@ const ScanPanel = ({
const isProcessing = phase === 'processing'
return (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
<Box>
{/* ── Capture phase ── */}
{phase === 'capture' && (
<>
@@ -160,6 +152,8 @@ const ScanPanel = ({
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.900',
// The wrapper used to clip this; it owns its own corners now
borderRadius: 'md',
overflow: 'hidden',
}}
>
@@ -211,7 +205,6 @@ const ScanPanel = ({
{(isNativeScanner || cameraAvailable) && (
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
@@ -240,7 +233,7 @@ const ScanPanel = ({
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
p: 2.5,
py: 2.5,
}}
>
{capturedImage && (
@@ -297,7 +290,7 @@ const ScanPanel = ({
{/* ── Error phase ── */}
{phase === 'error' && (
<Box sx={{ p: 2 }}>
<Box sx={{ py: 2 }}>
{capturedImage && (
<img
src={capturedImage}

View File

@@ -470,20 +470,11 @@ const VoicePanel = ({
: 'Hold to speak · quick tap for hands-free'
return (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
<Box>
{/* ── Header ── */}
<Box
sx={{
px: 1.5,
pt: 1.25,
pt: 0.5,
display: 'flex',
alignItems: 'center',
gap: 1,
@@ -506,7 +497,7 @@ const VoicePanel = ({
{/* ── Permission denied ── */}
{phase === 'denied' && (
<Box sx={{ p: 2 }}>
<Box sx={{ py: 2 }}>
<Box
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1.5 }}
>
@@ -532,7 +523,6 @@ const VoicePanel = ({
<Box
ref={segmentsScrollRef}
sx={{
px: 1.5,
pt: 1.25,
display: 'flex',
flexDirection: 'column',
@@ -556,14 +546,13 @@ const VoicePanel = ({
{/* ── Live transcript ── */}
{isListening && (
<Box sx={{ px: 1.5, pt: 1.25 }}>
<Box sx={{ pt: 1.25 }}>
<Box
sx={{
minHeight: 44,
borderRadius: 'md',
border: '1px dashed',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
px: 1.25,
py: 1,
}}

View File

@@ -1,8 +1,17 @@
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import pkg from './package.json'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ command }) => ({
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
},
// Strip console.* / debugger from production bundles only, so dev logging
// is untouched. `command` is 'build' for `vite build`, 'serve' for the dev server.
esbuild: {
drop: command === 'build' ? ['console', 'debugger'] : [],
},
plugins: [
react(),
VitePWA({
@@ -80,4 +89,4 @@ export default defineConfig({
},
],
},
})
}))