diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6cebead6e..2fbeaa26e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -35,7 +35,7 @@ "fs-extra": "^11.3.4", "i18next": "^24.2.0", "lucide-react-native": "^0.475.0", - "react": "^19.1.0", + "react": "19.1.0", "react-hook-form": "^7.54.0", "react-i18next": "^15.4.0", "react-native": "0.81.5", diff --git a/apps/web/__tests__/stores/chat-store.test.ts b/apps/web/__tests__/stores/chat-store.test.ts index 7aad69a46..2c6d799e6 100644 --- a/apps/web/__tests__/stores/chat-store.test.ts +++ b/apps/web/__tests__/stores/chat-store.test.ts @@ -7,7 +7,6 @@ describe('chat store', () => { useChatStore.setState({ messages: [], isTyping: false, - isStreaming: false, }) }) @@ -31,10 +30,9 @@ describe('chat store', () => { expect(state.messages).toEqual([]) }) - it('starts with typing and streaming false', () => { + it('starts with typing false', () => { const state = useChatStore.getState() expect(state.isTyping).toBe(false) - expect(state.isStreaming).toBe(false) }) }) @@ -122,25 +120,6 @@ describe('chat store', () => { }) }) - // ------------------------------------------------------------------------- - // setIsStreaming - // ------------------------------------------------------------------------- - - describe('setIsStreaming', () => { - it('sets streaming to true', () => { - const { setIsStreaming } = useChatStore.getState() - setIsStreaming(true) - expect(useChatStore.getState().isStreaming).toBe(true) - }) - - it('sets streaming to false', () => { - useChatStore.setState({ isStreaming: true }) - const { setIsStreaming } = useChatStore.getState() - setIsStreaming(false) - expect(useChatStore.getState().isStreaming).toBe(false) - }) - }) - // ------------------------------------------------------------------------- // clearMessages // ------------------------------------------------------------------------- @@ -162,12 +141,5 @@ describe('chat store', () => { clearMessages() expect(useChatStore.getState().isTyping).toBe(false) }) - - it('resets streaming state', () => { - useChatStore.setState({ isStreaming: true }) - const { clearMessages } = useChatStore.getState() - clearMessages() - expect(useChatStore.getState().isStreaming).toBe(false) - }) }) }) diff --git a/apps/web/app/(app)/calendar-sync/page.tsx b/apps/web/app/(app)/calendar-sync/page.tsx index 6104b693e..cf420adae 100644 --- a/apps/web/app/(app)/calendar-sync/page.tsx +++ b/apps/web/app/(app)/calendar-sync/page.tsx @@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl' import { plural } from '@/lib/plural' import { useProfile, useHasProAccess } from '@/hooks/use-profile' import { useBulkCreateHabits } from '@/hooks/use-habits' +import { getSupabaseClient } from '@/lib/supabase' import { API } from '@orbit/shared/api' import { getErrorMessage } from '@orbit/shared/utils' import type { FrequencyUnit } from '@orbit/shared/types/habit' @@ -215,8 +216,20 @@ export default function CalendarSyncPage() { } async function connectGoogle() { - // Redirect to auth flow with Google Calendar scope - window.location.href = '/api/auth/google?scope=calendar' + const supabase = getSupabaseClient() + const redirectTo = `${window.location.origin}/auth-callback` + sessionStorage.setItem('auth_return_url', '/calendar-sync') + + await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo, + scopes: 'https://www.googleapis.com/auth/calendar.readonly', + queryParams: { + access_type: 'offline', + }, + }, + }) } return ( diff --git a/apps/web/app/(app)/calendar/page.tsx b/apps/web/app/(app)/calendar/page.tsx index 0e2923523..c885b17ec 100644 --- a/apps/web/app/(app)/calendar/page.tsx +++ b/apps/web/app/(app)/calendar/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useRef } from 'react' import { addMonths, subMonths, startOfMonth, format } from 'date-fns' import { enUS, ptBR } from 'date-fns/locale' import { ChevronLeft, ChevronRight, Search } from 'lucide-react' @@ -9,6 +9,8 @@ import { useCalendarData } from '@/hooks/use-calendar-data' import { CalendarGrid } from '@/components/calendar/calendar-grid' import { CalendarDayDetail } from '@/components/calendar/calendar-day-detail' +const SWIPE_THRESHOLD = 50 + export default function CalendarPage() { const t = useTranslations() const locale = useLocale() @@ -43,8 +45,33 @@ export default function CalendarPage() { return dayMap.get(selectedDay) ?? [] }, [selectedDay, dayMap]) + // Swipe navigation + const touchStartX = useRef(null) + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) touchStartX.current = touch.clientX + }, []) + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + if (touchStartX.current === null) return + const touch = e.changedTouches[0] + if (!touch) return + const deltaX = touch.clientX - touchStartX.current + touchStartX.current = null + if (Math.abs(deltaX) < SWIPE_THRESHOLD) return + if (deltaX < 0) { + setCurrentMonth((m) => addMonths(m, 1)) + } else { + setCurrentMonth((m) => subMonths(m, 1)) + } + }, []) + return ( -
+
{/* Header */}
@@ -100,9 +127,11 @@ export default function CalendarPage() { )} {/* Refetch loading bar */} - {isFetching && !isLoading && ( -
- )} +
{/* Calendar grid */} {(!isLoading || isFetching) && ( diff --git a/apps/web/app/(app)/error.tsx b/apps/web/app/(app)/error.tsx new file mode 100644 index 000000000..4f05e5a00 --- /dev/null +++ b/apps/web/app/(app)/error.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useTranslations } from 'next-intl' + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations() + + useEffect(() => { + // Log error for debugging (server-side only via digest) + }, [error]) + + return ( +
+ +

+ {error.message || t('auth.genericError')} +

+ +
+ ) +} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 08fe763c2..fc4764353 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,41 +1,238 @@ 'use client' +import { useState, useEffect, useCallback, useRef } from 'react' +import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' +import { CalendarDays } from 'lucide-react' import { Providers } from '@/lib/providers' import { BottomNav } from '@/components/navigation/bottom-nav' import { TrialBanner } from '@/components/ui/trial-banner' +import { TrialExpiredModal } from '@/components/ui/trial-expired-modal' +import { PushPrompt } from '@/components/ui/push-prompt' +import { AppOverlay } from '@/components/ui/app-overlay' +import { OnboardingFlow } from '@/components/onboarding/onboarding-flow' +import { StreakCelebration } from '@/components/gamification/streak-celebration' +import { AllDoneCelebration } from '@/components/gamification/all-done-celebration' +import { GoalCompletedCelebration } from '@/components/gamification/goal-completed-celebration' +import { WelcomeBackToast } from '@/components/gamification/welcome-back-toast' +import { AchievementToast } from '@/components/gamification/achievement-toast' +import { LevelUpOverlay } from '@/components/gamification/level-up-overlay' +import { StreakFreezeCelebration } from '@/components/gamification/streak-freeze-celebration' +import { useProfile, useHasProAccess } from '@/hooks/use-profile' +import { useTotalHabitCount } from '@/hooks/use-habits' +import { useGamificationProfile } from '@/hooks/use-gamification' import { useUIStore } from '@/stores/ui-store' +import { getSupabaseClient } from '@/lib/supabase' +import { dismissCalendarImport } from '@/app/actions/profile' export default function AppLayout({ children, }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function AppLayoutContent({ children }: { children: React.ReactNode }) { const t = useTranslations() + const router = useRouter() + const { profile } = useProfile() + const hasProAccess = useHasProAccess() + const totalHabitCount = useTotalHabitCount() + const gamification = useGamificationProfile() + + const activeView = useUIStore((s) => s.activeView) const setShowCreateModal = useUIStore((s) => s.setShowCreateModal) + const setShowCreateGoalModal = useUIStore((s) => s.setShowCreateGoalModal) + + // Streak freeze ref + const streakFreezeRef = useRef<{ show: () => void }>(null) + + // Google Calendar import prompt state + const [showCalendarPrompt, setShowCalendarPrompt] = useState(false) + + useEffect(() => { + if ( + profile && + profile.hasCompletedOnboarding && + !profile.hasImportedCalendar + ) { + setShowCalendarPrompt(true) + } + }, [profile]) + + // --------------------------------------------------------------------------- + // handleCreate -- mirrors Nuxt default.vue logic + // --------------------------------------------------------------------------- + const handleCreate = useCallback(() => { + if (activeView === 'goals') { + setShowCreateGoalModal(true) + return + } + if (!hasProAccess && totalHabitCount >= 10) { + router.push('/upgrade') + return + } + setShowCreateModal(true) + }, [activeView, hasProAccess, totalHabitCount, router, setShowCreateModal, setShowCreateGoalModal]) + + // --------------------------------------------------------------------------- + // Calendar import prompt handlers + // --------------------------------------------------------------------------- + const handleDismissCalendarPrompt = useCallback(() => { + setShowCalendarPrompt(false) + dismissCalendarImport().catch(() => {}) + }, []) + + const handleCalendarImport = useCallback(async () => { + setShowCalendarPrompt(false) + dismissCalendarImport().catch(() => {}) + + if (profile?.hasGoogleConnection) { + router.push('/calendar-sync') + return + } + + // No Google tokens yet -- trigger OAuth, redirect to calendar-sync after + const supabase = getSupabaseClient() + const redirectTo = `${globalThis.location.origin}/auth-callback` + sessionStorage.setItem('auth_return_url', '/calendar-sync') + + await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo, + scopes: 'https://www.googleapis.com/auth/calendar.readonly', + queryParams: { access_type: 'offline' }, + }, + }) + }, [profile?.hasGoogleConnection, router]) + + // Track when calendar prompt is closed via overlay X button + const handleCalendarPromptOpenChange = useCallback( + (open: boolean) => { + if (!open && showCalendarPrompt) { + handleDismissCalendarPrompt() + } + }, + [showCalendarPrompt, handleDismissCalendarPrompt], + ) + + // --------------------------------------------------------------------------- + // Gamification clear handlers + // --------------------------------------------------------------------------- + const clearLevelUp = useCallback(() => { + // Level-up is tracked via ref diff in the hook -- no explicit clear needed + }, []) return ( - -
- - {t('nav.skipToContent')} - - - {/* Main content - full width mobile, max-w on desktop */} -
- - {children} -
- - {/* Bottom navigation */} - setShowCreateModal(true)} /> -
-
+
+ + {t('nav.skipToContent')} + + + {/* Main content - full width mobile, max-w on desktop */} +
+ +
{children}
+
+ + {/* Bottom navigation */} + + + +
+ ) +} + +// Extracted to its own component so conditional children don't trigger React key warnings +// in the parent AppLayoutContent +function GlobalOverlays({ + profile, + hasProAccess, + gamification, + streakFreezeRef, + showCalendarPrompt, + onCalendarPromptOpenChange, + onCalendarImport, + onDismissCalendarPrompt, +}: { + profile: ReturnType['profile'] + hasProAccess: boolean + gamification: ReturnType + streakFreezeRef: React.RefObject<{ show: () => void } | null> + showCalendarPrompt: boolean + onCalendarPromptOpenChange: (open: boolean) => void + onCalendarImport: () => void + onDismissCalendarPrompt: () => void +}) { + const t = useTranslations() + + return ( +
+ + {profile?.hasCompletedOnboarding && } + {profile && !profile.hasCompletedOnboarding && } + + + + + {hasProAccess && } + {hasProAccess && ( + {}} + /> + )} + + +
+
+ +
+

+ {t('onboarding.wizard.calendarDescription')} +

