diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json
index 085ef01..4edd311 100644
--- a/public/locales/en/settings.json
+++ b/public/locales/en/settings.json
@@ -171,7 +171,11 @@
},
"feedback": {
"title": "Send Feedback",
- "description": "Tell us how Donetick is working for you, report a bug, or request a feature."
+ "description": "Tell us how Donetick is working for you or request a feature."
+ },
+ "bugReport": {
+ "title": "Report a Bug",
+ "description": "Something not working right? Send us the details along with a technical snapshot."
}
}
}
diff --git a/src/constants/theme.js b/src/constants/theme.js
index 3e2e974..6593efa 100644
--- a/src/constants/theme.js
+++ b/src/constants/theme.js
@@ -5,3 +5,8 @@ import tailwindConfig from '/tailwind.config.mjs'
export const { theme: THEME } = resolveConfig(tailwindConfig)
export const COLORS = THEME.colors
+
+export const THEME_BACKGROUND = {
+ dark: '#000000',
+ light: '#FFFFFF',
+}
diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx
index 7df2d42..7728614 100644
--- a/src/contexts/ThemeContext.jsx
+++ b/src/contexts/ThemeContext.jsx
@@ -1,8 +1,9 @@
-import { COLORS } from '@/constants/theme'
import { CssBaseline } from '@mui/joy'
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types'
+import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
+
const primaryColor = 'cyan'
const shades = [
'50',
@@ -34,6 +35,9 @@ const theme = extendTheme({
colorSchemes: {
light: {
palette: {
+ background: {
+ body: THEME_BACKGROUND.light,
+ },
primary: primaryPalette,
success: {
50: '#f3faf7',
@@ -75,6 +79,9 @@ const theme = extendTheme({
},
dark: {
palette: {
+ background: {
+ body: THEME_BACKGROUND.dark,
+ },
primary: primaryPalette,
},
},
diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js
index 972a11e..ec84b7e 100644
--- a/src/hooks/useFileUpload.js
+++ b/src/hooks/useFileUpload.js
@@ -1,14 +1,15 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
+
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({
- entityType = 'chore_attachment',
- entityId,
draftId,
+ entityId,
+ entityType = 'chore_attachment',
} = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
@@ -19,28 +20,36 @@ export const useFileUpload = ({
showError({
title: 'Plus Feature',
message:
- 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
+ 'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
})
return null
}
try {
- const compressionOptions = {
- maxSizeMB: entityType === 'profile' ? 0.5 : 1,
- maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
- useWebWorker: true,
- fileType: 'image/jpeg',
+ // Only images go through compression — anything else (PDFs, docs)
+ // would be destroyed by re-encoding it as a JPEG.
+ let fileToUpload = file
+ if (file.type?.startsWith('image/')) {
+ const compressionOptions = {
+ maxSizeMB: entityType === 'profile' ? 0.5 : 1,
+ maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
+ useWebWorker: true,
+ fileType: 'image/jpeg',
+ }
+
+ const compressedFile = await imageCompression(
+ file,
+ compressionOptions,
+ )
+ fileToUpload = new File(
+ [compressedFile],
+ `${file.name.split('.')[0]}.jpg`,
+ { type: 'image/jpeg' },
+ )
}
- const compressedFile = await imageCompression(file, compressionOptions)
- const compressedJpegFile = new File(
- [compressedFile],
- `${file.name.split('.')[0]}.jpg`,
- { type: 'image/jpeg' },
- )
-
const formData = new FormData()
- formData.append('file', compressedJpegFile)
+ formData.append('file', fileToUpload)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', String(entityId))
if (draftId) formData.append('draftId', draftId)
@@ -62,7 +71,7 @@ export const useFileUpload = ({
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
- message: 'Image uploads are only available for Plus accounts.',
+ message: 'File uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
@@ -74,7 +83,7 @@ export const useFileUpload = ({
} else if (!response.ok) {
showError({
title: 'Upload Failed',
- message: 'Failed to upload image.',
+ message: 'Failed to upload file.',
})
return null
}
@@ -91,7 +100,7 @@ export const useFileUpload = ({
} catch {
showError({
title: 'Upload Failed',
- message: 'An error occurred while processing the image.',
+ message: 'An error occurred while processing the file.',
})
return null
}
diff --git a/src/hooks/useOnboardingGate.js b/src/hooks/useOnboardingGate.js
index 69a6a1d..d420d14 100644
--- a/src/hooks/useOnboardingGate.js
+++ b/src/hooks/useOnboardingGate.js
@@ -1,5 +1,6 @@
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
@@ -11,6 +12,9 @@ const ALLOWED_PATHS = [
'/login/settings',
'/privacy',
'/terms',
+ // An invite link is a legitimate first launch: the join view explains itself
+ // and routes to sign-in, so onboarding must not swallow the code.
+ '/circle/join',
]
const isAllowed = pathname =>
diff --git a/src/hooks/useStatusBar.js b/src/hooks/useStatusBar.js
index 9075adc..e6ac4e9 100644
--- a/src/hooks/useStatusBar.js
+++ b/src/hooks/useStatusBar.js
@@ -1,5 +1,6 @@
import { useColorScheme } from '@mui/joy'
import { useEffect } from 'react'
+
import statusBarManager from '../utils/StatusBarManager'
/**
@@ -35,10 +36,7 @@ export const useStatusBar = () => {
// Update the status bar with the resolved theme
await statusBarManager.updateResolvedTheme(resolvedTheme)
-
- // Also update the base theme for future reference
- await statusBarManager.setTheme(mode)
-
+
// Notify any custom listeners
statusBarManager.notifyThemeChange(resolvedTheme)
}
diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js
index 0293264..d09f20d 100644
--- a/src/hooks/useSyncOnReconnect.js
+++ b/src/hooks/useSyncOnReconnect.js
@@ -96,6 +96,9 @@ export function useSyncOnReconnect() {
// the same tick as the deep link, before the route changes, so this has
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
+ // No session, nothing to sync — and a 401 here would force a logout that
+ // hard-navigates signed-out visitors (invite links) away to /login.
+ if (!localStorage.getItem('token')) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {
diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx
index 8200769..86b145c 100644
--- a/src/queries/UserQueries.jsx
+++ b/src/queries/UserQueries.jsx
@@ -29,6 +29,7 @@ export const useAllUsers = () => {
export const useCircleMembers = () => {
const queryClient = useQueryClient()
+ const token = localStorage.getItem('token')
const { data, error, isLoading } = useQuery({
queryKey: ['allCircleMembers'],
@@ -46,6 +47,10 @@ export const useCircleMembers = () => {
return { res: [] }
}
},
+ // NavBar's avatar mounts this on every route, including the signed-out
+ // ones. Without the gate the 401 tips ApiClient into a forced logout that
+ // hard-navigates to /login — which is what used to eat circle invites.
+ enabled: !!token,
})
const handleRefetch = () => {
diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js
index c290c52..1d9689c 100644
--- a/src/service/ErrorReportService.js
+++ b/src/service/ErrorReportService.js
@@ -113,6 +113,9 @@ export const collectErrorReport = async ({ error, errorInfo, reportId }) => {
return {
reportId: reportId ?? newReportId(),
occurredAt: new Date().toISOString(),
+ // No error means the user came here deliberately from settings rather than
+ // off the back of a crash — same diagnostics, different story to tell.
+ kind: error ? 'crash' : 'bug',
error: describeError(error, errorInfo),
runtime: describeRuntime(),
app: context,
@@ -137,7 +140,11 @@ export const formatErrorReport = report => {
`Report ID: ${report.reportId}`,
`Time: ${report.occurredAt}`,
'',
- `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
+ // A user-initiated report has no throw behind it; "Error: Unknown" would
+ // only be noise in the panel the user is being asked to read.
+ report.kind === 'bug'
+ ? 'Reported manually (no crash)'
+ : `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
error.status
? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim()
: null,
@@ -210,11 +217,14 @@ export const formatErrorReport = report => {
* leaves infrastructure they control, and they see it before it is published.
*/
export const buildErrorIssueUrl = ({ description, report }) => {
- const title = `[crash] ${
- report.error.message?.slice(0, 80) ||
- report.error.name ||
- 'Unexpected error'
- }`
+ const isBug = report.kind === 'bug'
+ const title = isBug
+ ? `[bug] ${description?.trim().slice(0, 80) || 'Reported from the app'}`
+ : `[crash] ${
+ report.error.message?.slice(0, 80) ||
+ report.error.name ||
+ 'Unexpected error'
+ }`
const body = [
'### What happened',
description?.trim() || '_no description provided_',
@@ -241,7 +251,7 @@ export const submitErrorReport = async ({
}) => {
const payload = {
source: 'donetick-app',
- kind: 'error-report',
+ kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
reportId: report.reportId,
description: description?.trim() || null,
contactEmail: contactEmail?.trim() || null,
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 3510ae1..1d78e60 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -155,6 +155,21 @@ class ApiClient {
return
}
+ // An expired session on an invite link would otherwise drop the code on the
+ // way to /login. Stash it first so sign-in returns to the join.
+ try {
+ const { pathname, search } = window.location
+ if (pathname === '/circle/join') {
+ const code = new URLSearchParams(search).get('code')
+ if (code) {
+ const { setPendingInvite } = await import('./PendingInvite')
+ setPendingInvite(code)
+ }
+ }
+ } catch (e) {
+ console.error('Error preserving pending invite on logout', e)
+ }
+
await clearAllTokens()
try {
await offlineDB.clearAll()
diff --git a/src/utils/FileConvert.js b/src/utils/FileConvert.js
new file mode 100644
index 0000000..b61e2f1
--- /dev/null
+++ b/src/utils/FileConvert.js
@@ -0,0 +1,17 @@
+/**
+ * Turns an image source the scanners produce — a base64 data URI on iOS/web,
+ * a Capacitor localhost URL on Android — into a File the upload endpoint
+ * accepts. Both forms are fetchable, so one path covers them.
+ */
+export async function imageSourceToFile(source, fileName = 'scan.jpg') {
+ if (!source) return null
+ try {
+ const response = await fetch(source)
+ const blob = await response.blob()
+ const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
+ return new File([blob], fileName, { type })
+ } catch (e) {
+ console.error('[FileConvert] failed to convert image source:', e)
+ return null
+ }
+}
diff --git a/src/utils/PendingInvite.js b/src/utils/PendingInvite.js
new file mode 100644
index 0000000..b5d0059
--- /dev/null
+++ b/src/utils/PendingInvite.js
@@ -0,0 +1,40 @@
+import Cookies from 'js-cookie'
+
+// A circle invite link is often the very first thing a new user opens, so the
+// code has to survive the trip through login/signup (including OAuth, which
+// leaves and re-enters the app) and be replayed once a session exists.
+const INVITE_KEY = 'pending_circle_invite'
+const REDIRECT_COOKIE = 'ca_redirect'
+
+// `auto=1` tells the join view this visit is the return leg of an auth
+// round-trip, so it can submit the request instead of asking a second time.
+export const joinCirclePath = code =>
+ `/circle/join?code=${encodeURIComponent(code)}&auto=1`
+
+export const setPendingInvite = code => {
+ if (!code) return
+ localStorage.setItem(INVITE_KEY, code)
+ // Every post-auth landing point (password login, OAuth callback, MFA) already
+ // consumes `ca_redirect`, so reusing it is all the routing this needs.
+ Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
+}
+
+export const getPendingInvite = () => {
+ try {
+ return localStorage.getItem(INVITE_KEY)
+ } catch {
+ return null
+ }
+}
+
+export const clearPendingInvite = () => {
+ try {
+ localStorage.removeItem(INVITE_KEY)
+ } catch {
+ // ignore
+ }
+ const redirect = Cookies.get(REDIRECT_COOKIE)
+ if (redirect && redirect.startsWith('/circle/join')) {
+ Cookies.remove(REDIRECT_COOKIE)
+ }
+}
diff --git a/src/utils/StatusBarManager.js b/src/utils/StatusBarManager.js
index ce8fc62..7d03a43 100644
--- a/src/utils/StatusBarManager.js
+++ b/src/utils/StatusBarManager.js
@@ -2,6 +2,8 @@ import { Capacitor } from '@capacitor/core'
import { StatusBar, Style } from '@capacitor/status-bar'
import { SafeArea } from 'capacitor-plugin-safe-area'
+import { THEME_BACKGROUND } from '@/constants/theme'
+
/**
* StatusBarManager - A utility class to handle status bar configuration
* following Capacitor best practices and theme-aware styling
@@ -52,17 +54,18 @@ class StatusBarManager {
this.currentTheme = theme
try {
- let style = Style.Light // Default to light content (dark status bar)
-
- if (theme === 'dark') {
- style = Style.Dark // Dark content (light status bar)
- } else if (theme === 'system') {
- // For system theme, we need to detect the actual system preference
- // Joy UI's useColorScheme will handle this, but we default to light
- style = Style.Light
- }
+ const resolvedTheme =
+ theme === 'system'
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ : theme
+ const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
await StatusBar.setStyle({ style })
+ await StatusBar.setBackgroundColor({
+ color: THEME_BACKGROUND[resolvedTheme],
+ })
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
} catch (error) {
console.error('StatusBarManager: Failed to set theme:', error)
@@ -162,15 +165,7 @@ class StatusBarManager {
async updateResolvedTheme(resolvedTheme) {
if (!this.isNativePlatform) return
- try {
- const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
- await StatusBar.setStyle({ style })
- console.log(
- `StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
- )
- } catch (error) {
- console.error('StatusBarManager: Failed to update resolved theme:', error)
- }
+ await this.setTheme(resolvedTheme)
}
/**
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index 1b0959d..32e7a37 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -12,12 +12,14 @@ import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
+
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
+import { getPendingInvite } from '../../utils/PendingInvite'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import {
@@ -138,7 +140,15 @@ const LoginView = () => {
}, [])
useEffect(() => {
if (isAuthenticated && user) {
- Navigate('/chores')
+ // An already-signed-in visitor who lands here from a deep link (a circle
+ // invite, for example) still has to end up where they were headed.
+ const redirectUrl = Cookies.get('ca_redirect')
+ if (redirectUrl && redirectUrl !== '/') {
+ Cookies.remove('ca_redirect')
+ Navigate(redirectUrl)
+ } else {
+ Navigate('/chores')
+ }
}
}, [isAuthenticated, user, Navigate])
const handleSubmit = async e => {
@@ -416,9 +426,11 @@ const LoginView = () => {
}
diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx
index 62e4344..8600ce4 100644
--- a/src/views/Authorization/Signup.jsx
+++ b/src/views/Authorization/Signup.jsx
@@ -2,8 +2,11 @@ import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
+
+import { useAuth } from '../../hooks/useAuth.jsx'
import { useNotification } from '../../service/NotificationProvider'
-import { login, signUp } from '../../utils/Fetcher'
+import { signUp } from '../../utils/Fetcher'
+import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
import {
AuthPasswordField,
AuthSubmitButton,
@@ -25,28 +28,39 @@ const SignupView = () => {
const [displayNameError, setDisplayNameError] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { showError } = useNotification()
- const handleLogin = (username, password) => {
- login(username, password).then(response => {
- if (response.status === 200) {
- response.json().then(res => {
- localStorage.setItem('token', res.token)
- localStorage.setItem('token_expiry', res.expire)
+ const { login: authLogin } = useAuth()
+ // Sign-in goes through the auth context, not a bare fetch: it stores the
+ // refresh token and updates the provider's own state, so the rest of the app
+ // sees the new session without a reload.
+ const handleLogin = async (username, password) => {
+ const result = await authLogin({ username, password })
+ if (!result.success) {
+ showError({
+ title: 'Almost there',
+ message:
+ 'Your account was created, but signing in failed. Please sign in.',
+ })
+ Navigate('/login')
+ return
+ }
- // Invalidate user profile queries to ensure fresh data
- queryClient.invalidateQueries(['userProfile'])
+ // Invalidate user profile queries to ensure fresh data
+ queryClient.invalidateQueries(['userProfile'])
- // 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)
+ // Someone who signed up from a circle invite is joining an existing
+ // circle, so sending them through "name your circle" is both a dead
+ // end for the invite and the wrong question.
+ const pendingInvite = getPendingInvite()
+ if (pendingInvite) {
+ Navigate(joinCirclePath(pendingInvite), { replace: true })
+ return
+ }
- // Navigate('/login')
- }
- })
+ // 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 })
}
const handleSignUpValidation = () => {
// Reset errors before validation
@@ -132,7 +146,11 @@ const SignupView = () => {
return (
}
logoSize={0}
>
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index e7adf3a..f53b9c1 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -3,6 +3,7 @@ import {
ArrowDropDown,
AttachFile,
Delete,
+ DocumentScanner,
HorizontalRule,
Save,
UploadFile,
@@ -41,6 +42,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import DurationInput from '../../components/common/DurationInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
+import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import {
useArchiveChore,
useChore,
@@ -59,6 +61,7 @@ import {
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
+import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
import Priorities from '../../utils/Priorities.jsx'
@@ -173,6 +176,7 @@ const ChoreEdit = () => {
const { data: membersData, isLoading: isMemberDataLoading } =
useCircleMembers()
const { showError, showSuccess } = useNotification()
+ const { isNativeScanner, scanDocument } = useDocumentScanner()
const [userLabels, setUserLabels] = useState([])
@@ -671,6 +675,67 @@ const ChoreEdit = () => {
}
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
+ const uploadAttachmentFile = async file => {
+ if (!file) return
+ setIsUploadingAttachment(true)
+ try {
+ const response = choreId
+ ? await UploadChoreAttachment(file, 'chore_attachment', {
+ entityId: choreId,
+ })
+ : await UploadChoreAttachment(file, 'chore_attachment_draft', {
+ draftId,
+ })
+ if (!response.ok) {
+ showError({
+ title: 'Upload Failed',
+ message: 'Failed to upload attachment.',
+ })
+ return
+ }
+ const data = await response.json()
+ setAttachments(prev => [
+ ...prev,
+ {
+ file_path: data.path,
+ file_name: data.file_name,
+ size_bytes: data.size_bytes,
+ sign: data.sign,
+ },
+ ])
+ } catch {
+ showError({
+ title: 'Upload Failed',
+ message: 'Failed to upload attachment.',
+ })
+ } finally {
+ setIsUploadingAttachment(false)
+ }
+ }
+
+ // Native only: the OS scanner returns a cropped, deskewed page which is a
+ // better attachment than a raw camera shot of the same document.
+ const handleScanAttachment = async () => {
+ const { cancelled, error, image } = await scanDocument()
+ if (cancelled) return
+ if (error || !image) {
+ showError({
+ title: 'Scan Failed',
+ message: error || 'Could not scan the document.',
+ })
+ return
+ }
+ const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
+ if (!file) {
+ showError({
+ title: 'Scan Failed',
+ message: 'Could not read the scanned image.',
+ })
+ return
+ }
+ await uploadAttachmentFile(file)
+ }
+
const handleDelete = () => {
setConfirmModelConfig({
isOpen: true,
@@ -1109,62 +1174,39 @@ const ChoreEdit = () => {
))}
)}
- }
- loading={isUploadingAttachment}
- sx={{ alignSelf: 'flex-start' }}
- >
- Upload File
- {
- const file = e.target.files[0]
- if (!file) return
- setIsUploadingAttachment(true)
- try {
- const response = choreId
- ? await UploadChoreAttachment(file, 'chore_attachment', {
- entityId: choreId,
- })
- : await UploadChoreAttachment(
- file,
- 'chore_attachment_draft',
- { draftId },
- )
- if (!response.ok) {
- showError({
- title: 'Upload Failed',
- message: 'Failed to upload attachment.',
- })
- return
- }
- const data = await response.json()
- setAttachments(prev => [
- ...prev,
- {
- file_path: data.path,
- file_name: data.file_name,
- size_bytes: data.size_bytes,
- sign: data.sign,
- },
- ])
- } catch {
- showError({
- title: 'Upload Failed',
- message: 'Failed to upload attachment.',
- })
- } finally {
- setIsUploadingAttachment(false)
+
+ }
+ loading={isUploadingAttachment}
+ >
+ Upload File
+ {
+ const file = e.target.files[0]
e.target.value = ''
- }
- }}
- />
-
+ await uploadAttachmentFile(file)
+ }}
+ />
+
+ {isNativeScanner && (
+ }
+ disabled={isUploadingAttachment}
+ onClick={handleScanAttachment}
+ >
+ Scan
+
+ )}
+
diff --git a/src/views/Circles/JoinCircle.jsx b/src/views/Circles/JoinCircle.jsx
index 9026b02..a62b99f 100644
--- a/src/views/Circles/JoinCircle.jsx
+++ b/src/views/Circles/JoinCircle.jsx
@@ -1,162 +1,260 @@
-import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
-import Logo from '../../Logo'
-
-import { Button } from '@mui/joy'
-import { useState } from 'react'
+import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
+
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { JoinCircle } from '../../utils/Fetcher'
+import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
+import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
+import { CircleVignette } from '../Onboarding/OnboardingVignettes'
+
+const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
+
+const enter = (delay = 0) => ({
+ animation: `joinCircleIn 520ms ${EASE} ${delay}ms both`,
+ '@keyframes joinCircleIn': {
+ from: { opacity: 0, transform: 'translateY(12px)' },
+ to: { opacity: 1, transform: 'none' },
+ },
+ '@media (prefers-reduced-motion: reduce)': { animation: 'none' },
+})
const JoinCircleView = () => {
- const { data: userProfile } = useUserProfile()
+ const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
+ // Read the token rather than useAuth(): the provider's copy only updates
+ // through its own login(), so signup and the OAuth callback — which save
+ // tokens directly — would still look signed out here. The query hooks read
+ // storage the same way.
+ const isAuthenticated = !!localStorage.getItem('token')
const { showError } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [isJoining, setIsJoining] = useState(false)
- let [searchParams, setSearchParams] = useSearchParams()
+ const [searchParams] = useSearchParams()
const navigate = useNavigate()
const code = searchParams.get('code')
+ // `auto=1` is on the link we send the user back to after they authenticate,
+ // and only there — someone who opens an invite while already signed in gets
+ // asked, not auto-joined.
+ const isReturningFromAuth = searchParams.get('auto') === '1'
+ const autoJoinAttempted = useRef(false)
+
+ const submitJoin = useCallback(() => {
+ setIsJoining(true)
+ JoinCircle(code)
+ .then(resp => {
+ clearPendingInvite()
+ if (resp.ok) {
+ showAcknowledgment(
+ 'Your request has been sent. A circle admin will need to approve ' +
+ "it before you can access the circle and its chores. We'll " +
+ "notify you when it's approved.",
+ 'Request sent',
+ () => navigate('/chores'),
+ 'Got it',
+ 'success',
+ )
+ } else {
+ setIsJoining(false)
+ if (resp.status === 409) {
+ showError('You are already a member of this circle')
+ } else {
+ showError('Failed to join circle')
+ }
+ navigate('/chores')
+ }
+ })
+ .catch(() => {
+ setIsJoining(false)
+ clearPendingInvite()
+ showError('Could not send your join request. Please try again.')
+ })
+ }, [code, navigate, showAcknowledgment, showError])
+
+ // Coming back from login/signup the user already said yes by opening the
+ // link, so send the request instead of asking a second time. This step used
+ // to be missing entirely: the login page was a dead end.
+ useEffect(() => {
+ if (autoJoinAttempted.current) return
+ if (!code || !isReturningFromAuth) return
+ if (!isAuthenticated || !userProfile) return
+
+ autoJoinAttempted.current = true
+ submitJoin()
+ }, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
+
+ // Park the code so it survives the round-trip, including OAuth flows that
+ // leave the app entirely.
+ const goToAuth = destination => {
+ setPendingInvite(code)
+ navigate(destination)
+ }
+
+ const inviteCodeField = (
+
+ )
+
+ let title = "You're invited to join a circle"
+ let subtitle = null
+ let body = null
+
+ if (!code) {
+ title = 'Invite link is incomplete'
+ subtitle =
+ 'This invite link is missing a code. Ask the person who invited you to send a new link.'
+ body = (
+
+ )
+ // A token that no longer resolves to a profile is as good as signed out —
+ // better to offer sign-in than to spin forever.
+ } else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
+ subtitle =
+ "Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
+ body = (
+
+ {inviteCodeField}
+
+
+
+ )
+ } else if (isProfileLoading || isJoining) {
+ title = 'Sending your request'
+ subtitle = 'Sending your request…'
+ body = (
+
+
+
+ )
+ } else {
+ subtitle =
+ `Hi ${userProfile?.displayName || userProfile?.username}. ` +
+ "Send a request to share this circle's chores with its members."
+ body = (
+
+
+ A circle admin will review your request before you get access.
+
+
+
+
+ )
+ }
return (
-
-
+
+
+
+
-
-
-
- Done
-
+ {title}
+
+ {subtitle && (
+
- tick
-
-
- {code && userProfile && (
- <>
-
- Hi {userProfile?.displayName}, you have been invited to join the
- circle{' '}
-
-
-
- Joining will give you access to the circle's chores and members.
-
-
- You can leave the circle later from you Settings page.
-
-
-
- >
+ {subtitle}
+
)}
- {!code ||
- (!userProfile && (
- <>
-
- You need to be logged in to join a circle
-
-
- Login or sign up to continue
-
-
- >
- ))}
-
+
+
+ {body}
+
-
+
)
}
diff --git a/src/views/Modals/ErrorReportModal.jsx b/src/views/Modals/ErrorReportModal.jsx
index fd3c9a0..be4d41b 100644
--- a/src/views/Modals/ErrorReportModal.jsx
+++ b/src/views/Modals/ErrorReportModal.jsx
@@ -100,9 +100,13 @@ const IconHalo = ({ color = 'primary', icon }) => (
* user, everything else gathered automatically. The diagnostics are shown
* before sending rather than after — people are more willing to send a report
* they can see, and this is the one moment they already distrust the app.
+ *
+ * Also reached deliberately from settings with no error attached, where the
+ * same diagnostics back a bug the user noticed but the app never threw on.
*/
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
const { ResponsiveModal } = useResponsiveModal()
+ const isBugReport = !error
const [report, setReport] = useState(null)
const [description, setDescription] = useState('')
@@ -160,7 +164,10 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
{step === STEP.FORM && (
- } color='danger' />
+ }
+ color={isBugReport ? 'warning' : 'danger'}
+ />
@@ -168,26 +175,33 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
>
- Report this problem
+ {isBugReport ? 'Report a bug' : 'Report this problem'}
- A sentence about what you were doing turns this into something we
- can actually fix.
+ {isBugReport
+ ? 'Tell us what went wrong and we’ll attach the technical details for you.'
+ : 'A sentence about what you were doing turns this into something we can actually fix.'}
- What were you doing?
+
+ {isBugReport ? 'What went wrong?' : 'What were you doing?'}
+
@@ -279,7 +293,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
size='lg'
fullWidth
loading={submitting}
- disabled={!report}
+ // A crash report stands on its own; a manual one is only the
+ // description, so there's nothing to send without it.
+ disabled={!report || (isBugReport && !description.trim())}
onClick={handleSubmit}
>
Send report
@@ -293,7 +309,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
underline='hover'
onClick={onClose}
>
- Not now
+ {isBugReport ? 'Cancel' : 'Not now'}
diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx
index 3249d3f..378e919 100644
--- a/src/views/Settings/SettingsOverview.jsx
+++ b/src/views/Settings/SettingsOverview.jsx
@@ -1,6 +1,7 @@
import {
AccountCircle,
Api,
+ BugReport,
ChevronRight,
Circle,
Code,
@@ -35,9 +36,11 @@ import {
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 ErrorReportModal from '../Modals/ErrorReportModal'
import FeedbackModal from '../Modals/FeedbackModal'
const SettingsOverview = () => {
@@ -45,6 +48,7 @@ const SettingsOverview = () => {
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [feedbackOpen, setFeedbackOpen] = useState(false)
+ const [bugReportOpen, setBugReportOpen] = useState(false)
const settingsCards = [
{
@@ -133,6 +137,13 @@ const SettingsOverview = () => {
icon: ,
onSelect: () => setFeedbackOpen(true),
},
+ {
+ id: 'bugreport',
+ title: t('overview.sections.bugReport.title'),
+ description: t('overview.sections.bugReport.description'),
+ icon: ,
+ onSelect: () => setBugReportOpen(true),
+ },
]
const handleCardClick = setting => {
@@ -387,6 +398,13 @@ const SettingsOverview = () => {
open={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
+
+ {/* No error to pass: the report is about something the user saw, not
+ something the app threw, so the modal collects diagnostics only. */}
+ setBugReportOpen(false)}
+ />
)
}
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 5f6270a..500f7a9 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -1,18 +1,32 @@
import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
+import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
import moment from 'moment'
-import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
+import ModalActions from '../../components/common/ModalActions'
+import { useDocumentScanner } from '../../hooks/useDocumentScanner'
+import { useFileUpload } from '../../hooks/useFileUpload'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
+import { localAIService } from '../../service/LocalAIService'
+import { voiceInputService } from '../../service/VoiceInputService'
+import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import { CreateLabel } from '../../utils/Fetcher'
+import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount } from '../../utils/Helpers'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
+import AdvancedOptionsSection, {
+ AdvancedOptionsTrigger,
+} from './AdvancedOptionsSection'
+import AssigneePickerField from './AssigneePickerField'
+import AttachmentPickerField from './AttachmentPickerField'
import {
parseAssignees,
parseDueDate,
@@ -21,19 +35,6 @@ import {
parsePriority,
parseRepeatV2,
} from './CustomParsers'
-import SmartTaskTitleInput from './SmartTaskTitleInput'
-
-import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
-import ModalActions from '../../components/common/ModalActions'
-import { useDocumentScanner } from '../../hooks/useDocumentScanner'
-import { localAIService } from '../../service/LocalAIService'
-import { voiceInputService } from '../../service/VoiceInputService'
-import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
-import AdvancedOptionsSection, {
- AdvancedOptionsTrigger,
-} from './AdvancedOptionsSection'
-import AssigneePickerField from './AssigneePickerField'
-import AttachmentPickerField from './AttachmentPickerField'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
@@ -42,6 +43,7 @@ import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel'
+import SmartTaskTitleInput from './SmartTaskTitleInput'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
@@ -106,7 +108,7 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
-const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
+const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
@@ -190,6 +192,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const [showScan, setShowScan] = useState(false)
const [scanAutoCapture, setScanAutoCapture] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
+ const [isAttachingScan, setIsAttachingScan] = useState(false)
const [llmAvailable, setLlmAvailable] = useState(false)
const [showVoice, setShowVoice] = useState(false)
const [voiceAvailable, setVoiceAvailable] = useState(false)
@@ -213,6 +216,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
primaryAction: null,
})
const { isNativeScanner } = useDocumentScanner()
+ const { uploadFile } = useFileUpload({
+ entityType: 'chore_attachment_draft',
+ draftId,
+ })
useEffect(() => {
localAIService.isAvailable().then(setLlmAvailable)
@@ -274,11 +281,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
- isModalOpen,
- hasDescription,
- dueDate,
createChore,
+ dueDate,
handleCloseModal,
+ hasDescription,
+ isModalOpen,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -709,11 +716,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
createChore()
}
+ // The scan keeps its source image when asked: upload it against the draft so
+ // the server promotes it onto the chore the same way manual uploads are.
+ const attachScannedImage = async imageSource => {
+ // Creating the chore promotes whatever draft attachments exist at that
+ // moment, so Create waits on this upload rather than orphaning it.
+ setIsAttachingScan(true)
+ try {
+ const file = await imageSourceToFile(
+ imageSource,
+ `scan-${Date.now()}.jpg`,
+ )
+ if (!file) return
+ const uploaded = await uploadFile(file)
+ if (!uploaded) return
+ setAttachments(prev => [
+ ...prev,
+ { url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
+ ])
+ } finally {
+ setIsAttachingScan(false)
+ }
+ }
+
const handleTaskExtracted = ({
- taskName,
+ attachmentImage,
description: extractedDesc,
dueDate: extractedDue,
+ taskName,
}) => {
+ if (attachmentImage) {
+ attachScannedImage(attachmentImage)
+ }
if (taskName) {
processText(taskName)
}
@@ -801,6 +835,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
+ setIsAttachingScan(false)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -829,6 +864,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
const createChore = () => {
+ // A scanned attachment still uploading would be orphaned by the create
+ if (isAttachingScan) return
+
// Handle different assignee scenarios
let finalAssignees = assignees
let finalAssignedTo = null
@@ -988,7 +1026,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
+ }
+ >
+ Uploading…
+
+ ) : (
+
+ {canTakePhoto && (
+
+ ) : (
+
+ )
+ }
+ onClick={handleScan}
+ >
+ {isNativeScanner ? 'Scan' : 'Photo'}
+
+ )}
+ }
+ onClick={() => handlePickFile({ accept: 'image/*' })}
+ >
+ Image
+
+ }
+ onClick={() => handlePickFile()}
+ >
+ File
+
+
+ )}
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index c33e2c0..df093de 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -150,6 +150,9 @@ const NavBar = () => {
'/onboarding',
'/get-started',
'/ready',
+ // Reached from an invite link, often signed out: it owns its own shell
+ // and must not mount the avatar's authenticated queries.
+ '/circle/join',
].includes(location.pathname)
) {
return (
diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx
index dc2c27c..81ab9b6 100644
--- a/src/views/components/ScanToTask/ScanPanel.jsx
+++ b/src/views/components/ScanToTask/ScanPanel.jsx
@@ -8,11 +8,13 @@ import {
import {
Box,
Button,
+ Checkbox,
CircularProgress,
LinearProgress,
Typography,
} from '@mui/joy'
-import { useCallback, useEffect, useMemo } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
import { useScanToTask } from './useScanToTask'
/**
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
* belongs to the capture surface and drives a hidden input in this subtree.
*/
const ScanPanel = ({
- open,
- onTaskExtracted,
+ autoCapture,
+ canKeepImage = false,
+ initialImageUrl,
onClose,
onStateChange,
- initialImageUrl,
- autoCapture,
+ onTaskExtracted,
+ open,
}) => {
const {
- isNativeScanner,
- phase,
- capturedImage,
- ocrProgress,
- taskResult,
- errorMsg,
+ activate,
cameraAvailable,
- videoRef,
canvasRef,
- fileInputRef,
- startCamera,
- stopCamera,
capture,
+ capturedImage,
+ errorMsg,
+ fileInputRef,
handleFileSelect,
handleNativeScan,
- retake,
- activate,
+ isNativeScanner,
+ ocrProgress,
+ phase,
reset,
+ retake,
+ startCamera,
+ stopCamera,
+ taskResult,
+ videoRef,
} = useScanToTask()
+ // The scanned page is usually the task's source of truth (the bill, the
+ // notice), so keeping it is the default — the OCR text alone loses it.
+ const [keepImage, setKeepImage] = useState(false)
+
// Start/stop based on open state
useEffect(() => {
if (open) {
@@ -82,7 +89,10 @@ const ScanPanel = ({
// Auto-close and populate when done
useEffect(() => {
if (phase === 'done' && taskResult) {
- onTaskExtracted(taskResult)
+ onTaskExtracted({
+ ...taskResult,
+ attachmentImage: canKeepImage && keepImage ? capturedImage : null,
+ })
onClose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -137,6 +147,18 @@ const ScanPanel = ({
const isProcessing = phase === 'processing'
+ // Attachments are a Plus feature; without it the upload would only ever
+ // surface an upgrade error, so the choice isn't offered at all.
+ const keepImageToggle = !canKeepImage ? null : (
+ setKeepImage(e.target.checked)}
+ label='Keep photo as attachment'
+ sx={{ '--Checkbox-size': '18px' }}
+ />
+ )
+
return (
{/* ── Capture phase ── */}
@@ -201,16 +223,18 @@ const ScanPanel = ({
)}
- {/* Hidden when Upload is already the footer's primary action */}
- {(isNativeScanner || cameraAvailable) && (
-
+
+ {/* Hidden when Upload is already the footer's primary action */}
+ {(isNativeScanner || cameraAvailable) && (
Upload
-
- )}
+ )}
+ {keepImageToggle}
+
>
)}
@@ -285,6 +310,9 @@ const ScanPanel = ({
sx={{ width: '100%' }}
/>
)}
+
+ {/* Still editable here — the choice is only read once the task lands */}
+ {keepImageToggle}
)}