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 = () => { ))} )} - + await uploadAttachmentFile(file) + }} + /> + + {isNativeScanner && ( + + )} + 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?'} +