+
+ + +
+
+
+
) } diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 1fcb7cb15..dcb5cbbaa 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' +import { useSearchParams } from 'next/navigation' import { addDays, subDays, @@ -29,8 +30,10 @@ import { Trash2, } from 'lucide-react' import { useTranslations, useLocale } from 'next-intl' +import { useQueryClient } from '@tanstack/react-query' +import { habitKeys } from '@orbit/shared/query' import { plural } from '@/lib/plural' -import { HabitList } from '@/components/habits/habit-list' +import { HabitList, type HabitListHandle } from '@/components/habits/habit-list' import { HabitSummaryCard } from '@/components/habits/habit-summary-card' import { CreateHabitModal } from '@/components/habits/create-habit-modal' import { GoalsView } from '@/components/goals/goals-view' @@ -41,7 +44,7 @@ import { NotificationBell } from '@/components/navigation/notification-bell' import { useUIStore } from '@/stores/ui-store' import { useProfile } from '@/hooks/use-profile' import { useStreakInfo } from '@/hooks/use-gamification' -import { useBulkDeleteHabits, useBulkLogHabits, useBulkSkipHabits } from '@/hooks/use-habits' +import { useHabits, useBulkDeleteHabits, useBulkLogHabits, useBulkSkipHabits } from '@/hooks/use-habits' import { useTags } from '@/hooks/use-tags' import { formatAPIDate } from '@orbit/shared/utils' import type { HabitsFilter } from '@orbit/shared/types/habit' @@ -59,6 +62,8 @@ export default function TodayPage() { const t = useTranslations() const locale = useLocale() const dateFnsLocale = locale === 'pt-BR' ? ptBR : enUS + const searchParams = useSearchParams() + const queryClient = useQueryClient() const { profile } = useProfile() const { data: streakInfo } = useStreakInfo() const { tags } = useTags() @@ -84,7 +89,8 @@ export default function TodayPage() { const isSelectMode = useUIStore((s) => s.isSelectMode) const selectedHabitIds = useUIStore((s) => s.selectedHabitIds) const toggleSelectMode = useUIStore((s) => s.toggleSelectMode) - const toggleHabitSelection = useUIStore((s) => s.toggleHabitSelection) + const toggleSelectionCascade = useUIStore((s) => s.toggleSelectionCascade) + const selectAllHabits = useUIStore((s) => s.selectAllHabits) const clearSelection = useUIStore((s) => s.clearSelection) // Create modal (shared with layout's BottomNav via store) @@ -101,22 +107,32 @@ export default function TodayPage() { const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [showBulkLogConfirm, setShowBulkLogConfirm] = useState(false) const [showBulkSkipConfirm, setShowBulkSkipConfirm] = useState(false) - const [allCollapsed, setAllCollapsed] = useState(false) const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('right') const searchDebounceTimer = useRef | null>(null) const controlsMenuRef = useRef(null) const controlsMenuPanelRef = useRef(null) - const habitListRef = useRef<{ - collapseAll: () => void - expandAll: () => void - allCollapsed: boolean - markRecentlyCompleted: (habitId: string) => void - checkAndPromptParentLog: (childHabitId: string) => void - } | null>(null) + const habitListRef = useRef(null) const CONTROLS_MENU_WIDTH_PX = 200 const CONTROLS_MENU_MARGIN_PX = 8 + // ?date= query parameter handling + const dateParam = searchParams.get('date') + const initialDateStr = useMemo(() => { + if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) return dateParam + return null + }, [dateParam]) + + // Initialize selectedDate from URL ?date= param on mount + useEffect(() => { + if (initialDateStr) { + setSelectedDate(initialDateStr) + setActiveView('today') + } + // Only run on mount / when dateParam changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialDateStr]) + const selectedDate = useMemo( () => new Date(selectedDateStr + 'T00:00:00'), [selectedDateStr], @@ -253,6 +269,46 @@ export default function TodayPage() { return f }, [activeView, selectedDate, searchQueryStore, selectedFrequency, selectedTagIds, showGeneralOnToday]) + // Query habits for selection cascade helpers and count + const habitsQuery = useHabits(filters) + const habitsById = habitsQuery.data?.habitsById ?? new Map() + const childrenByParent = habitsQuery.data?.childrenByParent ?? new Map() + const habitsCount = habitsById.size + const hasFetched = habitsQuery.dataUpdatedAt > 0 + const isRefetching = habitsQuery.isFetching && hasFetched + + // Selection cascade helpers (matches Nuxt getDescendantIds / isAncestorSelected) + const getDescendantIds = useCallback( + (parentId: string): string[] => { + const childIds = childrenByParent.get(parentId) ?? [] + const loaded = habitListRef.current?.allLoadedIds + const ids: string[] = [] + for (const cid of childIds) { + if (loaded && !loaded.has(cid)) continue + ids.push(cid, ...getDescendantIds(cid)) + } + return ids + }, + [childrenByParent], + ) + + const isAncestorSelected = useCallback( + (habitId: string): boolean => { + const habit = habitsById.get(habitId) + if (!habit?.parentId) return false + if (selectedHabitIds.has(habit.parentId)) return true + return isAncestorSelected(habit.parentId) + }, + [habitsById, selectedHabitIds], + ) + + const handleToggleSelection = useCallback( + (habitId: string) => { + toggleSelectionCascade(habitId, getDescendantIds, isAncestorSelected) + }, + [toggleSelectionCascade, getDescendantIds, isAncestorSelected], + ) + // Tab keyboard navigation const handleTabKeydown = useCallback( (e: React.KeyboardEvent) => { @@ -287,10 +343,12 @@ export default function TodayPage() { }, []) // Select all / deselect all - const allSelected = selectedHabitIds.size > 0 + const allSelected = habitsCount > 0 && selectedHabitIds.size === habitsCount const selectAll = useCallback(() => { - // Toggle all currently visible -- store manages - }, []) + const loaded = habitListRef.current?.allLoadedIds + const allIds = loaded ? Array.from(loaded) : Array.from(habitsById.keys()) + selectAllHabits(allIds) + }, [habitsById, selectAllHabits]) const deselectAll = useCallback(() => { clearSelection() }, [clearSelection]) @@ -313,7 +371,16 @@ export default function TodayPage() { const ids = Array.from(selectedHabitIds) if (ids.length === 0) return try { - await bulkLog.mutateAsync(ids.map((id) => ({ habitId: id }))) + const result = await bulkLog.mutateAsync(ids.map((id) => ({ habitId: id }))) + const successIds = result.results + .filter((r) => r.status === 'Success') + .map((r) => r.habitId) + for (const id of successIds) { + habitListRef.current?.markRecentlyCompleted(id) + } + for (const id of successIds) { + habitListRef.current?.checkAndPromptParentLog(id) + } } catch { // Error handled in hook } finally { @@ -326,7 +393,16 @@ export default function TodayPage() { const ids = Array.from(selectedHabitIds) if (ids.length === 0) return try { - await bulkSkip.mutateAsync(ids.map((id) => ({ habitId: id }))) + const result = await bulkSkip.mutateAsync(ids.map((id) => ({ habitId: id }))) + const successIds = result.results + .filter((r) => r.status === 'Success') + .map((r) => r.habitId) + for (const id of successIds) { + habitListRef.current?.markRecentlyCompleted(id) + } + for (const id of successIds) { + habitListRef.current?.checkAndPromptParentLog(id) + } } catch { // Error handled in hook } finally { @@ -595,31 +671,32 @@ export default function TodayPage() { + {open && ( +
+

{text}

+
+ )} +
+ ) +} + export default function UpgradePage() { const t = useTranslations() const locale = useLocale() @@ -702,12 +735,7 @@ export default function UpgradePage() {
{t(`upgrade.features.${feat.key}.label`)} - +
{/* Free value */} diff --git a/apps/web/app/(auth)/auth-callback/page.tsx b/apps/web/app/(auth)/auth-callback/page.tsx index 546085b3e..6c1069333 100644 --- a/apps/web/app/(auth)/auth-callback/page.tsx +++ b/apps/web/app/(auth)/auth-callback/page.tsx @@ -2,8 +2,10 @@ import { useEffect, useState, useRef } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { useTranslations } from 'next-intl' +import Link from 'next/link' +import { useTranslations, useLocale } from 'next-intl' import { useAuthStore } from '@/stores/auth-store' +import { getSupabaseClient } from '@/lib/supabase' import type { LoginResponse } from '@orbit/shared/types/auth' function getCookieValue(name: string): string | undefined { @@ -15,112 +17,65 @@ function getCookieValue(name: string): string | undefined { export default function AuthCallbackPage() { const t = useTranslations() + const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() - const { setAuth, isAuthenticated } = useAuthStore() + const { setAuth } = useAuthStore() const [errorMessage, setErrorMessage] = useState(null) const processedRef = useRef(false) + const isAuthenticatedRef = useRef(false) + const errorMessageRef = useRef(null) useEffect(() => { if (processedRef.current) return processedRef.current = true - async function handleCallback() { - // Extract provider tokens from URL hash (Supabase implicit flow) - // and from query params (fallback) - let providerToken: string | undefined - let providerRefreshToken: string | undefined - let accessToken: string | undefined - - if (window.location.hash) { - const hashParams = new URLSearchParams(window.location.hash.substring(1)) - providerToken = hashParams.get('provider_token') ?? undefined - providerRefreshToken = hashParams.get('provider_refresh_token') ?? undefined - accessToken = hashParams.get('access_token') ?? undefined - } + // Extract provider tokens before Supabase client consumes them. + // Web: tokens are in the URL hash (implicit flow). + let extractedProviderToken: string | undefined + let extractedProviderRefreshToken: string | undefined - // Also check query params as fallback - providerToken ??= searchParams.get('provider_token') ?? undefined - providerRefreshToken ??= searchParams.get('provider_refresh_token') ?? undefined - accessToken ??= searchParams.get('access_token') ?? undefined + if (window.location.hash) { + const hashParams = new URLSearchParams(window.location.hash.substring(1)) + extractedProviderToken = hashParams.get('provider_token') ?? undefined + extractedProviderRefreshToken = hashParams.get('provider_refresh_token') ?? undefined + } + const query = new URLSearchParams(window.location.search) + extractedProviderToken ??= query.get('provider_token') ?? undefined + extractedProviderRefreshToken ??= query.get('provider_refresh_token') ?? undefined - // Supabase PKCE flow: code is in query params, exchange via Supabase client - const code = searchParams.get('code') + const supabase = getSupabaseClient() - if (!accessToken && !code) { - // No tokens at all -- something went wrong - setErrorMessage(t('auth.callbackError')) - return - } + const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { + if (event !== 'SIGNED_IN' && event !== 'INITIAL_SESSION') return + if (!session) return - // If we have a code but no access_token, we need to exchange it. - // For PKCE flow, exchange code for session via Supabase. - if (code && !accessToken) { - try { - // Exchange code for access token via Supabase REST API - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - - if (!supabaseUrl || !supabaseKey) { - setErrorMessage(t('auth.callbackError')) - return - } - - const tokenResponse = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=pkce`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': supabaseKey, - }, - body: JSON.stringify({ - auth_code: code, - code_verifier: sessionStorage.getItem('supabase-code-verifier') ?? '', - }), - }) - - if (tokenResponse.ok) { - const tokenData = await tokenResponse.json() - accessToken = tokenData.access_token - providerToken ??= tokenData.provider_token - providerRefreshToken ??= tokenData.provider_refresh_token - } else { - setErrorMessage(t('auth.callbackError')) - return - } - } catch { - setErrorMessage(t('auth.callbackError')) - return - } - } - - if (!accessToken) { - setErrorMessage(t('auth.callbackError')) - return - } - - // Exchange Supabase token for our app token via BFF - const referralCode = getCookieValue('referral_code') + subscription.unsubscribe() try { + const referralCode = getCookieValue('referral_code') + const response = await fetch('/api/auth/google', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - accessToken, - language: 'en', - googleAccessToken: providerToken, - googleRefreshToken: providerRefreshToken, + accessToken: session.access_token, + language: locale, + googleAccessToken: extractedProviderToken ?? session.provider_token ?? undefined, + googleRefreshToken: extractedProviderRefreshToken ?? session.provider_refresh_token ?? undefined, ...(referralCode ? { referralCode } : {}), }), }) if (!response.ok) { setErrorMessage(t('auth.callbackError')) + errorMessageRef.current = t('auth.callbackError') return } const loginResponse = (await response.json()) as LoginResponse setAuth(loginResponse) + isAuthenticatedRef.current = true // Handle referral if (referralCode) { @@ -139,14 +94,14 @@ export default function AuthCallbackPage() { router.push(safeUrl) } catch { setErrorMessage(t('auth.callbackError')) + errorMessageRef.current = t('auth.callbackError') } - } - - handleCallback() + }) - // 15s timeout for OAuth callback + // 15s timeout -- uses refs to avoid stale closure const timeoutId = setTimeout(() => { - if (!isAuthenticated && !errorMessage) { + subscription.unsubscribe() + if (!isAuthenticatedRef.current && !errorMessageRef.current) { setErrorMessage(t('auth.callbackError')) } }, 15000) @@ -163,12 +118,12 @@ export default function AuthCallbackPage() {
{errorMessage}
- {t('auth.backToLogin')} - + ) : ( <> diff --git a/apps/web/app/(auth)/error.tsx b/apps/web/app/(auth)/error.tsx new file mode 100644 index 000000000..965993c5f --- /dev/null +++ b/apps/web/app/(auth)/error.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useTranslations } from 'next-intl' + +/** + * Error boundary for auth pages (login, auth-callback). + * Matches the Nuxt NuxtErrorBoundary in auth.vue layout. + */ +export default function AuthError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations() + + return ( +
+ + + + + +

+ {error.message || t('auth.genericError')} +

+ +
+ ) +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 302b7fbfd..ff6c35503 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -2,9 +2,11 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' import { useTranslations, useLocale } from 'next-intl' import { isValidEmail } from '@orbit/shared/utils/email' import { useAuthStore } from '@/stores/auth-store' +import { getSupabaseClient } from '@/lib/supabase' import type { LoginResponse } from '@orbit/shared/types/auth' const BACKEND_ERROR_MAP: Record = { @@ -22,6 +24,56 @@ function getCookieValue(name: string): string | undefined { return value !== undefined ? decodeURIComponent(value) : undefined } +/** + * Extract a backend error message from a fetch response body. + * + * BFF routes return JSON bodies shaped like: + * { error: "..." } -- simple error + * { data: { error: "..." } } -- nested + * { errors: { Field: ["msg"] } } -- FluentValidation + * { data: { errors: { ... } } } -- nested FluentValidation + */ +function extractFetchError(err: unknown): string | undefined { + if (!err || typeof err !== 'object') return undefined + + const obj = err as Record + + // Direct error string + if (typeof obj.error === 'string') return obj.error + + // Nested data.error + if (obj.data && typeof obj.data === 'object') { + const data = obj.data as Record + if (typeof data.error === 'string') return data.error + + // Deeper: data.data.error + if (data.data && typeof data.data === 'object') { + const inner = data.data as Record + if (typeof inner.error === 'string') return inner.error + + // FluentValidation at data.data.errors + if (inner.errors && typeof inner.errors === 'object') { + const firstField = Object.values(inner.errors as Record)[0] + if (Array.isArray(firstField) && firstField.length > 0) return firstField[0] as string + } + } + + // FluentValidation at data.errors + if (data.errors && typeof data.errors === 'object') { + const firstField = Object.values(data.errors as Record)[0] + if (Array.isArray(firstField) && firstField.length > 0) return firstField[0] as string + } + } + + // Top-level FluentValidation + if (obj.errors && typeof obj.errors === 'object') { + const firstField = Object.values(obj.errors as Record)[0] + if (Array.isArray(firstField) && firstField.length > 0) return firstField[0] as string + } + + return undefined +} + export default function LoginPage() { const router = useRouter() const searchParams = useSearchParams() @@ -35,11 +87,8 @@ export default function LoginPage() { } function extractError(err: unknown): string { - if (err && typeof err === 'object' && 'error' in err) { - const msg = (err as { error?: string }).error - if (msg) return translateBackendError(msg) - } - return t('auth.genericError') + const backendError = extractFetchError(err) + return backendError ? translateBackendError(backendError) : t('auth.genericError') } const [step, setStep] = useState<'email' | 'code'>('email') @@ -260,29 +309,29 @@ export default function LoginPage() { } } - function signInWithGoogle() { + async function signInWithGoogle() { setIsGoogleLoading(true) setErrorMessage(null) try { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - - if (!supabaseUrl || !supabaseKey) { - setErrorMessage(t('auth.googleError')) - setIsGoogleLoading(false) - return - } - + const supabase = getSupabaseClient() const redirectTo = `${window.location.origin}/auth-callback` - const params = new URLSearchParams({ + + const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', - redirect_to: redirectTo, - scopes: 'https://www.googleapis.com/auth/calendar.readonly', - access_type: 'offline', + options: { + redirectTo, + scopes: 'https://www.googleapis.com/auth/calendar.readonly', + queryParams: { + access_type: 'offline', + }, + }, }) - window.location.href = `${supabaseUrl}/auth/v1/authorize?${params.toString()}` + if (error) { + setErrorMessage(t('auth.googleError')) + setIsGoogleLoading(false) + } } catch { setErrorMessage(t('auth.googleError')) setIsGoogleLoading(false) @@ -425,7 +474,7 @@ export default function LoginPage() { ref={(el) => { codeInputRefs.current[index] = el }} value={digit} data-code-index={index} - aria-label={`${t('auth.codeDigit')} ${index + 1}`} + aria-label={t('auth.codeDigit', { n: index + 1 })} type="text" inputMode="numeric" maxLength={20} @@ -476,9 +525,9 @@ export default function LoginPage() { {/* Privacy & Terms */}

- + {t('privacy.title')} - +

diff --git a/apps/web/app/(chat)/chat/page.tsx b/apps/web/app/(chat)/chat/page.tsx index f15108cee..1f05c6933 100644 --- a/apps/web/app/(chat)/chat/page.tsx +++ b/apps/web/app/(chat)/chat/page.tsx @@ -11,6 +11,7 @@ import { type KeyboardEvent, } from 'react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { ArrowLeft, Sparkles, @@ -22,8 +23,9 @@ import { } from 'lucide-react' import { useQueryClient } from '@tanstack/react-query' import { useTranslations } from 'next-intl' -import { habitKeys } from '@orbit/shared/query' -import type { ChatResponse } from '@orbit/shared/types/chat' +import { habitKeys, profileKeys } from '@orbit/shared/query' +import type { Profile } from '@orbit/shared/types/profile' +import { getErrorMessage } from '@orbit/shared/utils' import { useChatStore } from '@/stores/chat-store' import { useProfile } from '@/hooks/use-profile' import { sendChatMessage } from '@/app/actions/chat' @@ -51,6 +53,7 @@ const STARTER_CHIP_KEYS = [ export default function ChatPage() { const t = useTranslations() + const router = useRouter() const queryClient = useQueryClient() const { profile } = useProfile() @@ -93,7 +96,7 @@ export default function ChatPage() { const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { const el = chatContainerRef.current - if (el) el.scrollTop = el.scrollHeight + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) }) }, []) @@ -108,6 +111,18 @@ export default function ChatPage() { textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` }, [input]) + // ------------------------------------------------------------------------- + // Escape key -> navigate back + // ------------------------------------------------------------------------- + + useEffect(() => { + function handleKeydown(e: globalThis.KeyboardEvent) { + if (e.key === 'Escape') router.push('/') + } + document.addEventListener('keydown', handleKeydown) + return () => document.removeEventListener('keydown', handleKeydown) + }, [router]) + // ------------------------------------------------------------------------- // Image handling // ------------------------------------------------------------------------- @@ -197,9 +212,6 @@ export default function ChatPage() { setIsTyping(true) scrollToBottom() - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 60000) - try { const formData = new FormData() if (messageContent) formData.append('message', messageContent) @@ -211,41 +223,54 @@ export default function ChatPage() { .map((m) => ({ role: m.role, content: m.content })) formData.append('history', JSON.stringify(recentHistory)) - const response: ChatResponse = await sendChatMessage(formData) + const result = await sendChatMessage(formData) + + if (!result.ok) { + setIsTyping(false) + + if (result.status === 408) { + setSendError(t('chat.timeoutError')) + } else if (result.status === 403) { + setSendError(t('chat.limitReachedError')) + } else { + setSendError(getErrorMessage(result.error, t('chat.sendError'))) + } + + addMessage({ + id: crypto.randomUUID(), + role: 'ai', + content: t('chat.aiError'), + timestamp: new Date(), + }) + scrollToBottom() + return + } setIsTyping(false) addMessage({ id: crypto.randomUUID(), role: 'ai', - content: response.aiMessage || '', - actions: response.actions, + content: result.data.aiMessage || '', + actions: result.data.actions, timestamp: new Date(), }) scrollToBottom() - if (response.actions?.some((a) => a.status === 'Success')) { + // Increment AI messages used counter (optimistic cache update) + if (!hasProAccess) { + queryClient.setQueryData(profileKeys.detail(), (old) => + old ? { ...old, aiMessagesUsed: (old.aiMessagesUsed ?? 0) + 1 } : old, + ) + } + + if (result.data.actions?.some((a) => a.status === 'Success')) { queryClient.invalidateQueries({ queryKey: habitKeys.lists() }) } } catch (err: unknown) { setIsTyping(false) - - const isAbort = - err instanceof DOMException && err.name === 'AbortError' - const is403 = - (err as { status?: number })?.status === 403 || - (err as { response?: { status?: number } })?.response?.status === 403 - - if (isAbort) { - setSendError(t('chat.timeoutError')) - } else if (is403) { - setSendError(t('chat.limitReachedError')) - } else { - setSendError( - err instanceof Error ? err.message : t('chat.sendError'), - ) - } + setSendError(getErrorMessage(err, t('chat.sendError'))) addMessage({ id: crypto.randomUUID(), @@ -254,8 +279,6 @@ export default function ChatPage() { timestamp: new Date(), }) scrollToBottom() - } finally { - clearTimeout(timeoutId) } }, [ @@ -263,6 +286,7 @@ export default function ChatPage() { selectedImage, imagePreview, isTyping, + hasProAccess, addMessage, setIsTyping, scrollToBottom, diff --git a/apps/web/app/actions/auth.ts b/apps/web/app/actions/auth.ts index 0233889c6..e21c680c3 100644 --- a/apps/web/app/actions/auth.ts +++ b/apps/web/app/actions/auth.ts @@ -29,10 +29,12 @@ export async function requestDeletion(): Promise { /** * Confirm account deletion with the code received via email. + * Returns the scheduled deletion date from the backend response. */ -export async function confirmDeletion(code: string): Promise { - await authFetch('/api/auth/confirm-deletion', { +export async function confirmDeletion(code: string): Promise<{ scheduledDeletionAt?: string }> { + const response = await authFetch('/api/auth/confirm-deletion', { method: 'POST', body: JSON.stringify({ code }), }) + return response ?? {} } diff --git a/apps/web/app/actions/chat.ts b/apps/web/app/actions/chat.ts index 80147ae79..dd159b00d 100644 --- a/apps/web/app/actions/chat.ts +++ b/apps/web/app/actions/chat.ts @@ -5,25 +5,50 @@ import type { ChatResponse } from '@orbit/shared' const API_BASE = process.env.API_BASE ?? 'http://localhost:5000' +const CHAT_TIMEOUT_MS = 60_000 + +export type ChatResult = + | { ok: true; data: ChatResponse } + | { ok: false; error: string; status: number } + /** * Send a chat message to the AI assistant. * Accepts FormData with fields: message, image (File), history (JSON string). * Forwarded as multipart/form-data to the backend. + * + * Returns a discriminated union so the caller can inspect the HTTP status + * (Server Actions cannot propagate custom Error subclasses to the client). */ -export async function sendChatMessage(formData: FormData): Promise { +export async function sendChatMessage(formData: FormData): Promise { const headers = await getAuthHeaders() - // Do NOT set Content-Type -- fetch will set the multipart boundary automatically - const res = await fetch(`${API_BASE}/api/chat`, { - method: 'POST', - headers, - body: formData, - }) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), CHAT_TIMEOUT_MS) - if (!res.ok) { - const error = await res.json().catch(() => null) - throw new Error(error?.error ?? error?.message ?? `Failed with status ${res.status}`) - } + try { + // Do NOT set Content-Type -- fetch will set the multipart boundary automatically + const res = await fetch(`${API_BASE}/api/chat`, { + method: 'POST', + headers, + body: formData, + signal: controller.signal, + }) - return res.json() + if (!res.ok) { + const body = await res.json().catch(() => null) + const message = body?.error ?? body?.message ?? `Failed with status ${res.status}` + return { ok: false, error: message, status: res.status } + } + + const data: ChatResponse = await res.json() + return { ok: true, data } + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') { + return { ok: false, error: 'Request timed out', status: 408 } + } + const message = err instanceof Error ? err.message : 'Unknown error' + return { ok: false, error: message, status: 500 } + } finally { + clearTimeout(timeoutId) + } } diff --git a/apps/web/app/actions/profile.ts b/apps/web/app/actions/profile.ts index 9df3755f8..1f69cd2d1 100644 --- a/apps/web/app/actions/profile.ts +++ b/apps/web/app/actions/profile.ts @@ -87,3 +87,9 @@ export async function resetAccount(): Promise { method: 'POST', }) } + +export async function dismissCalendarImport(): Promise { + await authFetch('/api/calendar/dismiss', { + method: 'PUT', + }) +} diff --git a/apps/web/app/api/auth/send-code/route.ts b/apps/web/app/api/auth/send-code/route.ts index f8f00a7ae..4d1e421ee 100644 --- a/apps/web/app/api/auth/send-code/route.ts +++ b/apps/web/app/api/auth/send-code/route.ts @@ -11,7 +11,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const url = `${apiBase}/api/auth/send-code` - console.log('[send-code] POST', url) const response = await fetch(url, { method: 'POST', @@ -19,20 +18,16 @@ export async function POST(request: NextRequest) { body: JSON.stringify(body), }) - console.log('[send-code] response status:', response.status) - const data = await response.json().catch(() => null) if (!response.ok) { - console.log('[send-code] error data:', JSON.stringify(data)) return NextResponse.json(data ?? { error: 'Authentication failed' }, { status: response.status, }) } return NextResponse.json(data) - } catch (err: unknown) { - console.error('[send-code] fetch failed:', err) + } catch { return NextResponse.json({ error: 'Authentication failed' }, { status: 500 }) } } diff --git a/apps/web/app/api/subscription/webhook/route.ts b/apps/web/app/api/subscription/webhook/route.ts deleted file mode 100644 index 3f9e9344c..000000000 --- a/apps/web/app/api/subscription/webhook/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse, type NextRequest } from 'next/server' - -/** - * BFF: POST /api/subscription/webhook - * Forwards Stripe webhook to .NET backend with raw body and signature header. - * Does NOT use auth -- webhooks are unauthenticated. - */ -export async function POST(request: NextRequest) { - const apiBase = process.env.API_BASE ?? 'http://localhost:5000' - - try { - const rawBody = await request.text() - const stripeSignature = request.headers.get('stripe-signature') ?? '' - - const response = await fetch(`${apiBase}/api/subscriptions/webhook`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stripe-Signature': stripeSignature, - }, - body: rawBody, - }) - - const data = await response.text() - - return new NextResponse(data, { - status: response.status, - headers: { - 'Content-Type': response.headers.get('Content-Type') ?? 'application/json', - }, - }) - } catch { - return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 }) - } -} diff --git a/apps/web/app/api/subscriptions/checkout/route.ts b/apps/web/app/api/subscriptions/checkout/route.ts new file mode 100644 index 000000000..3584c4fbd --- /dev/null +++ b/apps/web/app/api/subscriptions/checkout/route.ts @@ -0,0 +1,74 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { getAuthToken, tryRefreshSession } from '@/lib/auth-api' + +/** + * BFF: POST /api/subscriptions/checkout + * Dedicated route that proxies checkout session creation to the .NET backend. + * Forwards the client's real IP via X-Forwarded-For for geolocation-based pricing. + * + * This takes precedence over the catch-all proxy because Next.js resolves + * specific routes before [...path]. The catch-all does NOT forward X-Forwarded-For, + * which breaks geolocation-based pricing on the backend. + */ + +const IP_PATTERN = /^[\d.:a-fA-F]+$/ + +function getClientIp(request: NextRequest): string { + const forwardedRaw = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '' + const realIpRaw = request.headers.get('x-real-ip') ?? '' + return [forwardedRaw, realIpRaw].find((ip) => ip && IP_PATTERN.test(ip)) ?? '' +} + +function buildHeaders(token: string | null, clientIp: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Forwarded-For': clientIp, + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + return headers +} + +async function proxyCheckout( + body: string, + token: string | null, + clientIp: string, +): Promise { + const apiBase = process.env.API_BASE ?? 'http://localhost:5000' + return fetch(`${apiBase}/api/subscriptions/checkout`, { + method: 'POST', + headers: buildHeaders(token, clientIp), + body, + }) +} + +export async function POST(request: NextRequest) { + const token = await getAuthToken() + const clientIp = getClientIp(request) + const body = await request.text() + + const response = await proxyCheckout(body, token, clientIp) + + if (response.status === 401) { + const newToken = await tryRefreshSession() + if (newToken) { + const retryResponse = await proxyCheckout(body, newToken, clientIp) + const retryData = await retryResponse.text() + return new NextResponse(retryData, { + status: retryResponse.status, + headers: { + 'Content-Type': retryResponse.headers.get('Content-Type') ?? 'application/json', + }, + }) + } + } + + const data = await response.text() + return new NextResponse(data, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') ?? 'application/json', + }, + }) +} diff --git a/apps/web/app/api/subscriptions/plans/route.ts b/apps/web/app/api/subscriptions/plans/route.ts new file mode 100644 index 000000000..df6890296 --- /dev/null +++ b/apps/web/app/api/subscriptions/plans/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { getAuthToken, tryRefreshSession } from '@/lib/auth-api' + +/** + * BFF: GET /api/subscriptions/plans + * Dedicated route that proxies plan pricing to the .NET backend. + * Forwards the client's real IP via X-Forwarded-For for geolocation-based pricing. + * + * This takes precedence over the catch-all proxy because Next.js resolves + * specific routes before [...path]. The catch-all does NOT forward X-Forwarded-For, + * which breaks geolocation-based pricing on the backend. + */ + +const IP_PATTERN = /^[\d.:a-fA-F]+$/ + +function getClientIp(request: NextRequest): string { + const forwardedRaw = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '' + const realIpRaw = request.headers.get('x-real-ip') ?? '' + return [forwardedRaw, realIpRaw].find((ip) => ip && IP_PATTERN.test(ip)) ?? '' +} + +function buildHeaders(token: string | null, clientIp: string): Record { + const headers: Record = { + 'X-Forwarded-For': clientIp, + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + return headers +} + +async function proxyPlans( + token: string | null, + clientIp: string, +): Promise { + const apiBase = process.env.API_BASE ?? 'http://localhost:5000' + return fetch(`${apiBase}/api/subscriptions/plans`, { + method: 'GET', + headers: buildHeaders(token, clientIp), + }) +} + +export async function GET(request: NextRequest) { + const token = await getAuthToken() + const clientIp = getClientIp(request) + + const response = await proxyPlans(token, clientIp) + + if (response.status === 401) { + const newToken = await tryRefreshSession() + if (newToken) { + const retryResponse = await proxyPlans(newToken, clientIp) + const retryData = await retryResponse.text() + return new NextResponse(retryData, { + status: retryResponse.status, + headers: { + 'Content-Type': retryResponse.headers.get('Content-Type') ?? 'application/json', + }, + }) + } + } + + const data = await response.text() + return new NextResponse(data, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') ?? 'application/json', + }, + }) +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index f2dcba893..3639a3d79 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -442,6 +442,120 @@ input[type="date"]::-webkit-calendar-picker-indicator { animation: ring-complete-pulse 800ms var(--ease-out); } +/* ═══════════════════════════════════════════════════════ + FRESH START ANIMATION + ═══════════════════════════════════════════════════════ */ + +.fresh-start-overlay { + position: fixed; + inset: 0; + z-index: 99999; + opacity: 0; + transition: opacity 0.4s ease; +} + +.fresh-start-overlay.is-visible { + opacity: 1; +} + +.fresh-start-overlay.is-fading-out { + opacity: 0; + transition: opacity 0.5s ease; +} + +/* Center orb */ +.fresh-start-orb { + width: 5rem; + height: 5rem; + border-radius: 9999px; + border: 2px solid var(--color-primary); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.3); + box-shadow: 0 0 0 0 rgba(var(--primary-shadow), 0); +} + +.is-visible .fresh-start-orb { + animation: fresh-start-orb 0.8s var(--ease-spring) forwards; +} + +/* Radiating rings */ +.fresh-start-ring { + position: absolute; + top: 50%; + left: 50%; + width: 5rem; + height: 5rem; + margin-top: -2.5rem; + margin-left: -2.5rem; + border-radius: 9999px; + border: 2px solid rgba(var(--primary-shadow), 0.4); + opacity: 0; + transform: scale(0.5); + pointer-events: none; +} + +.is-visible .fresh-start-ring-1 { + animation: fresh-start-ring 1.2s ease-out 0.2s forwards; +} + +.is-visible .fresh-start-ring-2 { + animation: fresh-start-ring 1.2s ease-out 0.4s forwards; +} + +/* Text reveal */ +.fresh-start-text { + opacity: 0; + transform: translateY(10px); +} + +.is-visible .fresh-start-text { + animation: fresh-start-text 0.6s var(--ease-out) 0.8s forwards; +} + +/* Fresh start keyframes */ +@keyframes fresh-start-orb { + 0% { + opacity: 0; + transform: scale(0.3); + box-shadow: 0 0 0 0 rgba(var(--primary-shadow), 0); + } + 50% { + opacity: 1; + transform: scale(1.1); + box-shadow: 0 0 30px rgba(var(--primary-shadow), 0.3), 0 0 60px rgba(var(--primary-shadow), 0.15); + } + 100% { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 20px rgba(var(--primary-shadow), 0.2), 0 0 40px rgba(var(--primary-shadow), 0.1); + } +} + +@keyframes fresh-start-ring { + 0% { + opacity: 0.6; + transform: scale(0.5); + } + 100% { + opacity: 0; + transform: scale(3); + } +} + +@keyframes fresh-start-text { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + /* ═══════════════════════════════════════════════════════ ACCESSIBILITY ═══════════════════════════════════════════════════════ */ @@ -454,6 +568,28 @@ input[type="date"]::-webkit-calendar-picker-indicator { transition-duration: 0.01ms !important; scroll-behavior: auto !important; } + + /* Fresh start: simple fade only */ + .fresh-start-orb, + .fresh-start-ring, + .fresh-start-text { + animation: none !important; + } + + .is-visible .fresh-start-orb { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 20px rgba(var(--primary-shadow), 0.2); + } + + .is-visible .fresh-start-ring { + display: none; + } + + .is-visible .fresh-start-text { + opacity: 1; + transform: translateY(0); + } } /* Global focus-visible indicator */ diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png new file mode 100644 index 000000000..3b1588a4c Binary files /dev/null and b/apps/web/app/icon.png differ diff --git a/apps/web/components/calendar/calendar-day-detail.tsx b/apps/web/components/calendar/calendar-day-detail.tsx index f18442d16..70307cc53 100644 --- a/apps/web/components/calendar/calendar-day-detail.tsx +++ b/apps/web/components/calendar/calendar-day-detail.tsx @@ -65,8 +65,8 @@ export function CalendarDayDetail({ const formattedDate = useMemo(() => { if (!dateStr) return '' const date = parseAPIDate(dateStr) - return format(date, locale === 'pt-BR' ? "EEEE, dd 'de' MMMM 'de' yyyy" : 'EEEE, MMMM d, yyyy', { locale: dateFnsLocale }) - }, [dateStr, locale, dateFnsLocale]) + return format(date, 'EEEE, MMM d', { locale: dateFnsLocale }) + }, [dateStr, dateFnsLocale]) const filteredEntries = useMemo(() => { if (showRecurring) return entries diff --git a/apps/web/components/gamification/achievement-card.tsx b/apps/web/components/gamification/achievement-card.tsx index 94e46e347..6254bfa6d 100644 --- a/apps/web/components/gamification/achievement-card.tsx +++ b/apps/web/components/gamification/achievement-card.tsx @@ -59,7 +59,7 @@ export function AchievementCard({ achievement, earned, earnedDate }: Achievement {earned && earnedDate && (

- {t('gamification.page.earnedOn', { date: format(new Date(earnedDate), locale === 'pt-BR' ? 'dd MMM yyyy' : 'MMM d, yyyy', { locale: dateFnsLocale }) })} + {t('gamification.page.earnedOn', { date: format(new Date(earnedDate), 'PPP', { locale: dateFnsLocale }) })}

)}
diff --git a/apps/web/components/gamification/achievement-toast.tsx b/apps/web/components/gamification/achievement-toast.tsx index 98752aa09..c34c29dba 100644 --- a/apps/web/components/gamification/achievement-toast.tsx +++ b/apps/web/components/gamification/achievement-toast.tsx @@ -3,54 +3,73 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' +import { useGamificationProfile } from '@/hooks/use-gamification' +import { usePortalContainer } from '@/hooks/use-portal-container' import type { Achievement } from '@orbit/shared/types/gamification' -interface AchievementToastProps { - newAchievements: Achievement[] - onClear: () => void -} - -export function AchievementToast({ newAchievements, onClear }: AchievementToastProps) { +export function AchievementToast() { const t = useTranslations() + const portalContainer = usePortalContainer('achievement-toast') + const { newAchievements, invalidate } = useGamificationProfile() const [visible, setVisible] = useState(false) const [currentAchievement, setCurrentAchievement] = useState(null) const queueRef = useRef([]) const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) + const visibleRef = useRef(false) useEffect(() => { setMounted(true) }, []) + // Keep visibleRef in sync + useEffect(() => { + visibleRef.current = visible + }, [visible]) + const processQueue = useCallback(() => { - if (queueRef.current.length === 0) return + if (visibleRef.current || queueRef.current.length === 0) return const next = queueRef.current.shift() ?? null setCurrentAchievement(next) setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) setTimeout(() => { setVisible(false) - setTimeout(() => processQueue(), 400) + setIsVisible(false) + setTimeout(() => { + setShouldRender(false) + processQueue() + }, 400) }, 4000) }, []) useEffect(() => { if (newAchievements.length > 0) { queueRef.current.push(...newAchievements) - onClear() - if (!visible) { + invalidate() + if (!visibleRef.current) { processQueue() } } - }, [newAchievements, onClear, processQueue, visible]) + }, [newAchievements, invalidate, processQueue]) - if (!mounted || !visible || !currentAchievement) return null + if (!mounted || !shouldRender || !currentAchievement) return null - return createPortal( + return portalContainer ? createPortal(
@@ -70,6 +89,6 @@ export function AchievementToast({ newAchievements, onClear }: AchievementToastP
, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/gamification/all-done-celebration.tsx b/apps/web/components/gamification/all-done-celebration.tsx index 2bd67ab37..4a620454e 100644 --- a/apps/web/components/gamification/all-done-celebration.tsx +++ b/apps/web/components/gamification/all-done-celebration.tsx @@ -4,14 +4,18 @@ import { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { useUIStore } from '@/stores/ui-store' +import { usePortalContainer } from '@/hooks/use-portal-container' import './all-done-celebration.css' export function AllDoneCelebration() { const t = useTranslations() + const portalContainer = usePortalContainer('all-done-celebration') const allDoneCelebration = useUIStore((s) => s.allDoneCelebration) const setAllDoneCelebration = useUIStore((s) => s.setAllDoneCelebration) const [visible, setVisible] = useState(false) const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) const dismissTimerRef = useRef>(undefined) useEffect(() => { @@ -24,25 +28,35 @@ export function AllDoneCelebration() { useEffect(() => { if (allDoneCelebration) { setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) dismissTimerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) setAllDoneCelebration(false) + setTimeout(() => setShouldRender(false), 300) }, 3500) } }, [allDoneCelebration, setAllDoneCelebration]) function dismiss() { setVisible(false) + setIsVisible(false) setAllDoneCelebration(false) + setTimeout(() => setShouldRender(false), 300) } - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal( {/* Backdrop */} @@ -95,6 +109,6 @@ export function AllDoneCelebration() {

, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/gamification/goal-completed-celebration.tsx b/apps/web/components/gamification/goal-completed-celebration.tsx index 6b9b51cad..553d5155f 100644 --- a/apps/web/components/gamification/goal-completed-celebration.tsx +++ b/apps/web/components/gamification/goal-completed-celebration.tsx @@ -4,15 +4,19 @@ import { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { useUIStore } from '@/stores/ui-store' +import { usePortalContainer } from '@/hooks/use-portal-container' import './goal-completed-celebration.css' export function GoalCompletedCelebration() { const t = useTranslations() + const portalContainer = usePortalContainer('goal-completed-celebration') const goalCompletedCelebration = useUIStore((s) => s.goalCompletedCelebration) const setGoalCompletedCelebration = useUIStore((s) => s.setGoalCompletedCelebration) const [visible, setVisible] = useState(false) const [goalName, setGoalName] = useState('') const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) const dismissTimerRef = useRef>(undefined) useEffect(() => { @@ -26,24 +30,34 @@ export function GoalCompletedCelebration() { if (goalCompletedCelebration) { setGoalName(goalCompletedCelebration.name) setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) dismissTimerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) setGoalCompletedCelebration(null) + setTimeout(() => setShouldRender(false), 300) }, 3500) } }, [goalCompletedCelebration, setGoalCompletedCelebration]) function dismiss() { setVisible(false) + setIsVisible(false) setGoalCompletedCelebration(null) + setTimeout(() => setShouldRender(false), 300) } - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal(
{/* Backdrop */} @@ -88,6 +102,6 @@ export function GoalCompletedCelebration() {

, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/gamification/level-up-overlay.tsx b/apps/web/components/gamification/level-up-overlay.tsx index 72e131444..609585402 100644 --- a/apps/web/components/gamification/level-up-overlay.tsx +++ b/apps/web/components/gamification/level-up-overlay.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' +import { usePortalContainer } from '@/hooks/use-portal-container' import './level-up-overlay.css' interface LevelUpOverlayProps { @@ -13,13 +14,20 @@ interface LevelUpOverlayProps { export function LevelUpOverlay({ leveledUp, newLevel, onClear }: LevelUpOverlayProps) { const t = useTranslations() + const portalContainer = usePortalContainer('level-up-overlay') const [visible, setVisible] = useState(false) const [level, setLevel] = useState(0) const [title, setTitle] = useState('') const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) + const timerRef = useRef>(undefined) useEffect(() => { setMounted(true) + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } }, []) useEffect(() => { @@ -27,21 +35,27 @@ export function LevelUpOverlay({ leveledUp, newLevel, onClear }: LevelUpOverlayP setLevel(newLevel) setTitle(t(`gamification.levels.${newLevel}`)) setVisible(true) - const timer = setTimeout(() => { + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) + timerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) onClear() + setTimeout(() => setShouldRender(false), 400) }, 3000) - return () => clearTimeout(timer) } }, [leveledUp, newLevel, onClear, t]) - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal(
{/* Orbital ring animation */} @@ -70,6 +84,6 @@ export function LevelUpOverlay({ leveledUp, newLevel, onClear }: LevelUpOverlayP
, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/gamification/streak-celebration.tsx b/apps/web/components/gamification/streak-celebration.tsx index c65c4c383..8a46cac77 100644 --- a/apps/web/components/gamification/streak-celebration.tsx +++ b/apps/web/components/gamification/streak-celebration.tsx @@ -5,17 +5,21 @@ import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { plural } from '@/lib/plural' import { useUIStore } from '@/stores/ui-store' +import { usePortalContainer } from '@/hooks/use-portal-container' import './streak-celebration.css' const MILESTONE_VALUES = [7, 14, 30, 100, 365] as const export function StreakCelebration() { const t = useTranslations() + const portalContainer = usePortalContainer('streak-celebration') const streakCelebration = useUIStore((s) => s.streakCelebration) const setStreakCelebration = useUIStore((s) => s.setStreakCelebration) const [visible, setVisible] = useState(false) const [streakCount, setStreakCount] = useState(0) const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) const dismissTimerRef = useRef>(undefined) useEffect(() => { @@ -29,10 +33,14 @@ export function StreakCelebration() { if (streakCelebration) { setStreakCount(streakCelebration.streak) setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) dismissTimerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) setStreakCelebration(null) + setTimeout(() => setShouldRender(false), 300) }, 2500) } }, [streakCelebration, setStreakCelebration]) @@ -51,15 +59,21 @@ export function StreakCelebration() { function dismiss() { setVisible(false) + setIsVisible(false) setStreakCelebration(null) + setTimeout(() => setShouldRender(false), 300) } - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal( {/* Backdrop */} @@ -118,6 +132,6 @@ export function StreakCelebration() {

, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/gamification/streak-freeze-celebration.tsx b/apps/web/components/gamification/streak-freeze-celebration.tsx index 60ecf785b..5a46ee3d7 100644 --- a/apps/web/components/gamification/streak-freeze-celebration.tsx +++ b/apps/web/components/gamification/streak-freeze-celebration.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useImperativeHandle, forwardRef, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' +import { usePortalContainer } from '@/hooks/use-portal-container' import './streak-freeze-celebration.css' export interface StreakFreezeCelebrationHandle { @@ -12,8 +13,11 @@ export interface StreakFreezeCelebrationHandle { export const StreakFreezeCelebration = forwardRef( function StreakFreezeCelebration(_props, ref) { const t = useTranslations() + const portalContainer = usePortalContainer('streak-freeze-celebration') const [visible, setVisible] = useState(false) const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) const dismissTimerRef = useRef>(undefined) useEffect(() => { @@ -25,24 +29,34 @@ export const StreakFreezeCelebration = forwardRef function show() { setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) dismissTimerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) + setTimeout(() => setShouldRender(false), 300) }, 3000) } function dismiss() { setVisible(false) + setIsVisible(false) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) + setTimeout(() => setShouldRender(false), 300) } useImperativeHandle(ref, () => ({ show })) - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal(
{/* Backdrop */} @@ -84,7 +98,7 @@ export const StreakFreezeCelebration = forwardRef

, - document.body - ) + portalContainer + ) : null } ) diff --git a/apps/web/components/gamification/welcome-back-toast.tsx b/apps/web/components/gamification/welcome-back-toast.tsx index 9acd70a2b..fba6f57f1 100644 --- a/apps/web/components/gamification/welcome-back-toast.tsx +++ b/apps/web/components/gamification/welcome-back-toast.tsx @@ -4,14 +4,18 @@ import { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { useProfile } from '@/hooks/use-profile' +import { usePortalContainer } from '@/hooks/use-portal-container' export function WelcomeBackToast() { const t = useTranslations() + const portalContainer = usePortalContainer('welcome-back-toast') const { profile } = useProfile() const [visible, setVisible] = useState(false) const [toastMessage, setToastMessage] = useState('') const [toastEmoji, setToastEmoji] = useState('\uD83D\uDC4B') const [mounted, setMounted] = useState(false) + const [shouldRender, setShouldRender] = useState(false) + const [isVisible, setIsVisible] = useState(false) const dismissTimerRef = useRef>(undefined) const checkedRef = useRef(false) @@ -26,12 +30,22 @@ export function WelcomeBackToast() { setToastMessage(message) setToastEmoji(emoji) setVisible(true) + setShouldRender(true) + requestAnimationFrame(() => setIsVisible(true)) if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current) dismissTimerRef.current = setTimeout(() => { setVisible(false) + setIsVisible(false) + setTimeout(() => setShouldRender(false), 300) }, 4000) } + function dismiss() { + setVisible(false) + setIsVisible(false) + setTimeout(() => setShouldRender(false), 300) + } + useEffect(() => { if (!profile || checkedRef.current) return checkedRef.current = true @@ -59,13 +73,19 @@ export function WelcomeBackToast() { } }, [profile, t]) - if (!mounted || !visible) return null + if (!mounted || !shouldRender) return null - return createPortal( + return portalContainer ? createPortal(
setVisible(false)} + className="fixed top-4 left-1/2 z-[10000] max-w-sm w-[calc(100%-2rem)] bg-surface-overlay border border-border-muted rounded-2xl shadow-[var(--shadow-lg)] backdrop-blur-xl px-5 py-4 cursor-pointer" + style={{ + transition: 'opacity 0.4s var(--ease-spring), transform 0.4s var(--ease-spring)', + opacity: isVisible ? 1 : 0, + transform: isVisible + ? 'translate(-50%, 0) scale(1)' + : 'translate(-50%, -20px) scale(0.95)', + }} + onClick={dismiss} >
{toastEmoji} @@ -74,6 +94,6 @@ export function WelcomeBackToast() {

, - document.body - ) + portalContainer + ) : null } diff --git a/apps/web/components/goals/goal-list.tsx b/apps/web/components/goals/goal-list.tsx index aaa944978..04bb556ba 100644 --- a/apps/web/components/goals/goal-list.tsx +++ b/apps/web/components/goals/goal-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef, useCallback } from 'react' +import { useRef, useCallback, useState } from 'react' import { GoalCard } from './goal-card' import { useReorderGoals } from '@/hooks/use-goals' import type { Goal, GoalPositionItem } from '@orbit/shared/types/goal' @@ -13,6 +13,16 @@ interface GoalListProps { goals: Goal[] } +// --------------------------------------------------------------------------- +// Constants (matching Nuxt SortableJS config) +// --------------------------------------------------------------------------- + +/** Delay before touch drag starts (ms) -- matches SortableJS delay: 300 */ +const TOUCH_HOLD_DELAY = 300 + +/** Minimum movement (px) before cancelling hold -- matches touchStartThreshold: 5 */ +const TOUCH_MOVE_THRESHOLD = 5 + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -21,19 +31,25 @@ export function GoalList({ goals }: GoalListProps) { const listRef = useRef(null) const reorderGoals = useReorderGoals() - // Drag state + // Drag state (shared between mouse and touch) const dragItemRef = useRef(null) const dragOverItemRef = useRef(null) - const handleDragStart = useCallback((index: number) => { - dragItemRef.current = index - }, []) + // Visual state + const [dragIndex, setDragIndex] = useState(null) + const [dragOverIndex, setDragOverIndex] = useState(null) - const handleDragEnter = useCallback((index: number) => { - dragOverItemRef.current = index - }, []) + // Touch state + const touchTimerRef = useRef | null>(null) + const touchStartPos = useRef<{ x: number; y: number } | null>(null) + const touchDragging = useRef(false) + const touchItemRef = useRef(null) - const handleDragEnd = useCallback(() => { + // ------------------------------------------------------------------------- + // Reorder logic (shared) + // ------------------------------------------------------------------------- + + const commitReorder = useCallback(() => { if ( dragItemRef.current === null || dragOverItemRef.current === null || @@ -41,10 +57,11 @@ export function GoalList({ goals }: GoalListProps) { ) { dragItemRef.current = null dragOverItemRef.current = null + setDragIndex(null) + setDragOverIndex(null) return } - // Build reordered list const reordered = [...goals] const [draggedItem] = reordered.splice(dragItemRef.current, 1) if (draggedItem) { @@ -60,18 +77,141 @@ export function GoalList({ goals }: GoalListProps) { dragItemRef.current = null dragOverItemRef.current = null + setDragIndex(null) + setDragOverIndex(null) }, [goals, reorderGoals]) + // ------------------------------------------------------------------------- + // HTML5 drag handlers (mouse/pointer) + // ------------------------------------------------------------------------- + + const handleDragStart = useCallback((index: number) => { + dragItemRef.current = index + setDragIndex(index) + }, []) + + const handleDragEnter = useCallback((index: number) => { + dragOverItemRef.current = index + setDragOverIndex(index) + }, []) + + const handleDragEnd = useCallback(() => { + commitReorder() + }, [commitReorder]) + + // ------------------------------------------------------------------------- + // Touch handlers (mobile support with 300ms hold delay) + // ------------------------------------------------------------------------- + + const clearTouchTimer = useCallback(() => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current) + touchTimerRef.current = null + } + }, []) + + const getItemIndexFromPoint = useCallback((x: number, y: number): number | null => { + const list = listRef.current + if (!list) return null + const children = Array.from(list.children) as HTMLElement[] + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (!child) continue + const rect = child.getBoundingClientRect() + if (y >= rect.top && y <= rect.bottom) { + return i + } + } + return null + }, []) + + const handleTouchStart = useCallback( + (index: number, e: React.TouchEvent) => { + const touch = e.touches[0] + if (!touch) return + touchStartPos.current = { x: touch.clientX, y: touch.clientY } + touchItemRef.current = e.currentTarget + + touchTimerRef.current = setTimeout(() => { + touchDragging.current = true + dragItemRef.current = index + setDragIndex(index) + }, TOUCH_HOLD_DELAY) + }, + [], + ) + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (!touch) return + + // Cancel hold if moved too far before delay elapsed + if (!touchDragging.current && touchStartPos.current) { + const dx = Math.abs(touch.clientX - touchStartPos.current.x) + const dy = Math.abs(touch.clientY - touchStartPos.current.y) + if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) { + clearTouchTimer() + return + } + } + + if (!touchDragging.current) return + + // Prevent scrolling while dragging + e.preventDefault() + + const overIndex = getItemIndexFromPoint(touch.clientX, touch.clientY) + if (overIndex !== null) { + dragOverItemRef.current = overIndex + setDragOverIndex(overIndex) + } + }, + [clearTouchTimer, getItemIndexFromPoint], + ) + + const handleTouchEnd = useCallback(() => { + clearTouchTimer() + if (touchDragging.current) { + touchDragging.current = false + commitReorder() + } + touchStartPos.current = null + touchItemRef.current = null + }, [clearTouchTimer, commitReorder]) + + // ------------------------------------------------------------------------- + // CSS class helper + // ------------------------------------------------------------------------- + + const getDragClasses = useCallback( + (index: number): string => { + if (dragIndex === null) return '' + if (index === dragIndex) return 'drag-chosen' + if (index === dragOverIndex) return 'drag-ghost' + return '' + }, + [dragIndex, dragOverIndex], + ) + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + return (
{goals.map((goal, index) => (
handleDragStart(index)} onDragEnter={() => handleDragEnter(index)} onDragEnd={handleDragEnd} onDragOver={(e) => e.preventDefault()} + onTouchStart={(e) => handleTouchStart(index, e)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} >
diff --git a/apps/web/components/habits/create-habit-modal.tsx b/apps/web/components/habits/create-habit-modal.tsx index 7bfa48d35..14e82a73e 100644 --- a/apps/web/components/habits/create-habit-modal.tsx +++ b/apps/web/components/habits/create-habit-modal.tsx @@ -9,7 +9,8 @@ import { useHabitForm } from '@/hooks/use-habit-form' import { useTagSelection } from '@/hooks/use-tag-selection' import { useCreateHabit, useCreateSubHabit } from '@/hooks/use-habits' import { formatAPIDate } from '@orbit/shared/utils' -import type { NormalizedHabit, CreateHabitRequest, CreateSubHabitRequest } from '@orbit/shared/types/habit' +import { useUIStore } from '@/stores/ui-store' +import type { NormalizedHabit, CreateHabitRequest, CreateSubHabitRequest, ScheduledReminderTime } from '@orbit/shared/types/habit' // --------------------------------------------------------------------------- // Props @@ -36,6 +37,7 @@ export function CreateHabitModal({ const createHabit = useCreateHabit() const createSubHabit = useCreateSubHabit() const isSubHabitMode = !!parentHabit + const activeView = useUIStore((s) => s.activeView) const formHelpers = useHabitForm({ initialData: { @@ -47,8 +49,9 @@ export function CreateHabitModal({ const [selectedGoalIds, setSelectedGoalIds] = useState([]) const [subHabits, setSubHabits] = useState([]) const [validationError, setValidationError] = useState('') + const [reminderTimes, setReminderTimes] = useState([0, 15]) - const atGoalLimit = selectedGoalIds.length >= 5 + const atGoalLimit = selectedGoalIds.length >= 10 const toggleGoal = useCallback((goalId: string) => { setSelectedGoalIds((prev) => { @@ -83,25 +86,49 @@ export function CreateHabitModal({ setSelectedGoalIds([]) setSubHabits([]) setValidationError('') + setReminderTimes([0, 15]) // Prefill from parent if sub-habit mode if (parentHabit) { + formHelpers.form.setValue('frequencyUnit', parentHabit.frequencyUnit) + formHelpers.form.setValue('frequencyQuantity', parentHabit.frequencyQuantity) + if (parentHabit.days?.length) { + formHelpers.form.setValue('days', [...parentHabit.days]) + } formHelpers.form.setValue('isBadHabit', parentHabit.isBadHabit) + formHelpers.form.setValue('isGeneral', parentHabit.isGeneral ?? false) + formHelpers.form.setValue('isFlexible', parentHabit.isFlexible ?? false) + formHelpers.form.setValue('slipAlertEnabled', parentHabit.slipAlertEnabled ?? false) + formHelpers.form.setValue('dueDate', parentHabit.dueDate ?? initialDate ?? formatAPIDate(new Date())) + formHelpers.form.setValue('dueTime', parentHabit.dueTime?.slice(0, 5) ?? '') + formHelpers.form.setValue('dueEndTime', parentHabit.dueEndTime?.slice(0, 5) ?? '') + formHelpers.form.setValue('endDate', parentHabit.endDate ?? '') + formHelpers.form.setValue('reminderEnabled', parentHabit.reminderEnabled ?? false) + setReminderTimes(parentHabit.reminderTimes?.length ? [...parentHabit.reminderTimes] : [0, 15]) + formHelpers.form.setValue('scheduledReminders', + parentHabit.scheduledReminders?.length + ? parentHabit.scheduledReminders.map((sr: ScheduledReminderTime) => ({ ...sr })) + : [] + ) + if (parentHabit.isGeneral) { formHelpers.setGeneral() } else if (parentHabit.isFlexible) { formHelpers.setFlexible() } else if (parentHabit.frequencyUnit) { formHelpers.setRecurring() - formHelpers.form.setValue('frequencyUnit', parentHabit.frequencyUnit) - formHelpers.form.setValue('frequencyQuantity', parentHabit.frequencyQuantity) - if (parentHabit.days?.length) { - formHelpers.form.setValue('days', [...parentHabit.days]) - } + } else { + formHelpers.setOneTime() } + tags.resetTags(parentHabit.tags?.map((t) => t.id) ?? []) setSelectedGoalIds(parentHabit.linkedGoals?.map((g) => g.id) ?? []) } + + // Auto-set general mode when on the general view + if (activeView === 'general') { + formHelpers.setGeneral() + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) @@ -125,6 +152,7 @@ export function CreateHabitModal({ } if (data.description) subRequest.description = data.description if (!data.isGeneral) { + // Schedule fields if (data.dueDate) subRequest.dueDate = data.dueDate if (data.isFlexible) { subRequest.isFlexible = true @@ -136,7 +164,16 @@ export function CreateHabitModal({ if (data.days?.length) subRequest.days = data.days if (data.endDate) subRequest.endDate = data.endDate } - if (data.dueTime) subRequest.dueTime = data.dueTime + // Reminder fields + if (data.dueTime) { + subRequest.dueTime = data.dueTime + if (data.dueEndTime) subRequest.dueEndTime = data.dueEndTime + subRequest.reminderEnabled = data.reminderEnabled + subRequest.reminderTimes = reminderTimes + } else if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) { + subRequest.reminderEnabled = true + subRequest.scheduledReminders = data.scheduledReminders ?? undefined + } } if (data.isBadHabit) { subRequest.isBadHabit = true @@ -163,21 +200,27 @@ export function CreateHabitModal({ if (data.isGeneral) { request.isGeneral = true } else { - if (data.dueDate) request.dueDate = data.dueDate + // Schedule fields + request.dueDate = data.dueDate if (data.isFlexible) { request.isFlexible = true - if (data.frequencyUnit) request.frequencyUnit = data.frequencyUnit - if (data.frequencyQuantity) request.frequencyQuantity = data.frequencyQuantity + request.frequencyUnit = data.frequencyUnit ?? undefined + request.frequencyQuantity = data.frequencyQuantity ?? undefined } else if (data.frequencyUnit) { request.frequencyUnit = data.frequencyUnit request.frequencyQuantity = data.frequencyQuantity ?? undefined if (data.days?.length) request.days = data.days if (data.endDate) request.endDate = data.endDate } + // Reminder fields (match Nuxt applyReminderFields) if (data.dueTime) { request.dueTime = data.dueTime if (data.dueEndTime) request.dueEndTime = data.dueEndTime request.reminderEnabled = data.reminderEnabled + request.reminderTimes = reminderTimes + } else if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) { + request.reminderEnabled = true + request.scheduledReminders = data.scheduledReminders ?? undefined } } if (data.isBadHabit) request.slipAlertEnabled = data.slipAlertEnabled @@ -196,7 +239,7 @@ export function CreateHabitModal({ } } }, - [formHelpers, isSubHabitMode, parentHabit, tags, selectedGoalIds, subHabits, createHabit, createSubHabit, onOpenChange], + [formHelpers, isSubHabitMode, parentHabit, tags, selectedGoalIds, subHabits, reminderTimes, createHabit, createSubHabit, onOpenChange], ) const isPending = createHabit.isPending || createSubHabit.isPending @@ -219,6 +262,8 @@ export function CreateHabitModal({ selectedGoalIds={selectedGoalIds} atGoalLimit={atGoalLimit} onToggleGoal={toggleGoal} + reminderTimes={reminderTimes} + onReminderTimesChange={setReminderTimes} > {/* Sub-habits (create-only, not in sub-habit mode) */} {!isSubHabitMode && ( diff --git a/apps/web/components/habits/description-viewer.tsx b/apps/web/components/habits/description-viewer.tsx index 4428b065b..0b245bc78 100644 --- a/apps/web/components/habits/description-viewer.tsx +++ b/apps/web/components/habits/description-viewer.tsx @@ -1,9 +1,12 @@ 'use client' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' +import { marked } from 'marked' +import DOMPurify from 'dompurify' import { ArrowLeft } from 'lucide-react' import { useTranslations } from 'next-intl' +import { usePortalContainer } from '@/hooks/use-portal-container' interface DescriptionViewerProps { open: boolean @@ -12,23 +15,6 @@ interface DescriptionViewerProps { description: string } -/** - * Minimal Markdown-like rendering: bold, italic, headers, line breaks. - * For full Markdown, install `marked` and `dompurify` packages. - */ -function renderSimpleMarkdown(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/^### (.+)$/gm, '

$1

') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/^# (.+)$/gm, '

$1

') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/\n/g, '
') -} - export function DescriptionViewer({ open, onOpenChange, @@ -36,20 +22,26 @@ export function DescriptionViewer({ description, }: DescriptionViewerProps) { const t = useTranslations() + const portalContainer = usePortalContainer('description-viewer') const [mounted, setMounted] = useState(false) + const [renderedHtml, setRenderedHtml] = useState('') useEffect(() => { setMounted(true) }, []) - const renderedHtml = useMemo(() => { - if (!description) return '' - return renderSimpleMarkdown(description) - }, [description]) + useEffect(() => { + if (!open || !description) { + setRenderedHtml('') + return + } + const raw = marked.parse(description, { async: false }) as string + setRenderedHtml(DOMPurify.sanitize(raw)) + }, [open, description]) if (!mounted || !open) return null - return createPortal( + return portalContainer ? createPortal(
{/* Header */}
@@ -71,6 +63,6 @@ export function DescriptionViewer({ />
, - document.body, - ) + portalContainer, + ) : null } diff --git a/apps/web/components/habits/edit-habit-modal.tsx b/apps/web/components/habits/edit-habit-modal.tsx index 1ffd1a8d4..7a7e74958 100644 --- a/apps/web/components/habits/edit-habit-modal.tsx +++ b/apps/web/components/habits/edit-habit-modal.tsx @@ -8,6 +8,8 @@ import { HabitFormFields } from './habit-form-fields' import { useHabitForm } from '@/hooks/use-habit-form' import { useTagSelection } from '@/hooks/use-tag-selection' import { useUpdateHabit, useHabitDetail } from '@/hooks/use-habits' +import { assignTags } from '@/app/actions/tags' +import { getErrorMessage } from '@orbit/shared/utils' import type { NormalizedHabit, UpdateHabitRequest } from '@orbit/shared/types/habit' // --------------------------------------------------------------------------- @@ -36,12 +38,14 @@ export function EditHabitModal({ const tags = useTagSelection() const [selectedGoalIds, setSelectedGoalIds] = useState([]) const [validationError, setValidationError] = useState('') + const [detailFetchError, setDetailFetchError] = useState('') const [originalEndDate, setOriginalEndDate] = useState('') + const [reminderTimes, setReminderTimes] = useState([0, 15]) - const atGoalLimit = selectedGoalIds.length >= 5 + const atGoalLimit = selectedGoalIds.length >= 10 // Fetch detail to get dueDate, dueTime, endDate etc. - const { data: habitDetail } = useHabitDetail(open && habit ? habit.id : null) + const { data: habitDetail, error: detailError } = useHabitDetail(open && habit ? habit.id : null) const toggleGoal = useCallback((goalId: string) => { setSelectedGoalIds((prev) => { @@ -51,10 +55,20 @@ export function EditHabitModal({ }) }, []) + // Show detail fetch error + useEffect(() => { + if (detailError) { + setDetailFetchError(getErrorMessage(detailError, t('errors.fetchHabits'))) + } + }, [detailError, t]) + // Populate form when modal opens or detail loads useEffect(() => { if (!open || !habit) return + setValidationError('') + setDetailFetchError('') + formHelpers.form.reset({ title: habit.title, description: habit.description || '', @@ -75,9 +89,9 @@ export function EditHabitModal({ }) setOriginalEndDate(habitDetail?.endDate ?? '') + setReminderTimes(habit.reminderTimes?.length ? [...habit.reminderTimes] : [0, 15]) tags.resetTags(habit.tags?.map((t) => t.id) ?? []) setSelectedGoalIds(habit.linkedGoals?.map((g) => g.id) ?? []) - setValidationError('') // Set mode based on habit data if (habit.isGeneral) { @@ -116,10 +130,11 @@ export function EditHabitModal({ if (data.description) request.description = data.description if (!data.isGeneral) { + // Schedule fields if (data.dueDate) request.dueDate = data.dueDate - if (data.frequencyUnit) { - request.frequencyUnit = data.frequencyUnit + if (!formHelpers.isOneTime) { + request.frequencyUnit = data.frequencyUnit ?? undefined request.frequencyQuantity = data.frequencyQuantity ?? undefined if (data.days?.length) request.days = data.days if (data.endDate) { @@ -129,10 +144,15 @@ export function EditHabitModal({ } } + // Reminder fields (match Nuxt applyRemindersToUpdate) if (data.dueTime) { request.dueTime = data.dueTime request.dueEndTime = data.dueEndTime || undefined request.reminderEnabled = data.reminderEnabled + request.reminderTimes = reminderTimes + } else if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) { + request.reminderEnabled = true + request.scheduledReminders = data.scheduledReminders ?? undefined } else { request.reminderEnabled = false } @@ -144,12 +164,13 @@ export function EditHabitModal({ try { await updateHabit.mutateAsync({ habitId: habit.id, data: request }) + await assignTags(habit.id, tags.selectedTagIds) onOpenChange(false) } catch { // Error handled by mutation } }, - [habit, formHelpers, originalEndDate, selectedGoalIds, updateHabit, onOpenChange], + [habit, formHelpers, originalEndDate, selectedGoalIds, reminderTimes, tags, updateHabit, onOpenChange], ) return ( @@ -166,8 +187,15 @@ export function EditHabitModal({ selectedGoalIds={selectedGoalIds} atGoalLimit={atGoalLimit} onToggleGoal={toggleGoal} + reminderTimes={reminderTimes} + onReminderTimesChange={setReminderTimes} /> + {/* Detail fetch error */} + {detailFetchError && ( +

{detailFetchError}

+ )} + {/* Validation error */} {validationError && (

{validationError}

diff --git a/apps/web/components/habits/habit-calendar.tsx b/apps/web/components/habits/habit-calendar.tsx index 66bcb5c58..7ba8a19c1 100644 --- a/apps/web/components/habits/habit-calendar.tsx +++ b/apps/web/components/habits/habit-calendar.tsx @@ -19,6 +19,7 @@ import { enUS, ptBR } from 'date-fns/locale' import { ChevronLeft, ChevronRight, X } from 'lucide-react' import { useTranslations, useLocale } from 'next-intl' import { useHabitLogs } from '@/hooks/use-habits' +import { useProfile } from '@/hooks/use-profile' import type { HabitLog } from '@orbit/shared/types/calendar' // --------------------------------------------------------------------------- @@ -63,8 +64,8 @@ export function HabitCalendar({ habitId, logs: externalLogs }: HabitCalendarProp [currentMonth, locale, dateFnsLocale], ) - // TODO: weekStartsOn from profile when available - const weekStartsOn = 1 as 0 | 1 + const { profile } = useProfile() + const weekStartsOn = (profile?.weekStartDay ?? 1) as 0 | 1 const weekdays = useMemo(() => { const sundayFirst = [ diff --git a/apps/web/components/habits/habit-card.tsx b/apps/web/components/habits/habit-card.tsx index a4257406c..e0a96b456 100644 --- a/apps/web/components/habits/habit-card.tsx +++ b/apps/web/components/habits/habit-card.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom' +import { usePortalContainer } from '@/hooks/use-portal-container' import { ChevronRight, Check, @@ -41,6 +42,7 @@ interface HabitCardProps { childrenDone?: number childrenTotal?: number searchQuery?: string + maxHabitDepth?: number onLog?: () => void onUnlog?: () => void onSkip?: () => void @@ -84,6 +86,7 @@ export function HabitCard({ childrenDone = 0, childrenTotal = 0, searchQuery = '', + maxHabitDepth = 5, onLog, onUnlog, onSkip, @@ -99,6 +102,7 @@ export function HabitCard({ onEnterSelectMode, }: HabitCardProps) { const t = useTranslations() + const portalContainer = usePortalContainer('habit-card') const { displayTime } = useTimeFormat() const isChild = depth > 0 @@ -729,7 +733,7 @@ export function HabitCard({ {/* Actions menu portal */} {showActionsMenu && - typeof document !== 'undefined' && + portalContainer && createPortal(
e.stopPropagation()} > - {showAddSubHabit && ( + {showAddSubHabit && depth < maxHabitDepth - 1 && ( )}
, - document.body, + portalContainer, )} ) diff --git a/apps/web/components/habits/habit-checklist.tsx b/apps/web/components/habits/habit-checklist.tsx index f64a8dfc6..c91d9ecf0 100644 --- a/apps/web/components/habits/habit-checklist.tsx +++ b/apps/web/components/habits/habit-checklist.tsx @@ -144,6 +144,9 @@ export function HabitChecklist({ )} {/* Items list (editable) */} + {/* NOTE: Nuxt source uses VueDraggable for drag-to-reorder in editable mode. + The GripHorizontal icon below is currently decorative only. + TODO: Add drag-to-reorder via @dnd-kit/core or react-beautiful-dnd. */} {editable ? (
{items.map((item, index) => ( diff --git a/apps/web/components/habits/habit-form-fields.tsx b/apps/web/components/habits/habit-form-fields.tsx index e170c2687..55e00107e 100644 --- a/apps/web/components/habits/habit-form-fields.tsx +++ b/apps/web/components/habits/habit-form-fields.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback, useMemo, useId, type ReactNode } from 'react' +import { useState, useMemo, useId, type ReactNode } from 'react' import { X, Plus, Bell, Check, ShieldAlert, PenSquare } from 'lucide-react' import { useTranslations } from 'next-intl' import { useQuery } from '@tanstack/react-query' @@ -14,6 +14,7 @@ import { AppDatePicker } from '@/components/ui/app-date-picker' import { AppSelect } from '@/components/ui/app-select' import type { TagSelectionState } from '@/hooks/use-tag-selection' import type { HabitFormHelpers } from '@/hooks/use-habit-form' +import { useHasProAccess } from '@/hooks/use-profile' // --------------------------------------------------------------------------- // Props @@ -25,6 +26,9 @@ interface HabitFormFieldsProps { selectedGoalIds: string[] atGoalLimit: boolean onToggleGoal: (goalId: string) => void + /** Controlled reminderTimes state from parent modal */ + reminderTimes: number[] + onReminderTimesChange: (times: number[]) => void children?: ReactNode } @@ -55,6 +59,8 @@ export function HabitFormFields({ selectedGoalIds, atGoalLimit, onToggleGoal, + reminderTimes, + onReminderTimesChange, children, }: HabitFormFieldsProps) { const t = useTranslations() @@ -62,6 +68,7 @@ export function HabitFormFields({ const scheduledReminderLabelId = useId() const slipAlertLabelId = useId() const slipAlertDescriptionId = useId() + const hasProAccess = useHasProAccess() const { form, @@ -130,9 +137,6 @@ export function HabitFormFields({ { value: 'days', label: t('habits.form.reminderUnitDays') }, ], [t]) - // reminderTimes is not in the form schema, manage as local state - const [reminderTimes, setReminderTimes] = useState([0, 15]) - const availablePresets = useMemo( () => REMINDER_PRESETS.filter((p) => !reminderTimes.includes(p.value)), [reminderTimes], @@ -152,7 +156,7 @@ export function HabitFormFields({ function addPreset(value: number) { if (!reminderTimes.includes(value)) { - setReminderTimes((prev) => [...prev, value].sort((a, b) => b - a)) + onReminderTimesChange([...reminderTimes, value].sort((a, b) => b - a)) } setShowAddReminder(false) } @@ -164,7 +168,7 @@ export function HabitFormFields({ else if (customUnit === 'hours') multiplier = 60 const minutes = customValue * multiplier if (!reminderTimes.includes(minutes)) { - setReminderTimes((prev) => [...prev, minutes].sort((a, b) => b - a)) + onReminderTimesChange([...reminderTimes, minutes].sort((a, b) => b - a)) } setCustomValue(null) setShowCustomInput(false) @@ -172,7 +176,7 @@ export function HabitFormFields({ } function removeReminder(value: number) { - setReminderTimes((prev) => prev.filter((v) => v !== value)) + onReminderTimesChange(reminderTimes.filter((v) => v !== value)) } // Scheduled reminders @@ -937,26 +941,44 @@ export function HabitFormFields({ {/* Slip alert toggle (only when bad habit) */} {watchedIsBadHabit && (
-
-
-
- - {t('habits.form.slipAlert')} + {!hasProAccess ? ( + /* Pro locked state */ +
+
+
+ + {t('habits.form.slipAlert')} + {t('common.proBadge')} +
+ {t('habits.form.slipAlertDescription')} +
+
+
- {t('habits.form.slipAlertDescription')}
- -
+ ) : ( + /* Pro unlocked state */ +
+
+
+ + {t('habits.form.slipAlert')} +
+ {t('habits.form.slipAlertDescription')} +
+ +
+ )}
)} diff --git a/apps/web/components/habits/habit-list.tsx b/apps/web/components/habits/habit-list.tsx index ab907ffac..836172b2f 100644 --- a/apps/web/components/habits/habit-list.tsx +++ b/apps/web/components/habits/habit-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useCallback, useRef, useEffect } from 'react' +import { useState, useMemo, useCallback, forwardRef, useImperativeHandle } from 'react' import { isToday as isDateToday, isTomorrow, @@ -24,6 +24,7 @@ import { CreateHabitModal } from './create-habit-modal' import { EditHabitModal } from './edit-habit-modal' import { LogHabitModal } from './log-habit-modal' import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { AppOverlay } from '@/components/ui/app-overlay' import { useHabits, useLogHabit, @@ -31,16 +32,17 @@ import { useDeleteHabit, useDuplicateHabit, useReorderHabits, - type NormalizedHabitsData, + useMoveHabitParent, } from '@/hooks/use-habits' import { useHabitVisibility } from '@/hooks/use-habit-visibility' import { useDrillNavigation } from '@/hooks/use-drill-navigation' +import { useConfig } from '@/hooks/use-config' import { useUIStore } from '@/stores/ui-store' import { formatAPIDate } from '@orbit/shared/utils' import type { NormalizedHabit, HabitsFilter } from '@orbit/shared/types/habit' // --------------------------------------------------------------------------- -// Props +// Props & Handle // --------------------------------------------------------------------------- interface HabitListProps { @@ -57,11 +59,32 @@ interface HabitListProps { onSeeUpcoming?: () => void } +export interface HabitListHandle { + collapseAll: () => void + expandAll: () => void + allCollapsed: boolean + allLoadedIds: Set + markRecentlyCompleted: (habitId: string) => void + checkAndPromptParentLog: (childHabitId: string) => void +} + +// --------------------------------------------------------------------------- +// Move parent picker types +// --------------------------------------------------------------------------- + +interface MoveParentOption { + id: string | null + label: string + depth: number + disabled: boolean + reason: string | null +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- -export function HabitList({ +export const HabitList = forwardRef(function HabitList({ view = 'today', selectedDate, showCompleted = false, @@ -73,7 +96,7 @@ export function HabitList({ onEnterSelectMode, onCreate, onSeeUpcoming, -}: HabitListProps) { +}, ref) { const t = useTranslations() const locale = useLocale() const dateFnsLocale = locale === 'pt-BR' ? ptBR : enUS @@ -84,7 +107,13 @@ export function HabitList({ const skipHabit = useSkipHabit() const deleteHabitMut = useDeleteHabit() const duplicateHabitMut = useDuplicateHabit() + // eslint-disable-next-line @typescript-eslint/no-unused-vars const reorderHabitsMut = useReorderHabits() + const moveHabitParentMut = useMoveHabitParent() + + // Config + const { config: appConfig } = useConfig() + const maxHabitDepth = appConfig.limits.maxHabitDepth const data = habitsQuery.data const habitsById = data?.habitsById ?? new Map() @@ -124,6 +153,11 @@ export function HabitList({ recentlyCompletedIds, }) + // Wrapper for getVisibleChildren that passes the current view + function getVisibleChildren(parentId: string): NormalizedHabit[] { + return visibility.getVisibleChildren(parentId, view) + } + // Drill navigation const drill = useDrillNavigation(habitsById, habitsQuery.dataUpdatedAt) @@ -142,6 +176,26 @@ export function HabitList({ }) }, []) + // Collapse / expand all + const expandableIds = useMemo(() => { + const ids: string[] = [] + for (const h of habitsById.values()) { + const childIds = childrenByParent.get(h.id) + if (childIds && childIds.length > 0) ids.push(h.id) + } + return ids + }, [habitsById, childrenByParent]) + + const allCollapsed = expandableIds.length > 0 && expandableIds.every((id) => collapsedIds.has(id)) + + const collapseAll = useCallback(() => { + setCollapsedIds(new Set(expandableIds)) + }, [expandableIds]) + + const expandAll = useCallback(() => { + setCollapsedIds(new Set()) + }, []) + // Filter habits const habits = useMemo(() => { if (view === 'general' || view === 'all') { @@ -155,25 +209,91 @@ export function HabitList({ return topLevelHabits.filter((h) => visibility.hasVisibleContent(h)) }, [topLevelHabits, view, showCompleted, recentlyCompletedIds, visibility]) - // Children progress - const getChildrenProgress = useCallback( - (habitId: string) => { - const children = getChildren(habitId) + // All loaded/selectable IDs including descendants + const allLoadedIds = useMemo(() => { + const ids = new Set() + function collect(habitId: string) { + ids.add(habitId) + const childIds = childrenByParent.get(habitId) + if (childIds) { + for (const cid of childIds) collect(cid) + } + } + for (const h of habits) { + collect(h.id) + } + return ids + }, [habits, childrenByParent]) + + // Children progress -- matches Nuxt computeChildProgress logic + const isListView = view === 'all' || view === 'general' + + const childrenProgressMap = useMemo(() => { + const map = new Map() + + function computeChildProgress( + child: NormalizedHabit, + computeFn: (id: string) => { done: number; total: number }, + ): { done: number; total: number } { let done = 0 let total = 0 - for (const child of children) { + + if (isListView || child.isGeneral) { + total++ + if (child.isCompleted) done++ + } else if (!visibility.isRelevantToday(child) && !child.isLoggedInRange) { + // Not relevant today and not logged -- only count nested + const nested = computeFn(child.id) + return nested + } else if (visibility.isDueOnSelectedDate(child) || child.isLoggedInRange) { total++ if (child.isCompleted || child.isLoggedInRange) done++ - // Recurse - const nested = getChildren(child.id) - for (const nc of nested) { - total++ - if (nc.isCompleted || nc.isLoggedInRange) done++ - } } + + const nested = computeFn(child.id) + done += nested.done + total += nested.total return { done, total } + } + + function compute(habitId: string): { done: number; total: number } { + const cached = map.get(habitId) + if (cached) return cached + + const children = getChildren(habitId) + if (children.length === 0) { + const result = { done: 0, total: 0 } + map.set(habitId, result) + return result + } + + let done = 0 + let total = 0 + for (const child of children) { + const progress = computeChildProgress(child, compute) + done += progress.done + total += progress.total + } + + const result = { done, total } + map.set(habitId, result) + return result + } + + for (const habit of habitsById.values()) { + if (!map.has(habit.id)) { + compute(habit.id) + } + } + + return map + }, [habitsById, getChildren, isListView, visibility]) + + const getChildrenProgress = useCallback( + (habitId: string) => { + return childrenProgressMap.get(habitId) ?? { done: 0, total: 0 } }, - [getChildren], + [childrenProgressMap], ) // Date groups for "all" view @@ -267,16 +387,16 @@ export function HabitList({ const items: DragItem[] = [] function addHabitTree(habit: NormalizedHabit, depth: number) { - const visibleChildren = visibility.getVisibleChildren(habit.id, view) + const visChildren = visibility.getVisibleChildren(habit.id, view) items.push({ id: habit.id, habit, depth, - hasChildren: visibleChildren.length > 0, + hasChildren: visChildren.length > 0, hasSubHabits: habit.hasSubHabits, }) if (!collapsedIds.has(habit.id)) { - for (const child of visibleChildren) { + for (const child of visChildren) { addHabitTree(child, depth + 1) } } @@ -308,7 +428,192 @@ export function HabitList({ const [showForceLogConfirm, setShowForceLogConfirm] = useState(false) const [forceLogHabitId, setForceLogHabitId] = useState(null) + // Auto-log parent state + const [showAutoLogParent, setShowAutoLogParent] = useState(false) + const [autoLogParentId, setAutoLogParentId] = useState(null) + const autoLogParentHabit = autoLogParentId ? habitsById.get(autoLogParentId) ?? null : null + + // Move parent picker state + const [showMoveParentOverlay, setShowMoveParentOverlay] = useState(false) + const [movingHabitId, setMovingHabitId] = useState(null) + const [selectedMoveParentId, setSelectedMoveParentId] = useState(null) + const [isMovingParent, setIsMovingParent] = useState(false) + const movingHabit = movingHabitId ? habitsById.get(movingHabitId) ?? null : null + + // ------------------------------------------------------------------------- + // Auto-log parent when all sub-habits complete + // ------------------------------------------------------------------------- + + function checkAndPromptParentLog(childHabitId: string) { + const child = habitsById.get(childHabitId) + if (!child?.parentId) return + const parent = habitsById.get(child.parentId) + if (!parent || parent.isCompleted) return + const { done, total } = getChildrenProgress(parent.id) + if (total > 0 && done >= total) { + setAutoLogParentId(parent.id) + setShowAutoLogParent(true) + } + } + + async function confirmAutoLogParent() { + const parentId = autoLogParentId + if (!parentId) return + setShowAutoLogParent(false) + setAutoLogParentId(null) + markRecentlyCompleted(parentId) + try { + await logHabit.mutateAsync({ habitId: parentId }) + // After logging parent, check if grandparent also needs logging + checkAndPromptParentLog(parentId) + } catch { + // Error handled by mutation + } + } + + // ------------------------------------------------------------------------- + // Move parent picker helpers + // ------------------------------------------------------------------------- + + function getHabitDepth(habitId: string): number { + let depth = 0 + let current = habitsById.get(habitId) + while (current?.parentId) { + depth++ + current = habitsById.get(current.parentId) + } + return depth + } + + function getSubtreeMaxDepth(habitId: string, baseDepth: number): number { + let max = baseDepth + const children = getChildren(habitId) + for (const child of children) { + const childMax = getSubtreeMaxDepth(child.id, baseDepth + 1) + if (childMax > max) max = childMax + } + return max + } + + function isDescendant(candidateId: string, ancestorId: string): boolean { + let current = habitsById.get(candidateId) + while (current?.parentId) { + if (current.parentId === ancestorId) return true + current = habitsById.get(current.parentId) + } + return false + } + + function validateMoveTarget( + targetParentId: string | null, + draggedId: string, + ): { valid: boolean; reason: string | null } { + if (targetParentId === draggedId) { + return { valid: false, reason: t('habits.moveParent.invalidSelf') } + } + + if (targetParentId && isDescendant(targetParentId, draggedId)) { + return { valid: false, reason: t('habits.moveParent.invalidDescendant') } + } + + const newParentDepth = targetParentId ? getHabitDepth(targetParentId) : -1 + const subtreeMax = getSubtreeMaxDepth(draggedId, newParentDepth + 1) + if (subtreeMax >= maxHabitDepth) { + return { + valid: false, + reason: t('habits.moveParent.invalidDepth', { max: maxHabitDepth }), + } + } + + return { valid: true, reason: null } + } + + const moveParentOptions = useMemo(() => { + if (!movingHabitId) return [] + + const options: MoveParentOption[] = [] + const rootValidation = validateMoveTarget(null, movingHabitId) + options.push({ + id: null, + label: t('habits.moveParent.toRoot'), + depth: 0, + disabled: !rootValidation.valid, + reason: rootValidation.reason, + }) + + function addOption(habit: NormalizedHabit, depth: number) { + const validation = validateMoveTarget(habit.id, movingHabitId!) + options.push({ + id: habit.id, + label: habit.title, + depth, + disabled: !validation.valid, + reason: validation.reason, + }) + + const ch = getChildren(habit.id) + for (const child of ch) { + addOption(child, depth + 1) + } + } + + for (const topLevel of topLevelHabits) { + addOption(topLevel, 0) + } + + return options + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [movingHabitId, topLevelHabits, habitsById, t, maxHabitDepth]) + + const selectedMoveOption = moveParentOptions.find( + (option) => option.id === selectedMoveParentId, + ) ?? null + + const canSubmitMoveParent = + movingHabit !== null && + !isMovingParent && + selectedMoveParentId !== movingHabit.parentId && + selectedMoveOption !== null && + !selectedMoveOption.disabled + + function openMoveParentPicker(habitId: string) { + const habit = habitsById.get(habitId) + if (!habit) return + setMovingHabitId(habitId) + setSelectedMoveParentId(habit.parentId) + setShowMoveParentOverlay(true) + } + + function closeMoveParentPicker() { + if (isMovingParent) return + setShowMoveParentOverlay(false) + setMovingHabitId(null) + setSelectedMoveParentId(null) + } + + async function confirmMoveParent() { + if (!movingHabitId || !canSubmitMoveParent) return + + setIsMovingParent(true) + try { + await moveHabitParentMut.mutateAsync({ + habitId: movingHabitId, + data: { parentId: selectedMoveParentId }, + }) + setShowMoveParentOverlay(false) + setMovingHabitId(null) + setSelectedMoveParentId(null) + } catch { + // Error handled by mutation + } finally { + setIsMovingParent(false) + } + } + + // ------------------------------------------------------------------------- // Actions + // ------------------------------------------------------------------------- + function openDetail(habit: NormalizedHabit) { setSelectedHabit(habit) setShowDetailDrawer(true) @@ -351,9 +656,11 @@ export function HabitList({ async function confirmSkip() { if (!habitToSkip) return + const skippedId = habitToSkip try { - await skipHabit.mutateAsync({ habitId: habitToSkip }) - markRecentlyCompleted(habitToSkip) + await skipHabit.mutateAsync({ habitId: skippedId }) + markRecentlyCompleted(skippedId) + checkAndPromptParentLog(skippedId) } catch { // Error handled by mutation } finally { @@ -364,6 +671,7 @@ export function HabitList({ function handleLogged(habitId: string) { markRecentlyCompleted(habitId) + checkAndPromptParentLog(habitId) } async function confirmForceLog() { @@ -400,6 +708,31 @@ export function HabitList({ return habit ? !habit.frequencyUnit : false }, [habitToSkip, habitsById]) + // Skip confirm message -- 3 branches: postpone, flexible, regular + const skipConfirmMessage = useMemo(() => { + if (isPostponeAction) return t('habits.postponeConfirmMessage') + if (habitToSkip) { + const habit = habitsById.get(habitToSkip) + if (habit?.flexibleTarget != null) { + return t('habits.skipConfirmMessageFlexible') + } + } + return t('habits.skipConfirmMessage') + }, [isPostponeAction, habitToSkip, habitsById, t]) + + // ------------------------------------------------------------------------- + // Expose imperative handle + // ------------------------------------------------------------------------- + + useImperativeHandle(ref, () => ({ + collapseAll, + expandAll, + get allCollapsed() { return allCollapsed }, + get allLoadedIds() { return allLoadedIds }, + markRecentlyCompleted, + checkAndPromptParentLog, + })) + // Render a single HabitCard with all handlers function renderHabitCard( habit: NormalizedHabit, @@ -424,6 +757,7 @@ export function HabitList({ childrenTotal={progress.total} isSelectMode={isSelectMode} isSelected={selectedHabitIds?.has(habit.id) ?? false} + maxHabitDepth={maxHabitDepth} onLog={() => promptLog(habit)} onUnlog={() => logHabit.mutate({ habitId: habit.id })} onForceLogParent={() => { @@ -432,9 +766,7 @@ export function HabitList({ }} onSkip={() => promptSkip(habit.id)} onDuplicate={() => duplicateHabitMut.mutate(habit.id)} - onMoveParent={() => { - // TODO: Move parent picker - }} + onMoveParent={() => openMoveParentPicker(habit.id)} onDelete={() => promptDelete(habit.id)} onDetail={() => openDetail(habit)} onDrillInto={() => drill.drillInto(habit.id)} @@ -591,7 +923,7 @@ export function HabitList({ )}
) : view === 'all' ? ( - /* ALL VIEW: date-grouped list */ + /* ALL VIEW: date-grouped list with nested children (up to depth 2) */ <> {dateGroups.map((group) => (
@@ -610,14 +942,38 @@ export function HabitList({ />
- {group.habits.map((habit) => - renderHabitCard( - habit, - 0, - getChildren(habit.id).length > 0, - habit.hasSubHabits, - ), - )} + {group.habits.map((habit) => ( +
+ {renderHabitCard( + habit, + 0, + getChildren(habit.id).length > 0, + habit.hasSubHabits, + )} + {/* Children (depth 1) when expanded */} + {!collapsedIds.has(habit.id) && getVisibleChildren(habit.id).map((child) => ( +
+ {renderHabitCard( + child, + 1, + getVisibleChildren(child.id).length > 0, + habitsById.get(child.id)?.hasSubHabits ?? false, + )} + {/* Grandchildren (depth 2) when child expanded */} + {!collapsedIds.has(child.id) && getVisibleChildren(child.id).map((grandchild) => ( +
+ {renderHabitCard( + grandchild, + 2, + getVisibleChildren(grandchild.id).length > 0, + habitsById.get(grandchild.id)?.hasSubHabits ?? false, + )} +
+ ))} +
+ ))} +
+ ))}
))} @@ -690,11 +1046,7 @@ export function HabitList({ ? 'habits.postponeConfirmTitle' : 'habits.skipConfirmTitle', )} - description={t( - isPostponeAction - ? 'habits.postponeConfirmMessage' - : 'habits.skipConfirmMessage', - )} + description={skipConfirmMessage} confirmLabel={t( isPostponeAction ? 'habits.postponeConfirmButton' @@ -714,7 +1066,7 @@ export function HabitList({ onOpenChange={setShowForceLogConfirm} title={t('habits.forceLogTitle')} description={t('habits.forceLogMessage')} - confirmLabel={t('habits.logHabit')} + confirmLabel={t('habits.forceLogConfirm')} cancelLabel={t('common.cancel')} onConfirm={confirmForceLog} onCancel={() => { @@ -723,6 +1075,87 @@ export function HabitList({ }} variant="warning" /> + + {/* Auto-log parent when all sub-habits complete */} + { + setAutoLogParentId(null) + setShowAutoLogParent(false) + }} + variant="success" + /> + + {/* Move parent picker */} + { + if (!open) closeMoveParentPicker() + }} + dismissible={!isMovingParent} + title={t('habits.moveParent.title')} + description={movingHabit ? t('habits.moveParent.description', { name: movingHabit.title }) : undefined} + footer={ +
+ + +
+ } + > + {moveParentOptions.length > 0 ? ( +
+ {moveParentOptions.map((option) => ( + + ))} +
+ ) : ( +

+ {t('habits.moveParent.noOptions')} +

+ )} +
) -} +}) diff --git a/apps/web/components/navigation/bottom-nav.tsx b/apps/web/components/navigation/bottom-nav.tsx index 7e8322582..e6d2dd0c6 100644 --- a/apps/web/components/navigation/bottom-nav.tsx +++ b/apps/web/components/navigation/bottom-nav.tsx @@ -4,6 +4,8 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { Home, MessageCircle, CalendarDays, User, Plus } from 'lucide-react' import { useTranslations } from 'next-intl' +import { useUIStore } from '@/stores/ui-store' +import { formatAPIDate } from '@orbit/shared/utils' import type { LucideIcon } from 'lucide-react' interface NavItem { @@ -19,6 +21,8 @@ interface BottomNavProps { export function BottomNav({ onCreate }: BottomNavProps) { const t = useTranslations() const pathname = usePathname() + const setSelectedDate = useUIStore((s) => s.setSelectedDate) + const setActiveView = useUIStore((s) => s.setActiveView) const navItems: NavItem[] = [ { name: t('nav.habits'), path: '/', icon: Home }, @@ -33,13 +37,29 @@ export function BottomNav({ onCreate }: BottomNavProps) { return pathname === path || pathname === path + '/' } + function handleNavClick(item: NavItem, event: React.MouseEvent) { + if (item.path === '/') { + // Reset date to today and view to 'today' when clicking Home + setSelectedDate(formatAPIDate(new Date())) + setActiveView('today') + if (isActive(item.path)) { + event.preventDefault() + } + } + } + return (
) - return createPortal(overlay, document.body) + return portalContainer ? createPortal(overlay, portalContainer) : null } diff --git a/apps/web/components/onboarding/onboarding-welcome.tsx b/apps/web/components/onboarding/onboarding-welcome.tsx index 73157dc63..946aeed55 100644 --- a/apps/web/components/onboarding/onboarding-welcome.tsx +++ b/apps/web/components/onboarding/onboarding-welcome.tsx @@ -1,7 +1,9 @@ 'use client' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslations } from 'next-intl' +import { profileKeys } from '@orbit/shared/query' +import type { Profile } from '@orbit/shared/types/profile' import { colorSchemeOptions, type ColorScheme } from '@orbit/shared/theme' import { useProfile, useHasProAccess } from '@/hooks/use-profile' import { useColorScheme } from '@/hooks/use-color-scheme' @@ -9,12 +11,29 @@ import { updateWeekStartDay, updateColorScheme as updateColorSchemeAction } from export function OnboardingWelcome() { const t = useTranslations() + const queryClient = useQueryClient() const { profile } = useProfile() const hasProAccess = useHasProAccess() const { currentScheme, applyScheme } = useColorScheme() const weekStartDayMutation = useMutation({ mutationFn: (day: number) => updateWeekStartDay({ weekStartDay: day }), + onMutate: async (newDay) => { + await queryClient.cancelQueries({ queryKey: profileKeys.all }) + const prev = queryClient.getQueryData(profileKeys.detail()) + queryClient.setQueryData(profileKeys.detail(), (old) => + old ? { ...old, weekStartDay: newDay } : old, + ) + return { prev } + }, + onError: (_err, _newDay, context) => { + if (context?.prev) { + queryClient.setQueryData(profileKeys.detail(), context.prev) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: profileKeys.all }) + }, }) const colorSchemeMutation = useMutation({ diff --git a/apps/web/components/ui/app-overlay.tsx b/apps/web/components/ui/app-overlay.tsx index 513f7b520..2651bae4f 100644 --- a/apps/web/components/ui/app-overlay.tsx +++ b/apps/web/components/ui/app-overlay.tsx @@ -4,6 +4,39 @@ import { useState, useEffect, useRef, useCallback, useId, type ReactNode } from import { createPortal } from 'react-dom' import { X, Expand } from 'lucide-react' import { useTranslations } from 'next-intl' +import DOMPurify from 'dompurify' +import { usePortalContainer } from '@/hooks/use-portal-container' + +// --------------------------------------------------------------------------- +// linkifyText -- converts URLs in plain text to clickable tags +// --------------------------------------------------------------------------- + +function escapeHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +function linkifyText(text: string): string { + const urlRegex = /(https?:\/\/[^\s<]+)/g + const parts = text.split(urlRegex) + + const result = parts.map((part, i) => { + const escaped = escapeHtml(part) + if (i % 2 === 1) { + return `${escaped}` + } + return escaped + }).join('') + + return DOMPurify.sanitize(result, { ALLOWED_TAGS: ['a'], ALLOWED_ATTR: ['href', 'target', 'rel', 'class'] }) +} + +// --------------------------------------------------------------------------- +// AppOverlay +// --------------------------------------------------------------------------- interface AppOverlayProps { open: boolean @@ -31,17 +64,35 @@ export function AppOverlay({ onExpandDescription, }: AppOverlayProps) { const t = useTranslations() + const portalContainer = usePortalContainer('app-overlay') const titleId = useId() const panelRef = useRef(null) const pointerDownOnBackdrop = useRef(false) const savedScrollY = useRef(0) const previouslyFocusedElement = useRef(null) const [mounted, setMounted] = useState(false) + const [animState, setAnimState] = useState<'hidden' | 'entering' | 'visible' | 'leaving'>('hidden') useEffect(() => { setMounted(true) }, []) + // Transition state machine + useEffect(() => { + if (open && mounted) { + setAnimState('entering') + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setAnimState('visible') + }) + }) + } else if (!open && animState === 'visible') { + setAnimState('leaving') + const timer = setTimeout(() => setAnimState('hidden'), 200) + return () => clearTimeout(timer) + } + }, [open, mounted]) // eslint-disable-line react-hooks/exhaustive-deps + const lockBodyScroll = useCallback(() => { savedScrollY.current = window.scrollY document.body.style.position = 'fixed' @@ -139,9 +190,20 @@ export function AppOverlay({ pointerDownOnBackdrop.current = false } - if (!open || !mounted) return null + if (animState === 'hidden' || !mounted) return null const hasTitle = !!(title || titleContent) + const linkedDescription = description ? linkifyText(description) : '' + + // Transition classes + const isEntering = animState === 'entering' + const isLeaving = animState === 'leaving' + const backdropClass = isEntering || isLeaving ? 'opacity-0' : 'opacity-100' + const panelClass = isEntering + ? 'translate-y-full sm:translate-y-0 sm:scale-95 opacity-0' + : isLeaving + ? 'opacity-0' + : 'translate-y-0 sm:scale-100 opacity-100' const overlay = (
{/* Backdrop */} -
+
{/* Panel - bottom sheet on mobile, centered modal on desktop */} e.stopPropagation()} > {/* Drag handle (mobile) */} @@ -180,7 +244,7 @@ export function AppOverlay({

{expandable && (