diff --git a/src/App.jsx b/src/App.jsx index 8bae287..ba2bc65 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() diff --git a/src/assets/logo.svg b/src/assets/logo.svg index f205a47..d350c38 100644 --- a/src/assets/logo.svg +++ b/src/assets/logo.svg @@ -1,1185 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/src/components/animations/PageTransition.jsx b/src/components/animations/PageTransition.jsx index 865d46c..bff11ed 100644 --- a/src/components/animations/PageTransition.jsx +++ b/src/components/animations/PageTransition.jsx @@ -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 diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 906a6fb..3c17b90 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -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: , }, + { + path: '/onboarding', + element: , + }, + { + path: '/get-started', + element: , + }, + { + path: '/ready', + element: , + }, + { + path: '/circle-setup', + element: , + }, + { + path: '/heard-about', + element: , + }, { path: '/auth/:provider', diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 49a8765..efb93c0 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -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() diff --git a/src/hooks/useOnboardingGate.js b/src/hooks/useOnboardingGate.js new file mode 100644 index 0000000..69a6a1d --- /dev/null +++ b/src/hooks/useOnboardingGate.js @@ -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 diff --git a/src/utils/Onboarding.js b/src/utils/Onboarding.js new file mode 100644 index 0000000..254af11 --- /dev/null +++ b/src/utils/Onboarding.js @@ -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 + } +} diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index f08a7f6..62e4344 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -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={} - logoSize={0} + logoSize={0} > { 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), diff --git a/src/views/Onboarding/CircleSetupView.jsx b/src/views/Onboarding/CircleSetupView.jsx new file mode 100644 index 0000000..96784c8 --- /dev/null +++ b/src/views/Onboarding/CircleSetupView.jsx @@ -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 }) => ( + *': { position: 'relative' }, + }} + > + + {icon} + + +) + +/** + * 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 ( + + + + {mode === 'invite' ? ( + + ) : ( + + } /> + + )} + + + + + {mode === 'invite' ? 'Bring your squad in' : 'Join a circle'} + + + {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."} + + + + {mode === 'invite' ? ( + + + + {inviteCode ?? 'Loading…'} + + + + + + + + } + disabled={!inviteCode} + onClick={copyLink} + > + Copy invite link instead + + + + + + + + + } + onClick={() => setMode('join')} + > + Join an existing circle instead + + + + ) : ( + + + + + + + + setMode('invite')} + > + Back + + + + )} + + + + + ) +} + +export default CircleSetupView diff --git a/src/views/Onboarding/GetStartedView.jsx b/src/views/Onboarding/GetStartedView.jsx new file mode 100644 index 0000000..9536a71 --- /dev/null +++ b/src/views/Onboarding/GetStartedView.jsx @@ -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 ( + + + + *': { position: 'relative' }, + }} + > + + + + Done + + tick + + + + {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.'} + + + + + {!signupDisabled && ( + + )} + + + + {isNativeApp() && ( + + } + onClick={() => navigate('/login/settings')} + > + Connect to a self-hosted server + + + )} + + + + + + + ) +} + +export default GetStartedView diff --git a/src/views/Onboarding/HeardAboutView.jsx b/src/views/Onboarding/HeardAboutView.jsx new file mode 100644 index 0000000..a1208b8 --- /dev/null +++ b/src/views/Onboarding/HeardAboutView.jsx @@ -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 }) => ( + + + {children} + + +) + +/** + * 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 ( + <> + + + Where'd you hear about Donetick? + + + Helps us know what's working. Totally optional. + + + + + {SOURCES.map(source => { + const active = selected === source + return ( + 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} + + {active && } + + + ) + })} + + {selected === OTHER && ( + + + )} + + + + + + + + + Skip + + + + ) +} + +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 ( + <> + + + Help us improve Donetick + + + Your server, your data. Both of these are off from this point unless + you turn them on. + + + + + {PRIVACY_TOGGLES.map(({ key, label, description }) => ( + + + + {label} + + + {description} + + + toggle(key)} + sx={{ flex: '0 0 auto', mt: 0.25 }} + /> + + ))} + + + + + + + ) +} + +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 + + return ( + + {isOfficial ? ( + + ) : ( + + )} + + ) +} + +export default HeardAboutView diff --git a/src/views/Onboarding/OnboardingView.jsx b/src/views/Onboarding/OnboardingView.jsx new file mode 100644 index 0000000..e7fdc98 --- /dev/null +++ b/src/views/Onboarding/OnboardingView.jsx @@ -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 }) => ( + + {Array.from({ length: count }, (_, index) => { + const active = index === activeIndex + return ( + 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' }, + }} + /> + ) + })} + +) + +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 ( + + + + + + Done + + tick + + + + + + + + + + {SLIDES.map((slide, index) => { + const active = index === activeIndex + const { Visual } = slide + return ( + + + {/* Remounting on activation replays the vignette's + entrance instead of it playing off-screen once. */} + + + + *': { animation: 'none' }, + }, + }} + > + + {slide.title} + + + {slide.body} + + + + ) + })} + + + + + + + {asksPermission ? ( + // The permission ask gets its own pair of choices: a system prompt + // is a decision, not a "Next". + <> + } + sx={authButtonSx} + > + Turn on reminders + + + + ) : ( + : null} + sx={authButtonSx} + > + {isLast ? 'Get started' : 'Next'} + + )} + + + ) +} + +export default OnboardingView diff --git a/src/views/Onboarding/OnboardingVignettes.jsx b/src/views/Onboarding/OnboardingVignettes.jsx new file mode 100644 index 0000000..7b99334 --- /dev/null +++ b/src/views/Onboarding/OnboardingVignettes.jsx @@ -0,0 +1,1569 @@ +import { + AddRounded, + CheckRounded, + ContactlessRounded, + HourglassEmptyRounded, + KeyboardRounded, + MicNoneRounded, + NotificationsActiveRounded, + PeopleAltRounded, + PhoneIphoneRounded, + PhotoCameraOutlined, + PlayArrowRounded, + Repeat, + SwitchAccessShortcutRounded, + ThumbDownRounded, + ThumbUpRounded, +} from '@mui/icons-material' +import { Box, Typography } from '@mui/joy' +import { + getPriorityColor, + getTextColorFromBackgroundColor, +} from '../../utils/Colors.jsx' + +/** + * Small living previews of the real product, one per onboarding slide. + * + * The task cards deliberately mirror ChoreCard's visual grammar (floating due + * date + frequency chips overlapping a radius-20 surface card, avatar initial, + * assignee chip) without using the component itself: ChoreCard pulls + * usePendingCommands and useUserProfile, and neither belongs on a pre-auth + * screen. + * + * Motion rule used throughout: the *base* style is always the finished state, + * and the keyframes describe how it got there. That way `prefers-reduced-motion` + * can switch every animation off and each vignette still reads correctly + * instead of collapsing to an invisible element. + */ + +const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)' +// One shared cycle length so the pills, the card and the caption of a vignette +// all change on the same beat. +const CYCLE_MS = 5400 + +const reducedMotion = { + '@media (prefers-reduced-motion: reduce)': { + '&, & *': { animation: 'none !important' }, + }, +} + +const cardSx = { + bgcolor: 'background.surface', + border: '1px solid', + borderColor: 'divider', + borderRadius: 20, + boxShadow: 'sm', +} + +const Stage = ({ children }) => ( + +) + +const Chip = ({ icon, children, color = 'primary', sx }) => ( + + {icon} + {children} + +) + +// Mirrors CompactChoreCard's solid, label-coloured chip (not the soft-tinted +// `Chip` above) so a MiniChoreCard can fake a real label instead of a generic tag. +const LabelChip = ({ label, color, sx }) => ( + + {label} + +) + +const getName = name => { + const split = Array.from(name) + // if the first character is emoji then remove it from the name + if (isNaN(Number(split[0])) && /\p{Emoji}/u.test(split[0])) { + return split.slice(1).join('').trim() + } + return name +} + +/** + * ChoreCard in miniature: the chips ride on top of the card's edge exactly as + * they do in the task list. + */ +const MiniChoreCard = ({ + title, + due, + dueColor = 'primary', + repeat, + label, + labelColor = '#5c6bc0', + footer, +}) => ( + + + {due} + {repeat && ( + }> + {repeat} + + )} + + + + + {Array.from(title)[0]} + + + + {getName(title)} + + {label && ( + + )} + {footer} + + + +) + +const AssigneeChip = ({ name, color = 'primary', sx }) => ( + + + {Array.from(name)[0]} + + {name} + +) + +// Stacks its children in one grid cell and cross-fades between them, so the +// container never resizes as the content changes. The keyframes are derived +// from the child count so a two-up cycler holds each state twice as long +// instead of leaving a third of the cycle blank. +const Cycler = ({ children, sx, cycleMs = CYCLE_MS }) => { + const items = Array.isArray(children) ? children : [children] + const count = items.length + const step = cycleMs / count + const hold = 100 / count + // Emotion emits inline @keyframes under the literal name, so each arity + // needs its own or the two definitions collide. + const name = `cycleSwap${count}` + + return ( + + {items.map((child, index) => ( + + {child} + + ))} + + ) +} + +/* ------------------------------------------- slide 1: three ways to capture */ + +// This vignette runs slower than the shared CYCLE_MS: each capture method +// gets a full 3s on screen so the input-to-task morph has room to read. +const CAPTURE_STEP_MS = 3000 +const CAPTURE_CYCLE_MS = CAPTURE_STEP_MS * 3 + +const SOURCES = [ + { icon: , label: 'Speak' }, + { icon: , label: 'Snap' }, + { icon: , label: 'Type' }, +] + +const SourcePill = ({ icon, label, index }) => ( + + {icon} + {label} + +) + +/** + * Each capture method gets its own caption rather than one blanket badge: the + * on-device claim is only unambiguously true for the photo path (native OCR + * plus a local model, nothing uploaded), while speech-to-text still goes + * through the OS speech recognizer. + */ +const CaptureVariant = ({ card, caption, icon }) => ( + + {card} + + {caption} + + +) + +// Crossfades a "capturing" moment into the task it produces. Both layers sit +// in the same grid cell on one shared clock (delay = this variant's slot in +// the outer Cycler) so the morph always lands while the variant is on screen: +// ~0-38% holds the raw input, ~46-60% is the handoff, the rest holds the card. +const CaptureMorph = ({ before, after, delay = 0 }) => ( + + + {before} + + + {after} + + +) + +const WAVE_BARS = [10, 18, 24, 14, 20, 11, 16] + +/** The "before": a live waveform standing in for on-device speech capture. */ +const VoiceWave = ({ delay = 0 }) => ( + + + + {WAVE_BARS.map((height, index) => ( + + ))} + + + Listening… + + +) + +const CORNER_MARKS = [ + { top: 6, left: 6, borderWidth: '2px 0 0 2px' }, + { top: 6, right: 6, borderWidth: '2px 2px 0 0' }, + { bottom: 6, left: 6, borderWidth: '0 0 2px 2px' }, + { bottom: 6, right: 6, borderWidth: '0 2px 2px 0' }, +] + +/** The "before": a viewfinder with a shutter flash timed to the handoff. */ +const CameraFrame = ({ delay = 0 }) => ( + + {CORNER_MARKS.map((mark, index) => ( + + ))} + + + +) + +const TYPE_TEXT = 'change ac filter friday @ryan' + +/** The "before": a live-typed line, revealed a character at a time. */ +const TypingField = ({ delay = 0 }) => ( + + + + + + {TYPE_TEXT} + + + + + +) + +export const CaptureVignette = () => ( + + + {SOURCES.map((source, index) => ( + + ))} + + + + } + caption='Dates, labels and points from your words' + card={ + } + after={ + + } + /> + } + /> + } + caption='Read on your device' + card={ + } + after={ + + + + } + /> + } + /> + } + /> + } + caption='#labels @people *points as you type' + card={ + } + after={ + + + + } + /> + } + /> + } + /> + + +) + +/* --------------------------------- slide 2: due date vs. completion date */ + +const Milestone = ({ label, date, tone = 'neutral', delay }) => ( + + + {tone === 'done' && } + + + + {label} + + + {date} + + + +) + +const ScheduleTrack = ({ mode, next, delay }) => ( + + + {mode} + + + + {/* Connector sits behind the milestones and draws itself first. */} + + + + + + + + +) + +export const ScheduleVignette = () => ( + + + + +) + +/* ------------------------- slide 3: tap to finish, note, logged in history */ + +/** + * A row from the task history, mirroring HistoryCard: coloured left rule, + * status avatar and label, the note as a soft inline card, then the performer + * chip and meta strip. + */ +const HistoryRow = ({ + status, + color, + icon, + note, + meta, + performer, + divider = true, +}) => ( + + + + + {icon} + + + {status} + + + + {note && ( + + + {note} + + + )} + + + {performer && } + + {meta} + + + + +) + +/** + * The action row from ChoreView in miniature. Which buttons show depends on + * the task's state there β€” done/skip/start normally, approve/reject when it is + * waiting on a manager β€” so the vignette cycles between the two rather than + * lining all five up in a row that never exists in the app. + */ +const ActionButton = ({ + icon, + label, + color = 'neutral', + variant, + flex = 1, +}) => ( + + {icon} + {label} + +) + +const ActionState = ({ children }) => ( + + {children} + +) + +const ActionRow = ({ children }) => ( + {children} +) + +// Off the deck as of the five-slide cut β€” NFC now rides along in the "nobody +// has to be the nag" copy. Kept intact so it can be swapped back in as its own +// slide without rebuilding it. +export const NfcVignette = () => ( + + + + {/* Two rings leaving the tag: the phone reading it. */} + {[0, 1].map(ring => ( + + ))} + + + + + + Tag on the washer + + + Tap to open + + + + + {/* The task the tag opens, with the action row the app actually shows. */} + + + Swap the washer filter + + + + + + } + label='Done' + /> + } + label='Skip' + /> + + } + label='Start timer' + /> + + + + }> + Amalie marked it done Β· waiting on you + + + } + label='Approve' + /> + } + label='Reject' + /> + + + + + + {/* A tighter radius than the task cards: at radius 20 the corner clips + the status rule and it reads as a rendering slip. */} + + {/* grid-template-rows animates the new entry open, pushing the older + one down the way the real list does β€” no height thrash. */} + + + } + performer='Mo' + note='Used the delicate cycle β€” filter needs a clean next time.' + meta='Just now Β· β˜… 5 pts' + /> + + + + } + performer='Amalie' + meta='Last week Β· β˜… 5 pts' + /> + + +) + +/* ----------------------------------------------- slide 4: share the load */ + +const MEMBERS = [ + { initial: 'M', name: 'Mo', color: 'primary' }, + { initial: 'A', name: 'Amalie', color: 'success' }, + { initial: 'S', name: 'Sam', color: 'warning' }, +] + +const AVATAR = 40 +const AVATAR_GAP = 12 +const STEP = AVATAR + AVATAR_GAP + +export const CircleVignette = () => ( + + + {MEMBERS.map(member => ( + + ))} + + } + /> + + + + {/* Selection ring hopping between members: the rotating assignee. */} + + {MEMBERS.map(member => ( + + {member.initial} + + ))} + + + } color='neutral'> + Takes turns + + + +) + +// One member's turn on the task, and one at a time. Slower than the shared +// CYCLE_MS so each hand-off has room to read. +const NAG_STEP_MS = 3000 +const NAG_CYCLE_MS = NAG_STEP_MS * MEMBERS.length + +// The history entry shown during any given member's turn is the *previous* +// member's completion β€” that's the hand-off that put the task on the current +// person's plate β€” so this is MEMBERS rotated back by one. +const NAG_HISTORY_ORDER = [MEMBERS[MEMBERS.length - 1], ...MEMBERS.slice(0, -1)] + +// Each entry cycles through the same three depths β€” front, middle, back β€” +// then drops out just before its next lap re-enters at the front. With +// exactly one entry per member, "back" doubles as the cap on how many stay +// visible: a 4th completion would simply be this same motion continuing. +const historyStackKeyframes = { + '0%': { opacity: 0, transform: 'translateY(-14px) scale(0.94)', zIndex: 3 }, + '6%': { opacity: 1, transform: 'translateY(0) scale(1)', zIndex: 3 }, + '27%': { opacity: 1, transform: 'translateY(0) scale(1)', zIndex: 3 }, + '33%': { + opacity: 0.75, + transform: 'translateY(10px) scale(0.94)', + zIndex: 2, + }, + '54%': { + opacity: 0.75, + transform: 'translateY(10px) scale(0.94)', + zIndex: 2, + }, + '60%': { + opacity: 0.45, + transform: 'translateY(20px) scale(0.88)', + zIndex: 1, + }, + '88%': { + opacity: 0.45, + transform: 'translateY(20px) scale(0.88)', + zIndex: 1, + }, + '100%': { opacity: 0, transform: 'translateY(30px) scale(0.82)', zIndex: 1 }, +} + +/** + * A tighter cut of "share the load": just the task and whose turn it is. + * The assignee chip and the history stack ride the same clock, offset by one + * member, so each hand-off both moves the chip and drops that member's + * completion onto the top of the stack β€” pushing the older ones back and + * capping out at three before the oldest cycles away. + */ +export const TakesTurnsVignette = () => ( + + + {MEMBERS.map(member => ( + + ))} + + } + /> + + + {NAG_HISTORY_ORDER.map((member, index) => ( + + } + performer={member.name} + meta='Just now Β· β˜… 5 pts' + divider={false} + /> + + ))} + + +) + +/* ------------------------------------- slide 5: widgets on the home screen */ + +// Home-screen widgets read as a separate material from in-app cards: rounder +// corners, a heavier shadow, no relationship to the page's own surfaces. +const widgetSx = { + ...cardSx, + borderRadius: 18, + boxShadow: 'md', + p: 1.5, +} + +const float = (duration, delay) => ({ + animation: `widgetFloat ${duration}ms ease-in-out ${delay}ms infinite alternate`, + '@keyframes widgetFloat': { + from: { transform: 'translateY(4px)' }, + to: { transform: 'translateY(-6px)' }, + }, +}) + +const WidgetTask = ({ name, time, done }) => ( + + + {done && } + + + {name} + + + {time} + + +) + +const TodayWidget = () => ( + + + + Today + + 3 left + + + + + + + + + + + +) + +/* ------------------- notification banners, stacked over the Today widget */ + +const NotificationBanner = ({ + icon, + color = 'primary', + title, + body, + delay, +}) => ( + + + {icon} + + + + {title} + + + {body} + + + +) + +/** + * Retention in one picture: the two nudges that bring people back (a reminder + * and a circle update) arriving over the home-screen widget they'll be glancing + * at all day. + */ +export const RemindersVignette = () => ( + + } + title='Take out the trash' + body='Due in 30 minutes Β· Bin night' + delay={160} + /> + } + title='Amalie finished Kitchen deep clean' + body='Your turn is next Saturday' + delay={520} + /> + + +) + +/* -------------------------------------------- slide 1: the problem itself */ + +// The mess in your head, in the order it usually arrives: each item flies in +// from its own angle and lands in a tidy column. Base style is the landed +// state, so with reduced motion it's simply a neat list. +// +// Each row now mirrors CompactChoreCard's own grammar: a priority bar on the +// leading edge, an (unchecked, decorative) complete button, a frequency line, +// and a trailing label chip β€” the same signals, just in a self-contained card +// instead of a full-width list row. +const LITTLE_THINGS = [ + { + label: 'Water bill', + priority: 1, + frequency: 'Monthly', + tag: 'Bills', + tagColor: '#5c6bc0', + from: '-28px, 18px, -6deg', + }, + { + label: 'AC filter', + priority: 2, + frequency: 'Every 3 months', + tag: 'Home', + tagColor: '#26a69a', + from: '30px, 22px, 5deg', + }, + { + label: 'Trash day', + priority: 2, + frequency: 'Every Monday', + tag: 'Home', + tagColor: '#26a69a', + from: '-34px, 26px, -4deg', + }, + { + label: "Dog's medicine", + priority: 1, + frequency: 'Daily', + tag: 'Pets', + tagColor: '#ec407a', + from: '26px, 30px, 6deg', + }, + { + label: 'Whose turn to cook', + priority: 3, + frequency: 'Weekly', + tag: 'Cooking', + tagColor: '#66bb6a', + from: '-22px, 34px, -5deg', + }, +] + +export const ProblemVignette = () => ( + + + {LITTLE_THINGS.map((thing, index) => ( + + + + + + {thing.label} + + + + + {thing.frequency} + + + + + + {thing.tag} + + + ))} + + +) diff --git a/src/views/Onboarding/WorkspaceReadyView.jsx b/src/views/Onboarding/WorkspaceReadyView.jsx new file mode 100644 index 0000000..8533417 --- /dev/null +++ b/src/views/Onboarding/WorkspaceReadyView.jsx @@ -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 ( + + + + *': { position: 'relative' }, + }} + > + + + + {userProfile?.displayName + ? `You're all set, ${userProfile.displayName.split(' ')[0]}` + : "You're all set"} + + + Your workspace is ready. Here's what's waiting inside. + + + + + {READY.map((item, index) => ( + + + + + + {item} + + + ))} + + + + + + + + ) +} + +export default WorkspaceReadyView diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 0539b2a..c33e2c0 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -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') } > @@ -145,6 +147,9 @@ const NavBar = () => { '/password/update', '/login/settings', '/welcome', + '/onboarding', + '/get-started', + '/ready', ].includes(location.pathname) ) { return ( diff --git a/vite.config.mjs b/vite.config.mjs index 5d6d70f..524cc03 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -3,10 +3,15 @@ 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({ @@ -84,4 +89,4 @@ export default defineConfig({ }, ], }, -}) +}))