feat: remove OTP gate from transactions, fix categories auth, add implementation plan

- Remove OtpGateGuard from transactions controller (OTP verified at login)
- Fix categories controller to use authenticated user instead of TEMP_USER_ID
- Add comprehensive implementation plan document
- Update .env.example with WEB_APP_URL
- Prepare for admin dashboard development
This commit is contained in:
dwindown
2025-10-11 14:00:11 +07:00
parent 0da6071eb3
commit 249f3a9d7d
159 changed files with 13748 additions and 3369 deletions

View File

@@ -0,0 +1,8 @@
# API Base URL
VITE_API_URL=http://localhost:3001
# Google OAuth Client ID (same as backend)
VITE_GOOGLE_CLIENT_ID=your-google-client-id
# Exchange Rate API
VITE_EXCHANGE_RATE_URL=https://api.exchangerate-api.com/v4/latest/IDR

File diff suppressed because it is too large Load Diff

View File

@@ -19,18 +19,19 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"firebase": "^12.3.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-day-picker": "^9.11.0",
"react-dom": "^19.1.1",
"react-hook-form": "^7.64.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",

View File

@@ -1,64 +1,69 @@
import { useEffect } from "react";
import axios from "axios";
import { useAuth } from "./hooks/useAuth";
import { AuthForm } from "./components/AuthForm";
import { Dashboard } from "./components/Dashboard";
import { ThemeProvider } from "./components/ThemeProvider";
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './components/ThemeProvider'
import { Dashboard } from './components/Dashboard'
import { Login } from './components/pages/Login'
import { Register } from './components/pages/Register'
import { OtpVerification } from './components/pages/OtpVerification'
import { AuthCallback } from './components/pages/AuthCallback'
import { Loader2 } from 'lucide-react'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
function AppContent() {
// ---- Authentication (MUST be at top level) ----
const { user, loading: authLoading, getIdToken } = useAuth();
// ---- Effects ----
// ---- Setup Axios Interceptor for Auth ----
useEffect(() => {
const interceptor = axios.interceptors.request.use(async (config) => {
if (user && getIdToken) {
try {
const token = await getIdToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} catch (error) {
console.error('Failed to get auth token:', error);
}
}
return config;
});
return () => {
axios.interceptors.request.eject(interceptor);
};
}, [getIdToken, user]);
// Show loading screen while checking auth
if (authLoading) {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<Loader2 className="h-12 w-12 animate-spin text-blue-600 mx-auto" />
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
)
}
// Show auth form if not authenticated
if (!user) {
return <AuthForm />;
return <Navigate to="/auth/login" replace />
}
// Show dashboard if authenticated
return <Dashboard />;
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
)
}
if (user) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
export default function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="tabungin-ui-theme">
<AppContent />
</ThemeProvider>
);
<BrowserRouter>
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
<AuthProvider>
<Routes>
{/* Public Routes */}
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
<Route path="/auth/otp" element={<OtpVerification />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Protected Routes */}
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
</Routes>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
}

View File

@@ -1,141 +0,0 @@
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { Logo } from './Logo';
import { ThemeToggle } from './ThemeToggle';
export const AuthForm = () => {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { signIn, signUp, signInWithGoogle, loading, error } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isLogin) {
await signIn(email, password);
} else {
await signUp(email, password);
}
} catch {
// Error is handled by useAuth hook
}
};
const handleGoogleSignIn = async () => {
try {
await signInWithGoogle();
} catch {
// Error is handled by useAuth hook
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative">
{/* Theme Toggle - positioned in top right */}
<div className="absolute top-4 right-4">
<ThemeToggle />
</div>
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<Logo variant="large" className="mx-auto mb-6" />
<h2 className="mt-6 text-center text-3xl font-extrabold text-foreground">
{isLogin ? 'Sign in to your account' : 'Create your account'}
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Welcome to Tabungin
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-destructive">
Authentication Error
</h3>
<div className="mt-2 text-sm text-destructive/80">
{error}
</div>
</div>
</div>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter your email"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete={isLogin ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter your password"
/>
</div>
</div>
<div className="space-y-4">
<button
type="submit"
disabled={loading}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full"
>
{loading ? 'Loading...' : (isLogin ? 'Sign in' : 'Sign up')}
</button>
<button
type="button"
onClick={handleGoogleSignIn}
disabled={loading}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-destructive/80 h-10 px-4 py-2 w-full"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
</div>
<div className="text-center">
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="text-primary hover:text-primary/80 text-sm underline-offset-4 hover:underline"
>
{isLogin ? "Don't have an account? Sign up" : "Already have an account? Sign in"}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -13,6 +13,8 @@ export function Breadcrumb({ currentPage }: BreadcrumbProps) {
return 'Wallets'
case '/transactions':
return 'Transactions'
case '/profile':
return 'Profile'
default:
return page.charAt(0).toUpperCase() + page.slice(1)
}

View File

@@ -1,28 +1,22 @@
import { useState } from "react"
import { Routes, Route, useLocation, useNavigate } from "react-router-dom"
import { DashboardLayout } from "./layout/DashboardLayout"
import { Overview } from "./pages/Overview"
import { Wallets } from "./pages/Wallets"
import { Transactions } from "./pages/Transactions"
import { Profile } from "./pages/Profile"
export function Dashboard() {
const [currentPage, setCurrentPage] = useState("/")
const renderPage = () => {
switch (currentPage) {
case "/":
return <Overview />
case "/wallets":
return <Wallets />
case "/transactions":
return <Transactions />
default:
return <Overview />
}
}
const location = useLocation()
const navigate = useNavigate()
return (
<DashboardLayout currentPage={currentPage} onNavigate={setCurrentPage}>
{renderPage()}
<DashboardLayout currentPage={location.pathname} onNavigate={navigate}>
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/wallets" element={<Wallets />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</DashboardLayout>
)
}

View File

@@ -1,3 +1,4 @@
import { useTheme } from '@/hooks/useTheme';
import logoLight from '../assets/images/logo.png';
import logoDark from '../assets/images/logo-dark.png';
import logoLargeLight from '../assets/images/logo-large.png';
@@ -10,43 +11,29 @@ interface LogoProps {
}
export const Logo = ({ variant = 'header', className = '' }: LogoProps) => {
const { actualTheme } = useTheme();
const getLogoSrc = () => {
const isDark = actualTheme === 'dark';
switch (variant) {
case 'large':
return {
light: logoLargeLight,
dark: logoLargeDark,
};
return isDark ? logoLargeDark : logoLargeLight;
case 'icon':
return {
light: logoIcon,
dark: logoIcon,
};
return logoIcon;
default: // header
return {
light: logoLight,
dark: logoDark,
};
return isDark ? logoDark : logoLight;
}
};
const logos = getLogoSrc();
const logoSrc = getLogoSrc();
const baseClassName = variant === 'icon' ? 'w-8 h-8' : variant === 'large' ? 'h-12' : 'h-8';
return (
<>
{/* Light mode logo */}
<img
src={logos.light}
alt="Tabungin"
className={`${baseClassName} ${className} block dark:hidden`}
/>
{/* Dark mode logo */}
<img
src={logos.dark}
alt="Tabungin"
className={`${baseClassName} ${className} hidden dark:block`}
/>
</>
<img
src={logoSrc}
alt="Tabungin"
className={`${baseClassName} ${className}`}
/>
);
};

View File

@@ -9,12 +9,17 @@ type ThemeProviderProps = {
export function ThemeProvider({
children,
defaultTheme = 'system',
defaultTheme = 'light',
storageKey = 'tabungin-ui-theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem(storageKey) as Theme
// If system theme is stored, convert to light
if (stored === 'system') {
return 'light'
}
return stored || defaultTheme
})
const [actualTheme, setActualTheme] = useState<'dark' | 'light'>('light')
@@ -23,19 +28,10 @@ export function ThemeProvider({
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
setActualTheme(systemTheme)
return
}
root.classList.add(theme)
setActualTheme(theme)
// Only support light and dark, no system
const themeToApply = theme === 'system' ? 'light' : theme
root.classList.add(themeToApply)
setActualTheme(themeToApply)
}, [theme])
const value = {

View File

@@ -1,47 +1,28 @@
import { Moon, Sun, Monitor } from "lucide-react"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "../hooks/useTheme"
export function ThemeToggle() {
const { setTheme, actualTheme } = useTheme()
const { theme, setTheme } = useTheme()
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark")
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Toggle theme"
>
{actualTheme === 'dark' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="rounded-full"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
)
}

View File

@@ -89,7 +89,7 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
const response = await axios.get(`${API}/categories`)
const categories = response.data
const options: Option[] = categories.map((cat: any) => ({
const options: Option[] = categories.map((cat: { name: string }) => ({
label: cat.name,
value: cat.name
}))
@@ -147,9 +147,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
for (const category of categories) {
try {
await axios.post(`${API}/categories`, { name: category })
} catch (error: any) {
} catch (error) {
// Ignore if category already exists (409 conflict)
if (error.response?.status !== 409) {
const err = error as { response?: { status?: number } }
if (err.response?.status !== 409) {
console.error('Failed to create category:', error)
}
}

View File

@@ -1,4 +1,4 @@
import { Home, Wallet, Receipt, LogOut } from "lucide-react"
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
import { Logo } from "../Logo"
import {
Sidebar,
@@ -12,9 +12,9 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useAuth } from "@/hooks/useAuth"
import { useAuth } from "@/contexts/AuthContext"
import { getAvatarUrl } from "@/lib/utils"
// Menu items
const items = [
{
title: "Overview",
@@ -31,6 +31,11 @@ const items = [
url: "/transactions",
icon: Receipt,
},
{
title: "Profile",
url: "/profile",
icon: User,
},
]
interface AppSidebarProps {
@@ -39,12 +44,12 @@ interface AppSidebarProps {
}
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const { user, signOut } = useAuth()
const { user, logout } = useAuth()
return (
<Sidebar>
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<div className="mx-auto">
<Logo variant="large"/>
</div>
</SidebarHeader>
@@ -72,13 +77,29 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">{user?.email}</span>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
{getAvatarUrl(user?.avatarUrl) ? (
<img
src={getAvatarUrl(user?.avatarUrl)!}
alt={user?.name || user?.email || 'User'}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-4 w-4" />
</div>
)}
<div className="flex flex-col min-w-0">
{user?.name && (
<span className="text-sm font-medium truncate">{user.name}</span>
)}
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
</div>
</div>
<button
onClick={signOut}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={logout}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 flex-shrink-0"
title="Sign out"
>
<LogOut className="h-4 w-4" />

View File

@@ -0,0 +1,96 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/hooks/useTheme"
import { Button } from "@/components/ui/button"
import { Logo } from "@/components/Logo"
interface AuthLayoutProps {
children: React.ReactNode
title: string
description: string
}
export function AuthLayout({ children, title, description }: AuthLayoutProps) {
const { theme, setTheme } = useTheme()
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark")
}
return (
<div className="min-h-screen flex">
{/* Left Side - Branding */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
<div className="absolute inset-0 bg-grid-white/5 [mask-image:radial-gradient(white,transparent_85%)]" />
<div className="relative z-10 flex flex-col justify-between p-12 w-full">
<div className="flex items-center gap-3">
<Logo variant="large"/>
</div>
<div className="space-y-6 max-w-md">
<h1 className="text-4xl font-bold leading-tight">
Manage your finances with ease
</h1>
<p className="text-lg text-muted-foreground">
Track expenses, manage budgets, and achieve your financial goals with our intuitive platform.
</p>
<div className="flex gap-8 pt-4">
<div>
<div className="text-3xl font-bold">10K+</div>
<div className="text-sm text-muted-foreground">Active Users</div>
</div>
<div>
<div className="text-3xl font-bold">99%</div>
<div className="text-sm text-muted-foreground">Satisfaction</div>
</div>
<div>
<div className="text-3xl font-bold">24/7</div>
<div className="text-sm text-muted-foreground">Support</div>
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
© 2025 Tabungin. All rights reserved.
</div>
</div>
</div>
{/* Right Side - Auth Form */}
<div className="flex-1 flex flex-col">
{/* Header with Theme Toggle */}
<div className="flex justify-between items-center p-6">
<div className="lg:hidden flex items-center gap-2">
<Logo className="h-8" variant="large" />
</div>
<div className="ml-auto">
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="rounded-full"
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
</div>
</div>
{/* Auth Content */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
<p className="text-muted-foreground">{description}</p>
</div>
{children}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { Loader2 } from 'lucide-react'
export function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { updateUser } = useAuth()
useEffect(() => {
const token = searchParams.get('token')
if (token) {
// Store token and redirect to dashboard
localStorage.setItem('token', token)
// Force reload to trigger auth context
window.location.href = '/'
} else {
// No token, redirect to login
navigate('/auth/login')
}
}, [searchParams, navigate, updateUser])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Completing sign in...</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { AuthLayout } from '@/components/layout/AuthLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { Loader2, Mail, Lock, AlertCircle } from 'lucide-react'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function Login() {
const navigate = useNavigate()
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result = await login(email, password)
if (result.requiresOtp) {
// Redirect to OTP verification page
navigate('/auth/otp', {
state: {
tempToken: result.tempToken,
availableMethods: result.availableMethods
}
})
} else {
// Login successful, redirect to dashboard
navigate('/')
}
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || 'Login failed. Please check your credentials.')
} finally {
setLoading(false)
}
}
const handleGoogleLogin = () => {
// Redirect to backend Google OAuth endpoint
window.location.href = `${API_URL}/api/auth/google`
}
return (
<AuthLayout
title="Welcome Back"
description="Sign in to your Tabungin account"
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Google Sign In */}
<Button
type="button"
variant="outline"
className="w-full h-11"
onClick={handleGoogleLogin}
disabled={loading || !GOOGLE_CLIENT_ID}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
{/* Email/Password Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
disabled={loading}
/>
</div>
</div>
<Button type="submit" className="w-full h-11" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<Link to="/auth/register" className="font-medium text-primary hover:underline">
Sign up
</Link>
</p>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1,318 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { AuthLayout } from '@/components/layout/AuthLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2, Mail, Smartphone, AlertCircle, RefreshCw, Shield } from 'lucide-react'
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function OtpVerification() {
const navigate = useNavigate()
const location = useLocation()
const { verifyOtp } = useAuth()
// Get params from either location.state (from login) or URL query params (from Google OAuth)
const searchParams = new URLSearchParams(location.search)
const urlToken = searchParams.get('token')
const urlMethods = searchParams.get('methods')
const tempToken = location.state?.tempToken || urlToken
const availableMethods = location.state?.availableMethods ||
(urlMethods ? JSON.parse(decodeURIComponent(urlMethods)) : null)
const [code, setCode] = useState('')
const [method, setMethod] = useState<'email' | 'whatsapp' | 'totp'>(
availableMethods?.totp ? 'totp' : availableMethods?.whatsapp ? 'whatsapp' : 'email'
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [resendLoading, setResendLoading] = useState(false)
const [resendTimer, setResendTimer] = useState(30)
const [canResend, setCanResend] = useState(false)
// Countdown timer for resend button
useEffect(() => {
if (resendTimer > 0) {
const timer = setTimeout(() => setResendTimer(resendTimer - 1), 1000)
return () => clearTimeout(timer)
} else {
setCanResend(true)
}
}, [resendTimer])
if (!tempToken) {
navigate('/auth/login')
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await verifyOtp(tempToken, code, method)
// Verification successful, redirect to dashboard
navigate('/')
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || 'Invalid OTP code. Please try again.')
} finally {
setLoading(false)
}
}
const handleResendEmail = async () => {
setResendLoading(true)
setError('')
try {
// Call backend to resend OTP with temp token
await axios.post(`${API_URL}/api/otp/email/resend`, {
tempToken
})
// Reset timer
setResendTimer(30)
setCanResend(false)
setError('')
} catch {
setError('Failed to resend code. Please try again.')
} finally {
setResendLoading(false)
}
}
const handleResendWhatsApp = async () => {
setResendLoading(true)
setError('')
try {
// Call backend to resend WhatsApp OTP with temp token
await axios.post(`${API_URL}/api/otp/whatsapp/resend`, {
tempToken
})
// Reset timer
setResendTimer(30)
setCanResend(false)
setError('')
} catch {
setError('Failed to resend code. Please try again.')
setResendLoading(false)
}
}
return (
<AuthLayout
title="Verify Your Identity"
description="Enter the verification code to continue"
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-center p-4 bg-primary/5 rounded-lg border border-primary/10">
<Shield className="h-5 w-5 text-primary mr-2" />
<p className="text-sm text-muted-foreground">
Two-factor authentication is enabled
</p>
</div>
<Tabs value={method} onValueChange={(v) => setMethod(v as 'email' | 'whatsapp' | 'totp')}>
<TabsList className={`grid w-full ${availableMethods?.whatsapp ? 'grid-cols-3' : 'grid-cols-2'}`}>
{availableMethods?.email && (
<TabsTrigger value="email" disabled={loading}>
<Mail className="mr-2 h-4 w-4" />
Email
</TabsTrigger>
)}
{availableMethods?.whatsapp && (
<TabsTrigger value="whatsapp" disabled={loading}>
<Smartphone className="mr-2 h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
{availableMethods?.totp && (
<TabsTrigger value="totp" disabled={loading}>
<Shield className="mr-2 h-4 w-4" />
Authenticator
</TabsTrigger>
)}
</TabsList>
<TabsContent value="email" className="space-y-4">
<p className="text-sm text-muted-foreground">
A 6-digit code has been sent to your email address. Please check your inbox.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email-code">Email Code</Label>
<Input
id="email-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleResendEmail}
disabled={!canResend || resendLoading || loading}
>
{resendLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : canResend ? (
<>
<Mail className="mr-2 h-4 w-4" />
Resend Code
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Resend in {resendTimer}s
</>
)}
</Button>
</form>
</TabsContent>
<TabsContent value="whatsapp" className="space-y-4">
<p className="text-sm text-gray-600">
A 6-digit code has been sent to your WhatsApp number. Please check your WhatsApp messages.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="whatsapp-code">WhatsApp Code</Label>
<Input
id="whatsapp-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleResendWhatsApp}
disabled={!canResend || resendLoading || loading}
>
{resendLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : canResend ? (
<>
<Smartphone className="mr-2 h-4 w-4" />
Resend Code
</>
) : (
<>
<Smartphone className="mr-2 h-4 w-4" />
Resend in {resendTimer}s
</>
)}
</Button>
</form>
</TabsContent>
<TabsContent value="totp" className="space-y-4">
<p className="text-sm text-gray-600">
Open your authenticator app and enter the 6-digit code.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp-code">Authenticator Code</Label>
<Input
id="totp-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
</form>
</TabsContent>
</Tabs>
<Button
variant="ghost"
className="w-full"
onClick={() => navigate('/auth/login')}
disabled={loading}
>
Back to Login
</Button>
</div>
</AuthLayout>
)
}

View File

@@ -279,7 +279,7 @@ export function Overview() {
kind: wallet.kind
}
})
}, [wallets, transactions, exchangeRates])
}, [wallets, transactions, exchangeRates, dateRange, customStartDate, customEndDate])
// Flexible trend data based on selected period and date range
const trendData = useMemo(() => {
@@ -526,7 +526,7 @@ export function Overview() {
</div>
{/* Date Range Filter */}
<div className="space-y-3 flex flex-col md:flex-row md:justify-between gap-3 md:w-full">
<div className="space-y-3 flex flex-col md:flex-row md:flex-wrap md:justify-between gap-3 md:w-full">
<div className="flex flex-col sm:flex-row gap-3">
<label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1">
@@ -583,7 +583,7 @@ export function Overview() {
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Balance</CardTitle>
@@ -628,19 +628,6 @@ export function Overview() {
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Transactions</CardTitle>
<Receipt className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate).length}</div>
<p className="text-xs text-muted-foreground">
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} transactions
</p>
</CardContent>
</Card>
</div>
{/* Second Row: Wallet Breakdown (Full Width) */}
@@ -674,10 +661,10 @@ export function Overview() {
<TableCell>
<div className="flex items-center gap-2">
<button
className="font-medium text-nowrap hover:text-blue-600 hover:underline text-left"
className="font-medium text-nowrap hover:text-blue-600 hover:underline text-left cursor-pointer"
onClick={() => {
// Navigate to transactions page with wallet filter
window.location.href = `/transactions?wallet=${wallet.name}`
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
}}
>
{wallet.name}
@@ -694,10 +681,10 @@ export function Overview() {
</TableCell>
<TableCell className="text-center">
<button
className="hover:text-blue-600 hover:underline"
className="hover:text-blue-600 hover:underline cursor-pointer"
onClick={() => {
// Navigate to transactions page with wallet filter
window.location.href = `/transactions?wallet=${wallet.name}`
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
}}
>
{wallet.transactionCount}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { AuthLayout } from '@/components/layout/AuthLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { Loader2, Mail, Lock, User, AlertCircle } from 'lucide-react'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function Register() {
const navigate = useNavigate()
const { register } = useAuth()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
try {
await register(email, password, name || undefined)
// Registration successful, redirect to dashboard
navigate('/')
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
const handleGoogleSignup = () => {
// Redirect to backend Google OAuth endpoint
window.location.href = `${API_URL}/api/auth/google`
}
return (
<AuthLayout
title="Create Account"
description="Sign up for Tabungin to start managing your finances"
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Google Sign Up */}
<Button
type="button"
variant="outline"
className="w-full h-11"
onClick={handleGoogleSignup}
disabled={loading || !GOOGLE_CLIENT_ID}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
{/* Email/Password Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name (Optional)</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
disabled={loading}
minLength={8}
/>
</div>
<p className="text-xs text-muted-foreground">Must be at least 8 characters</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
required
disabled={loading}
/>
</div>
</div>
<Button type="submit" className="w-full h-11" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create Account'
)}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/auth/login" className="font-medium text-primary hover:underline">
Sign in
</Link>
</p>
</div>
</AuthLayout>
)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from "react"
import { useSearchParams } from "react-router-dom"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,7 +19,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter } from "lucide-react"
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter, X } from "lucide-react"
import { Label } from "@/components/ui/label"
import axios from "axios"
import { formatCurrency } from "@/constants/currencies"
import { formatLargeNumber } from "@/utils/numberFormat"
@@ -61,26 +63,31 @@ interface Transaction {
const API = "/api"
export function Transactions() {
const [searchParams] = useSearchParams()
const [wallets, setWallets] = useState<Wallet[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(true)
// Filters
const [searchTerm, setSearchTerm] = useState("")
// Filters - initialize from URL parameters
const [searchTerm, setSearchTerm] = useState<string>(searchParams.get("search") || "")
const [walletFilter, setWalletFilter] = useState<string>("all")
const [directionFilter, setDirectionFilter] = useState<string>("all")
const [amountMin, setAmountMin] = useState("")
const [amountMax, setAmountMax] = useState("")
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
const [directionFilter, setDirectionFilter] = useState<string>(searchParams.get("direction") || "all")
const [amountMin, setAmountMin] = useState<string>(searchParams.get("amountMin") || "")
const [amountMax, setAmountMax] = useState<string>(searchParams.get("amountMax") || "")
const [dateFrom, setDateFrom] = useState<Date | undefined>(
searchParams.get("dateFrom") ? new Date(searchParams.get("dateFrom")!) : undefined
)
const [dateTo, setDateTo] = useState<Date | undefined>(
searchParams.get("dateTo") ? new Date(searchParams.get("dateTo")!) : undefined
)
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false)
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null)
const [showFilters, setShowFilters] = useState(false)
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
useEffect(() => {
loadData()
loadExchangeRates()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const loadExchangeRates = async () => {
@@ -100,6 +107,19 @@ export function Transactions() {
const walletsRes = await axios.get(`${API}/wallets`)
const activeWallets = walletsRes.data.filter((w: Wallet) => !w.deletedAt)
setWallets(activeWallets)
// Set wallet filter from URL after wallets are loaded
const walletParam = searchParams.get("wallet")
if (walletParam) {
// Check if it's a wallet ID or name
const walletById = activeWallets.find((w: Wallet) => w.id === walletParam)
const walletByName = activeWallets.find((w: Wallet) => w.name === walletParam)
if (walletById) {
setWalletFilter(walletById.id)
} else if (walletByName) {
setWalletFilter(walletByName.id)
}
}
// Load transactions from all wallets
const transactionPromises = activeWallets.map((wallet: Wallet) =>
@@ -142,6 +162,16 @@ export function Transactions() {
setEditingTransaction(null)
}
const clearFilters = () => {
setSearchTerm("")
setWalletFilter("all")
setDirectionFilter("all")
setAmountMin("")
setAmountMax("")
setDateFrom(undefined)
setDateTo(undefined)
}
// Filter transactions
const filteredTransactions = useMemo(() => {
return transactions.filter(transaction => {
@@ -150,8 +180,10 @@ export function Transactions() {
const matchesSearch = !searchTerm ||
(transaction.memo?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
// Wallet filter
const matchesWallet = walletFilter === "all" || transaction.walletId === walletFilter
// Wallet filter - support both wallet ID and wallet name
const matchesWallet = walletFilter === "all" ||
transaction.walletId === walletFilter ||
wallets.find(w => w.id === transaction.walletId)?.name === walletFilter
// Direction filter
const matchesDirection = directionFilter === "all" || transaction.direction === directionFilter
@@ -167,7 +199,7 @@ export function Transactions() {
return matchesSearch && matchesWallet && matchesDirection && matchesAmount && matchesDate
})
}, [transactions, searchTerm, walletFilter, directionFilter, amountMin, amountMax, dateFrom, dateTo])
}, [transactions, wallets, searchTerm, walletFilter, directionFilter, amountMin, amountMax, dateFrom, dateTo])
// Calculate stats for filtered transactions
const stats = useMemo(() => {
@@ -249,21 +281,7 @@ export function Transactions() {
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Transactions</CardTitle>
<Receipt className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalTransactions}</div>
<p className="text-xs text-muted-foreground">
{transactions.length > filteredTransactions.length &&
`Filtered from ${transactions.length} total`
}
</p>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -304,30 +322,42 @@ export function Transactions() {
{/* Filters */}
{showFilters && (
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 text-xs"
>
<X className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<CardContent className="space-y-4">
{/* Row 1: Search, Wallet, Direction */}
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<div>
<label className="text-sm font-medium mb-2 block">Search Memo</label>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Search Memo</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search in memo..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
className="pl-9 h-9"
/>
</div>
</div>
{/* Wallet Filter */}
<div>
<label className="text-sm font-medium mb-2 block">Wallet</label>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Wallet</Label>
<Select value={walletFilter} onValueChange={setWalletFilter}>
<SelectTrigger>
<SelectTrigger className="h-9">
<SelectValue placeholder="All wallets" />
</SelectTrigger>
<SelectContent>
@@ -342,10 +372,10 @@ export function Transactions() {
</div>
{/* Direction Filter */}
<div>
<label className="text-sm font-medium mb-2 block">Direction</label>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Direction</Label>
<Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger>
<SelectTrigger className="h-9">
<SelectValue placeholder="All directions" />
</SelectTrigger>
<SelectContent>
@@ -355,72 +385,106 @@ export function Transactions() {
</SelectContent>
</Select>
</div>
</div>
{/* Amount Range */}
<div>
<label className="text-sm font-medium mb-2 block">Min Amount</label>
{/* Row 2: Amount Range */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Min Amount</Label>
<Input
type="number"
placeholder="0"
value={amountMin}
onChange={(e) => setAmountMin(e.target.value)}
className="h-9"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Max Amount</label>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Max Amount</Label>
<Input
type="number"
placeholder="No limit"
value={amountMax}
onChange={(e) => setAmountMax(e.target.value)}
className="h-9"
/>
</div>
</div>
{/* Date Range */}
<div>
<label className="text-sm font-medium mb-2 block">From Date</label>
{/* Row 3: Date Range */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">From Date</Label>
<DatePicker
date={dateFrom}
onDateChange={setDateFrom}
placeholder="Select start date"
className="w-full"
className="w-full h-9"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">To Date</label>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">To Date</Label>
<DatePicker
date={dateTo}
onDateChange={setDateTo}
placeholder="Select end date"
className="w-full"
className="w-full h-9"
/>
</div>
{/* Clear Filters */}
<div className="flex items-end">
<Button
variant="outline"
onClick={() => {
setSearchTerm("")
setWalletFilter("all")
setDirectionFilter("all")
setAmountMin("")
setAmountMax("")
setDateFrom(undefined)
setDateTo(undefined)
}}
className="w-full"
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Active Filters Badge */}
{(searchTerm || walletFilter !== "all" || directionFilter !== "all" || amountMin || amountMax || dateFrom || dateTo) && (
<div className="flex flex-wrap gap-2">
{searchTerm && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
<Search className="h-3 w-3" />
{searchTerm}
<button onClick={() => setSearchTerm("")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{walletFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Wallet: {wallets.find(w => w.id === walletFilter)?.name}
<button onClick={() => setWalletFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{directionFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
{directionFilter === "in" ? "Income" : "Expense"}
<button onClick={() => setDirectionFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{(amountMin || amountMax) && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Amount: {amountMin || "0"} - {amountMax || "∞"}
<button onClick={() => { setAmountMin(""); setAmountMax(""); }} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{(dateFrom || dateTo) && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Date: {dateFrom?.toLocaleDateString()} - {dateTo?.toLocaleDateString()}
<button onClick={() => { setDateFrom(undefined); setDateTo(undefined); }} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
)}
{/* Transactions Table */}
<Card>
<CardHeader>

View File

@@ -18,7 +18,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Plus, Search, Edit, Trash2, Wallet } from "lucide-react"
import { Plus, Search, Edit, Trash2, Wallet, Filter, X } from "lucide-react"
import { Label } from "@/components/ui/label"
import axios from "axios"
import { WalletDialog } from "@/components/dialogs/WalletDialog"
import {
@@ -50,7 +51,9 @@ export function Wallets() {
const [wallets, setWallets] = useState<Wallet[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [kindFilter, setKindFilter] = useState<string>("all")
const [currencyFilter, setCurrencyFilter] = useState<string>("all")
const [showFilters, setShowFilters] = useState(false)
const [walletDialogOpen, setWalletDialogOpen] = useState(false)
const [editingWallet, setEditingWallet] = useState<Wallet | null>(null)
@@ -90,14 +93,21 @@ export function Wallets() {
setEditingWallet(null)
}
const clearFilters = () => {
setSearchTerm("")
setKindFilter("all")
setCurrencyFilter("all")
}
// Filter wallets
const filteredWallets = useMemo(() => {
return wallets.filter(wallet => {
const matchesSearch = wallet.name.toLowerCase().includes(searchTerm.toLowerCase())
const matchesCurrency = currencyFilter === "all" || wallet.currency === currencyFilter
return matchesSearch && matchesCurrency
const matchesKind = kindFilter === "all" || wallet.kind === kindFilter
const matchesCurrency = currencyFilter === "all" || wallet.currency === currencyFilter || wallet.unit === currencyFilter
return matchesSearch && matchesKind && matchesCurrency
})
}, [wallets, searchTerm, currencyFilter])
}, [wallets, searchTerm, kindFilter, currencyFilter])
// Get unique currencies for filter
const availableCurrencies = useMemo(() => {
@@ -147,6 +157,10 @@ export function Wallets() {
</p>
</div>
<div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
<Filter className="mr-2 h-4 w-4" />
{showFilters ? 'Hide Filters' : 'Show Filters'}
</Button>
<Button onClick={() => setWalletDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Wallet
@@ -196,46 +210,113 @@ export function Wallets() {
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
{showFilters && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 text-xs"
>
<X className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<div className="flex-1">
<CardContent className="space-y-4">
{/* Row 1: Search, Type, Currency */}
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Search Wallet</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search wallets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
className="pl-9 h-9"
/>
</div>
</div>
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Currencies</SelectItem>
{availableCurrencies.map(currency => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Type Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Type</Label>
<Select value={kindFilter} onValueChange={setKindFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="money">Money</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
</SelectContent>
</Select>
</div>
{/* Currency Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Currency/Unit</Label>
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All currencies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Currencies/Units</SelectItem>
{availableCurrencies.map(currency => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</Card>
)}
{/* Active Filters Badge */}
{(searchTerm || kindFilter !== "all" || currencyFilter !== "all") && (
<div className="flex flex-wrap gap-2">
{searchTerm && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
<Search className="h-3 w-3" />
{searchTerm}
<button onClick={() => setSearchTerm("")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{kindFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Type: {kindFilter === "money" ? "Money" : "Asset"}
<button onClick={() => setKindFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
{currencyFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Currency: {currencyFilter}
<button onClick={() => setCurrencyFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
)}
{/* Wallets Table */}
<Card>
<CardHeader>
<CardTitle>Wallets ({filteredWallets.length})</CardTitle>
<CardDescription>
{searchTerm || currencyFilter !== "all"
{filteredWallets.length !== wallets.length
? `Filtered from ${wallets.length} total wallets`
: "All your wallets"
}
@@ -246,8 +327,8 @@ export function Wallets() {
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Currency/Unit</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
@@ -256,7 +337,7 @@ export function Wallets() {
{filteredWallets.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
{searchTerm || currencyFilter !== "all"
{filteredWallets.length !== wallets.length
? "No wallets match your filters"
: "No wallets found. Create your first wallet!"
}
@@ -266,6 +347,13 @@ export function Wallets() {
filteredWallets.map((wallet) => (
<TableRow key={wallet.id}>
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
<TableCell>
{wallet.kind === 'money' ? (
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
) : (
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
)}
</TableCell>
<TableCell>
<Badge
variant="outline"
@@ -274,13 +362,6 @@ export function Wallets() {
{wallet.kind}
</Badge>
</TableCell>
<TableCell>
{wallet.kind === 'money' ? (
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
) : (
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
)}
</TableCell>
<TableCell>
{new Date(wallet.createdAt).toLocaleDateString()}
</TableCell>

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -145,7 +145,7 @@ const SidebarProvider = React.forwardRef<
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar bg-background",
className
)}
ref={ref}
@@ -185,7 +185,7 @@ const Sidebar = React.forwardRef<
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
"flex h-full w-[--sidebar-width] flex-col bg-sidebar bg-background text-sidebar-foreground",
className
)}
ref={ref}
@@ -202,7 +202,7 @@ const Sidebar = React.forwardRef<
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
className="w-[--sidebar-width] bg-sidebar bg-background p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -256,7 +256,7 @@ const Sidebar = React.forwardRef<
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
className="flex h-full w-full flex-col bg-sidebar bg-background group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
@@ -308,10 +308,10 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar bg-background-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar bg-background",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
@@ -396,7 +396,7 @@ const SidebarSeparator = React.forwardRef<
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
className={cn("mx-2 w-auto bg-sidebar bg-background-border", className)}
{...props}
/>
)
@@ -468,7 +468,7 @@ const SidebarGroupAction = React.forwardRef<
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
@@ -520,13 +520,13 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar bg-background-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar bg-background-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar bg-background-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: "hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
@@ -614,7 +614,7 @@ const SidebarMenuAction = React.forwardRef<
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
@@ -730,8 +730,8 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar bg-background-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar bg-background-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,158 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface User {
id: string
email: string
name: string | null
avatarUrl: string | null
emailVerified: boolean
}
interface LoginResponse {
requiresOtp?: boolean
availableMethods?: {
email: boolean
totp: boolean
}
tempToken?: string
token?: string
user?: User
}
interface AuthContextType {
user: User | null
token: string | null
loading: boolean
login: (email: string, password: string) => Promise<LoginResponse>
register: (email: string, password: string, name?: string) => Promise<{ token: string; user: User }>
logout: () => void
verifyOtp: (tempToken: string, code: string, method: 'email' | 'whatsapp' | 'totp') => Promise<{ token: string; user: User }>
updateUser: (updates: Partial<User>) => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
// Load token from localStorage on mount
useEffect(() => {
const storedToken = localStorage.getItem('token')
if (storedToken) {
setToken(storedToken)
fetchUser(storedToken)
} else {
setLoading(false)
}
}, [])
// Set axios default auth header when token changes
useEffect(() => {
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
} else {
delete axios.defaults.headers.common['Authorization']
}
}, [token])
const fetchUser = async (authToken: string) => {
try {
const response = await axios.get(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${authToken}` }
})
setUser(response.data)
} catch (error) {
console.error('Failed to fetch user:', error)
// Token might be invalid, clear it
localStorage.removeItem('token')
setToken(null)
} finally {
setLoading(false)
}
}
const login = async (email: string, password: string) => {
const response = await axios.post(`${API_URL}/api/auth/login`, {
email,
password
})
if (response.data.requiresOtp) {
// Return OTP requirement info
return response.data
}
// No OTP required, set token and user
const { token: authToken, user: userData } = response.data
setToken(authToken)
setUser(userData)
localStorage.setItem('token', authToken)
return response.data
}
const register = async (email: string, password: string, name?: string) => {
const response = await axios.post(`${API_URL}/api/auth/register`, {
email,
password,
name
})
const { token: authToken, user: userData } = response.data
setToken(authToken)
setUser(userData)
localStorage.setItem('token', authToken)
return response.data
}
const verifyOtp = async (tempToken: string, code: string, method: 'email' | 'whatsapp' | 'totp') => {
const response = await axios.post(`${API_URL}/api/auth/verify-otp`, {
tempToken,
otpCode: code,
method
})
const { token: authToken, user: userData } = response.data
setToken(authToken)
setUser(userData)
localStorage.setItem('token', authToken)
return response.data
}
const logout = () => {
setToken(null)
setUser(null)
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
}
const updateUser = (updates: Partial<User>) => {
if (user) {
setUser({ ...user, ...updates })
}
}
return (
<AuthContext.Provider value={{ user, token, loading, login, register, logout, verifyOtp, updateUser }}>
{children}
</AuthContext.Provider>
)
}
// Export useAuth hook separately to avoid fast-refresh issues
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -1,127 +0,0 @@
import { useState, useEffect } from 'react';
import {
type User,
onAuthStateChanged,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signInWithPopup,
signOut as firebaseSignOut
} from 'firebase/auth';
import { auth, googleProvider } from '../lib/firebase';
export const useAuth = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return () => unsubscribe();
}, []);
const signIn = async (email: string, password: string) => {
try {
setError(null);
setLoading(true);
await signInWithEmailAndPassword(auth, email, password);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An error occurred';
setError(message);
throw error;
} finally {
setLoading(false);
}
};
const signUp = async (email: string, password: string) => {
try {
setError(null);
setLoading(true);
await createUserWithEmailAndPassword(auth, email, password);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An error occurred';
setError(message);
throw error;
} finally {
setLoading(false);
}
};
const signInWithGoogle = async () => {
try {
setError(null);
setLoading(true);
const result = await signInWithPopup(auth, googleProvider);
console.log('✅ Google sign-in successful:', result.user.email);
} catch (error: unknown) {
// Handle user cancellation gracefully
const errorWithCode = error as { code?: string; message?: string };
if (errorWithCode.code === 'auth/popup-closed-by-user' ||
errorWithCode.code === 'auth/cancelled-popup-request') {
// User cancelled - don't show error, just reset loading
console.log(' User cancelled Google sign-in');
setError(null);
return;
}
// Handle common Firebase auth errors
let userFriendlyMessage = 'Failed to sign in with Google';
switch (errorWithCode.code) {
case 'auth/popup-blocked':
userFriendlyMessage = 'Popup was blocked. Please allow popups for this site and try again.';
break;
case 'auth/network-request-failed':
userFriendlyMessage = 'Network error. Please check your internet connection.';
break;
case 'auth/too-many-requests':
userFriendlyMessage = 'Too many failed attempts. Please try again later.';
break;
case 'auth/configuration-not-found':
userFriendlyMessage = 'Google sign-in is not properly configured. Please contact support.';
break;
default:
userFriendlyMessage = errorWithCode.message || 'An error occurred during Google sign-in';
}
console.error('❌ Google sign-in error:', errorWithCode);
setError(userFriendlyMessage);
throw error;
} finally {
setLoading(false);
}
};
const signOut = async () => {
try {
setError(null);
await firebaseSignOut(auth);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An error occurred';
setError(message);
throw error;
}
};
const getIdToken = async () => {
if (user) {
return await user.getIdToken();
}
return null;
};
return {
user,
loading,
error,
signIn,
signUp,
signInWithGoogle,
signOut,
getIdToken,
};
};

View File

@@ -1,49 +0,0 @@
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
// Validate required environment variables
const requiredEnvVars = [
'VITE_FIREBASE_API_KEY',
'VITE_FIREBASE_AUTH_DOMAIN',
'VITE_FIREBASE_PROJECT_ID',
'VITE_FIREBASE_STORAGE_BUCKET',
'VITE_FIREBASE_MESSAGING_SENDER_ID',
'VITE_FIREBASE_APP_ID'
];
const missingVars = requiredEnvVars.filter(varName => !import.meta.env[varName]);
if (missingVars.length > 0) {
console.error('❌ Missing Firebase environment variables:', missingVars);
console.error('Please check your .env.local file and ensure all Firebase config variables are set.');
console.error('See .env.example for the required variables.');
}
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
// Initialize Firebase Authentication and get a reference to the service
export const auth = getAuth(app);
// Initialize Google Auth Provider with additional configuration
export const googleProvider = new GoogleAuthProvider();
// Configure Google provider for better UX
googleProvider.setCustomParameters({
prompt: 'select_account', // Always show account selection
});
// Add additional scopes if needed
googleProvider.addScope('email');
googleProvider.addScope('profile');
export default app;

View File

@@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function getAvatarUrl(avatarUrl: string | null | undefined): string | null {
if (!avatarUrl) return null
// If it's already a full URL (starts with http), return as is
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
return avatarUrl
}
// If it's a relative path (starts with /), prepend API URL
if (avatarUrl.startsWith('/')) {
return `${API_URL}${avatarUrl}`
}
return avatarUrl
}

View File

@@ -10,10 +10,10 @@ export default defineConfig({
},
},
server: {
port: 5173,
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
target: 'http://localhost:3001',
changeOrigin: true
}
}