diff --git a/backend/src/controllers/ai.controller.ts b/backend/src/controllers/ai.controller.ts index dbd7438..a38497e 100644 --- a/backend/src/controllers/ai.controller.ts +++ b/backend/src/controllers/ai.controller.ts @@ -1,8 +1,8 @@ -import { Response } from 'express' +import { NextFunction, Response } from 'express' import { AuthRequest } from '../middlewares/auth.middleware' import * as aiService from '../services/ai.service' -export const chat = async (req: AuthRequest, res: Response) => { +export const chat = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const { message } = req.body if (!message || typeof message !== 'string' || message.trim().length === 0) { @@ -12,15 +12,17 @@ export const chat = async (req: AuthRequest, res: Response) => { const result = await aiService.chat(req.user!.id, message.trim()) res.status(200).json(result) } catch (error: any) { - res.status(500).json({ message: error.message }) + res.status(500) + next(error) } } -export const getInsights = async (req: AuthRequest, res: Response) => { +export const getInsights = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const result = await aiService.getInsights(req.user!.id) res.status(200).json(result) } catch (error: any) { - res.status(500).json({ message: error.message }) + res.status(500) + next(error) } } diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index c34936d..f696d50 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -1,9 +1,9 @@ -import { Request, Response } from 'express' +import { NextFunction, Request, Response } from 'express' import * as authService from '../services/auth.service' -import { registerSchema, loginSchema } from '../validators/auth.validator' +import { registerSchema, loginSchema, updateProfileSchema } from '../validators/auth.validator' import { AuthRequest } from '../middlewares/auth.middleware' -export const register = async (req: Request, res: Response) => { +export const register = async (req: Request, res: Response, next: NextFunction) => { try { const parsed = registerSchema.safeParse(req.body) if (!parsed.success) { @@ -27,12 +27,12 @@ export const register = async (req: Request, res: Response) => { token: result.token, }) } catch (error: any) { - const status = error.message === 'User already exists' ? 409 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'User already exists' ? 409 : 400) + next(error) } } -export const login = async (req: Request, res: Response) => { +export const login = async (req: Request, res: Response, next: NextFunction) => { try { const parsed = loginSchema.safeParse(req.body) if (!parsed.success) { @@ -56,16 +56,37 @@ export const login = async (req: Request, res: Response) => { token: result.token, }) } catch (error: any) { - res.status(401).json({ message: error.message }) + res.status(401) + next(error) } } -export const getMe = async (req: AuthRequest, res: Response) => { +export const getMe = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const userId = req.user!.id const user = await authService.getMe(userId) res.status(200).json({ user }) } catch (error: any) { - res.status(404).json({ message: error.message }) + res.status(404) + next(error) } } + +export const updateProfile = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const parsed = updateProfileSchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ message: parsed.error.issues[0].message }) + } + + const user = await authService.updateProfile(req.user!.id, parsed.data.name.trim()) + res.status(200).json({ message: 'Profile updated', user }) + } catch (error: any) { + res.status(400) + next(error) + } +} + +export const updateAvatarPlaceholder = async (_req: AuthRequest, res: Response) => { + res.status(501).json({ message: 'Profile photo upload is not implemented yet' }) +} diff --git a/backend/src/controllers/goal.controller.ts b/backend/src/controllers/goal.controller.ts index 8b2898b..e29bc2d 100644 --- a/backend/src/controllers/goal.controller.ts +++ b/backend/src/controllers/goal.controller.ts @@ -1,9 +1,9 @@ -import { Response } from 'express' +import { NextFunction, Response } from 'express' import { AuthRequest } from '../middlewares/auth.middleware' import * as goalService from '../services/goal.service' import { createGoalSchema, updateGoalSchema, contributeSchema } from '../validators/goal.validator' -export const create = async (req: AuthRequest, res: Response) => { +export const create = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const parsed = createGoalSchema.safeParse(req.body) if (!parsed.success) { @@ -12,29 +12,32 @@ export const create = async (req: AuthRequest, res: Response) => { const goal = await goalService.create(req.user!.id, parsed.data) res.status(201).json({ message: 'Goal created', goal }) } catch (error: any) { - res.status(400).json({ message: error.message }) + res.status(400) + next(error) } } -export const getAll = async (req: AuthRequest, res: Response) => { +export const getAll = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const goals = await goalService.getAll(req.user!.id) res.status(200).json({ goals }) } catch (error: any) { - res.status(400).json({ message: error.message }) + res.status(400) + next(error) } } -export const getById = async (req: AuthRequest, res: Response) => { +export const getById = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const goal = await goalService.getById(req.user!.id, req.params.id as string) res.status(200).json({ goal }) } catch (error: any) { - res.status(404).json({ message: error.message }) + res.status(404) + next(error) } } -export const update = async (req: AuthRequest, res: Response) => { +export const update = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const parsed = updateGoalSchema.safeParse(req.body) if (!parsed.success) { @@ -43,22 +46,22 @@ export const update = async (req: AuthRequest, res: Response) => { const goal = await goalService.update(req.user!.id, req.params.id as string, parsed.data) res.status(200).json({ message: 'Goal updated', goal }) } catch (error: any) { - const status = error.message === 'Goal not found' ? 404 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'Goal not found' ? 404 : 400) + next(error) } } -export const remove = async (req: AuthRequest, res: Response) => { +export const remove = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const result = await goalService.remove(req.user!.id, req.params.id as string) res.status(200).json(result) } catch (error: any) { - const status = error.message === 'Goal not found' ? 404 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'Goal not found' ? 404 : 400) + next(error) } } -export const contribute = async (req: AuthRequest, res: Response) => { +export const contribute = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const parsed = contributeSchema.safeParse(req.body) if (!parsed.success) { @@ -67,7 +70,7 @@ export const contribute = async (req: AuthRequest, res: Response) => { const goal = await goalService.contribute(req.user!.id, req.params.id as string, parsed.data.amount) res.status(200).json({ message: 'Contribution added', goal }) } catch (error: any) { - const status = error.message === 'Goal not found' ? 404 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'Goal not found' ? 404 : 400) + next(error) } } diff --git a/backend/src/controllers/passport.controller.ts b/backend/src/controllers/passport.controller.ts index f582e01..c8096aa 100644 --- a/backend/src/controllers/passport.controller.ts +++ b/backend/src/controllers/passport.controller.ts @@ -1,21 +1,23 @@ -import { Response } from 'express' +import { NextFunction, Response } from 'express' import { AuthRequest } from '../middlewares/auth.middleware' import * as passportService from '../services/passport.service' -export const getPassport = async (req: AuthRequest, res: Response) => { +export const getPassport = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const passport = await passportService.getPassport(req.user!.id) res.status(200).json({ passport }) } catch (error: any) { - res.status(500).json({ message: error.message }) + res.status(500) + next(error) } } -export const getBreakdown = async (req: AuthRequest, res: Response) => { +export const getBreakdown = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const breakdown = await passportService.getBreakdown(req.user!.id) res.status(200).json({ breakdown }) } catch (error: any) { - res.status(500).json({ message: error.message }) + res.status(500) + next(error) } } diff --git a/backend/src/controllers/transaction.controller.ts b/backend/src/controllers/transaction.controller.ts index 0ac8750..cf6c55d 100644 --- a/backend/src/controllers/transaction.controller.ts +++ b/backend/src/controllers/transaction.controller.ts @@ -1,9 +1,9 @@ -import { Response } from 'express' +import { NextFunction, Response } from 'express' import { AuthRequest } from '../middlewares/auth.middleware' import * as transactionService from '../services/transaction.service' import { createTransactionSchema, updateTransactionSchema } from '../validators/transaction.validator' -export const create = async (req: AuthRequest, res: Response) => { +export const create = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const parsed = createTransactionSchema.safeParse(req.body) if (!parsed.success) { @@ -12,11 +12,12 @@ export const create = async (req: AuthRequest, res: Response) => { const transaction = await transactionService.create(req.user!.id, parsed.data) res.status(201).json({ message: 'Transaction created', transaction }) } catch (error: any) { - res.status(400).json({ message: error.message }) + res.status(400) + next(error) } } -export const getAll = async (req: AuthRequest, res: Response) => { +export const getAll = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const filters = { type: req.query.type as string, @@ -29,20 +30,22 @@ export const getAll = async (req: AuthRequest, res: Response) => { const result = await transactionService.getAll(req.user!.id, filters) res.status(200).json(result) } catch (error: any) { - res.status(400).json({ message: error.message }) + res.status(400) + next(error) } } -export const getById = async (req: AuthRequest, res: Response) => { +export const getById = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const transaction = await transactionService.getById(req.user!.id, req.params.id as string) res.status(200).json({ transaction }) } catch (error: any) { - res.status(404).json({ message: error.message }) + res.status(404) + next(error) } } -export const update = async (req: AuthRequest, res: Response) => { +export const update = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const parsed = updateTransactionSchema.safeParse(req.body) if (!parsed.success) { @@ -51,26 +54,27 @@ export const update = async (req: AuthRequest, res: Response) => { const transaction = await transactionService.update(req.user!.id, req.params.id as string, parsed.data) res.status(200).json({ message: 'Transaction updated', transaction }) } catch (error: any) { - const status = error.message === 'Transaction not found' ? 404 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'Transaction not found' ? 404 : 400) + next(error) } } -export const remove = async (req: AuthRequest, res: Response) => { +export const remove = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const result = await transactionService.remove(req.user!.id, req.params.id as string) res.status(200).json(result) } catch (error: any) { - const status = error.message === 'Transaction not found' ? 404 : 400 - res.status(status).json({ message: error.message }) + res.status(error.message === 'Transaction not found' ? 404 : 400) + next(error) } } -export const getSummary = async (req: AuthRequest, res: Response) => { +export const getSummary = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const summary = await transactionService.getSummary(req.user!.id) res.status(200).json({ summary }) } catch (error: any) { - res.status(400).json({ message: error.message }) + res.status(400) + next(error) } } diff --git a/backend/src/middlewares/error.middleware.ts b/backend/src/middlewares/error.middleware.ts index fd1954c..750d956 100644 --- a/backend/src/middlewares/error.middleware.ts +++ b/backend/src/middlewares/error.middleware.ts @@ -1,12 +1,40 @@ -import { Request, Response, NextFunction } from 'express' +import { NextFunction, Request, Response } from 'express' +import { Prisma } from '@prisma/client' -export const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { - console.error(`[Error] ${req.method} ${req.path}:`, err.message) +const CONNECTION_ERROR_MESSAGE = 'Connection error. Please check your internet connection.' +function isPrismaConnectionError(err: unknown) { + if ( + err instanceof Prisma.PrismaClientInitializationError || + err instanceof Prisma.PrismaClientRustPanicError + ) { + return true + } + + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return ['P1000', 'P1001', 'P1002', 'P1017'].includes(err.code) + } + + if (err instanceof Error) { + return /prisma|database|connection/i.test(err.message) + } + + return false +} + +export const errorHandler = (err: unknown, req: Request, res: Response, _next: NextFunction) => { const statusCode = res.statusCode !== 200 ? res.statusCode : 500 + const isConnectionError = isPrismaConnectionError(err) + + console.error(`[Error] ${req.method} ${req.path}:`, err) + + if (isConnectionError || statusCode >= 500) { + return res.status(isConnectionError ? 503 : 500).json({ + message: CONNECTION_ERROR_MESSAGE, + }) + } + + const message = err instanceof Error && err.message ? err.message : 'Something went wrong. Please try again.' - res.status(statusCode).json({ - message: err.message || 'Internal server error', - ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }), - }) + return res.status(statusCode).json({ message }) } diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 8a7d0e0..301623b 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -7,5 +7,7 @@ const router = Router() router.post('/register', authController.register) router.post('/login', authController.login) router.get('/me', protect, authController.getMe) +router.put('/profile', protect, authController.updateProfile) +router.put('/profile/avatar', protect, authController.updateAvatarPlaceholder) -export default router \ No newline at end of file +export default router diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 526eb6d..6147d35 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -86,4 +86,19 @@ export const getMe = async ( userId: string ) => { } return user -} \ No newline at end of file +} + +export const updateProfile = async (userId: string, name: string) => { + const user = await prisma.user.update({ + where: { id: userId }, + data: { name }, + select: { + id: true, + name: true, + email: true, + createdAt: true + } + }) + + return user +} diff --git a/backend/src/services/goal.service.ts b/backend/src/services/goal.service.ts index dc48a62..1fc593c 100644 --- a/backend/src/services/goal.service.ts +++ b/backend/src/services/goal.service.ts @@ -52,6 +52,9 @@ export const getById = async (userId: string, id: string) => { export const update = async (userId: string, id: string, data: Partial) => { const existing = await prisma.goal.findFirst({ where: { id, userId } }) if (!existing) throw new Error('Goal not found') + if (data.targetAmount !== undefined && data.targetAmount < Number(existing.currentAmount)) { + throw new Error('Target amount cannot be lower than the amount already contributed') + } const goal = await prisma.goal.update({ where: { id }, @@ -61,7 +64,14 @@ export const update = async (userId: string, id: string, data: Partial 0 + ? Math.min(100, Math.round((Number(goal.currentAmount) / Number(goal.targetAmount)) * 100)) + : 0, + } } export const remove = async (userId: string, id: string) => { @@ -77,6 +87,10 @@ export const contribute = async (userId: string, id: string, amount: number) => if (!existing) throw new Error('Goal not found') const newAmount = Number(existing.currentAmount) + amount + if (newAmount > Number(existing.targetAmount)) { + throw new Error('Contribution cannot exceed the goal target') + } + const goal = await prisma.goal.update({ where: { id }, data: { currentAmount: new Prisma.Decimal(newAmount) }, diff --git a/backend/src/services/passport.service.ts b/backend/src/services/passport.service.ts index 6144adf..52dd718 100644 --- a/backend/src/services/passport.service.ts +++ b/backend/src/services/passport.service.ts @@ -41,6 +41,7 @@ export const calculateScore = async (userId: string) => { } } const savingsStreak = Math.min(30, daysWithSavings.size) + const completedGoals = goals.filter((goal) => Number(goal.currentAmount) >= Number(goal.targetAmount)) // Budget adherence: income > expense ratio (max 100%) let totalIncome = 0 @@ -90,7 +91,16 @@ export const calculateScore = async (userId: string) => { }, }) - return { score: clampedScore, tier, badges, savingsStreak, budgetAdherence, incomeConsistency } + return { + score: clampedScore, + tier, + badges, + savingsStreak, + budgetAdherence, + incomeConsistency, + completedGoalsCount: completedGoals.length, + hasCompletedGoal: completedGoals.length > 0, + } } export const getPassport = async (userId: string) => { diff --git a/backend/src/validators/auth.validator.ts b/backend/src/validators/auth.validator.ts index baaf9b2..5e5fe86 100644 --- a/backend/src/validators/auth.validator.ts +++ b/backend/src/validators/auth.validator.ts @@ -10,3 +10,7 @@ export const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(1, 'Password is required'), }) + +export const updateProfileSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters').max(50), +}) diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index 0fccc97..770f369 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -81,9 +81,9 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
P
-

PayPath

+

PayPath

-

+

Your AI-powered financial coach

diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 670108d..e437722 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -190,40 +190,4 @@ .animate-pulse-dot { animation: pulse-dot 2s ease-in-out infinite; } - .avatar-glow { - position: relative; - } - .avatar-glow::before { - content: ""; - position: absolute; - inset: -3px; - border-radius: inherit; - padding: 2px; - background: conic-gradient( - from 0deg, - oklch(0.88 0.22 120 / 0.15), - oklch(0.88 0.22 120 / 0.9), - oklch(0.70 0.16 145 / 0.35), - oklch(0.88 0.22 120 / 0.15) - ); - -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - animation: avatar-orbit 2.2s linear infinite; - } - .avatar-glow::after { - content: ""; - position: absolute; - inset: -6px; - border-radius: inherit; - background: radial-gradient(circle, oklch(0.88 0.22 120 / 0.18), transparent 60%); - opacity: 0.8; - pointer-events: none; - } -} - -@keyframes avatar-orbit { - to { - transform: rotate(360deg); - } } diff --git a/frontend/app/goals/page.tsx b/frontend/app/goals/page.tsx index bd32466..5aafa66 100644 --- a/frontend/app/goals/page.tsx +++ b/frontend/app/goals/page.tsx @@ -7,6 +7,7 @@ import AppShell from "@/components/layout/AppShell"; import Header from "@/components/layout/Header"; import AddGoalDialog from "@/components/goals/AddGoalDialog"; import EditGoalDialog from "@/components/goals/EditGoalDialog"; +import ContributeGoalDialog from "@/components/goals/ContributeGoalDialog"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -53,25 +54,16 @@ function isGoalOverdue(goal: Goal) { } export default function GoalsPage() { - const { goals, addFunds, deleteGoal, fetchGoals, isLoading, error } = useGoalStore(); + const { goals, deleteGoal, fetchGoals, isLoading, error } = useGoalStore(); const [addOpen, setAddOpen] = useState(false); const [editGoal, setEditGoal] = useState(null); + const [contributeGoal, setContributeGoal] = useState(null); const [deleteGoalTarget, setDeleteGoalTarget] = useState(null); useEffect(() => { fetchGoals(); }, [fetchGoals]); - const handleAddFunds = async (goal: Goal) => { - const amount = Math.round(goal.targetAmount * 0.1); - try { - await addFunds(goal.id, amount); - toast.success(`Added ${formatCurrency(amount)} to ${goal.name}`); - } catch { - toast.error("Failed to add funds"); - } - }; - const handleDeleteGoal = async (goal: Goal) => { try { await deleteGoal(goal.id); @@ -157,11 +149,22 @@ export default function GoalsPage() { ) : ( /* Goal cards */ -
+
{goals.map((goal, i) => { - const percentage = Math.round((goal.currentAmount / goal.targetAmount) * 100); + const percentage = goal.progress ?? Math.round((goal.currentAmount / goal.targetAmount) * 100); const isComplete = percentage >= 100; const isOverdue = isGoalOverdue(goal); + const statusLabel = isComplete ? "Done" : isOverdue ? "Overdue" : "Ongoing"; + const statusClassName = isComplete + ? "bg-primary/10 text-primary" + : isOverdue + ? "bg-destructive/10 text-destructive" + : "bg-muted text-muted-foreground"; + const progressClassName = isOverdue + ? "h-3 rounded-full [&_[data-slot=progress-indicator]]:bg-destructive" + : isComplete + ? "h-3 rounded-full [&_[data-slot=progress-indicator]]:bg-primary" + : "h-3 rounded-full [&_[data-slot=progress-indicator]]:bg-muted-foreground"; return (
-
+

{goal.name}

- + {!isComplete && ( + + )}
- {isOverdue && ( - - - Overdue - - )} - + + {isOverdue && } + {statusLabel} + + {percentage}%
@@ -211,10 +214,7 @@ export default function GoalsPage() {
- +
@@ -226,7 +226,7 @@ export default function GoalsPage() {
)}
+ { + if (!open) setContributeGoal(null); + }} + /> ) { return ( - + {children} diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx new file mode 100644 index 0000000..8e52c8d --- /dev/null +++ b/frontend/app/notifications/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect } from "react"; +import AppShell from "@/components/layout/AppShell"; +import Header from "@/components/layout/Header"; +import { useNotificationStore } from "@/store/notificationStore"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Notification01Icon, Tick01Icon, Delete01Icon } from "@hugeicons/core-free-icons"; + +const typeStyles = { + success: "bg-primary/10 text-primary", + warning: "bg-yellow-400/10 text-yellow-400", + info: "bg-blue-400/10 text-blue-400", +}; + +export default function NotificationsPage() { + const { notifications, markAllRead, clearAll } = useNotificationStore(); + + useEffect(() => { + if (notifications.some((notification) => !notification.read)) { + markAllRead(); + } + }, [markAllRead, notifications]); + + return ( + +
+
+
+
+

All Notifications

+

+ {notifications.length === 0 ? "No notifications yet." : `${notifications.length} notification${notifications.length === 1 ? "" : "s"}`} +

+
+ {notifications.length > 0 && ( +
+ +
+ )} +
+ + {notifications.length === 0 ? ( + + + +
+

No notifications yet

+

Activity alerts will appear here as they come in.

+
+
+
+ ) : ( +
+ {notifications.map((notification) => ( + + + +
+ +
+
+
+ {notification.title} + + {new Date(notification.createdAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+
+
+
+ +

{notification.message}

+
+
+ ))} +
+ )} + + {notifications.length > 0 && ( +
+ +
+ )} +
+ + ); +} diff --git a/frontend/app/passport/page.tsx b/frontend/app/passport/page.tsx index deab581..55c2fa6 100644 --- a/frontend/app/passport/page.tsx +++ b/frontend/app/passport/page.tsx @@ -23,6 +23,8 @@ interface PassportData { savingsStreak: number; budgetAdherence: number; incomeConsistency: number; + hasCompletedGoal: boolean; + completedGoalsCount: number; } interface BreakdownFactor { @@ -33,11 +35,14 @@ interface BreakdownFactor { } const badgeConfig = [ - { name: "Saver", icon: DollarCircleIcon }, - { name: "Budgeter", icon: CreditCardIcon }, - { name: "Investor", icon: Award01Icon }, + { name: "Starter", icon: DollarCircleIcon }, + { name: "Builder", icon: CreditCardIcon }, + { name: "Stable", icon: Award01Icon }, + { name: "Trusted", icon: CheckmarkCircle01Icon }, ]; +const tierOrder = badgeConfig.map((badge) => badge.name); + export default function PassportPage() { const [passport, setPassport] = useState(null); const [factors, setFactors] = useState([]); @@ -64,6 +69,7 @@ export default function PassportPage() { const score = passport?.score ?? 0; const tier = passport?.tier ?? "Starter"; const earnedBadges = passport?.badges ?? []; + const hasCompletedGoal = passport?.hasCompletedGoal ?? false; const circumference = 2 * Math.PI * 78; const dashOffset = circumference * (1 - score / 100); @@ -72,7 +78,7 @@ export default function PassportPage() { { label: "Log 10 transactions", done: earnedBadges.includes("Logger") }, { label: "Chat with AI Coach", done: score > 20 }, { label: "Maintain a budget for 30 days", done: earnedBadges.includes("Streak Master") }, - { label: "Reach your first goal", done: earnedBadges.includes("Trusted") }, + { label: "Reach your first goal", done: hasCompletedGoal }, ]; return ( @@ -169,7 +175,9 @@ export default function PassportPage() { className="flex justify-center gap-6" > {badgeConfig.map((badge) => { - const isEarned = earnedBadges.includes(badge.name); + const currentTierIndex = tierOrder.indexOf(tier); + const badgeTierIndex = tierOrder.indexOf(badge.name); + const isEarned = currentTierIndex >= badgeTierIndex; return (
state.user); - const { theme, setTheme } = useThemeStore(); const [editOpen, setEditOpen] = useState(false); - const [pushNotif, setPushNotif] = useState(true); - const [emailNotif, setEmailNotif] = useState(false); + const [pushNotif, setPushNotif] = useState(() => { + if (typeof window === "undefined") return true; + const storedValue = localStorage.getItem("paypath_push_notifications"); + return storedValue === null ? true : storedValue === "true"; + }); + const [emailNotif, setEmailNotif] = useState(() => { + if (typeof window === "undefined") return false; + const storedValue = localStorage.getItem("paypath_email_notifications"); + return storedValue === null ? false : storedValue === "true"; + }); const [logoutConfirm, setLogoutConfirm] = useState(false); @@ -53,6 +50,14 @@ export default function SettingsPage() { }) : "—"; + useEffect(() => { + localStorage.setItem("paypath_push_notifications", String(pushNotif)); + }, [pushNotif]); + + useEffect(() => { + localStorage.setItem("paypath_email_notifications", String(emailNotif)); + }, [emailNotif]); + return (
@@ -91,33 +96,6 @@ export default function SettingsPage() { - {/* Appearance */} - - - - Appearance - - -
- {themes.map((t) => ( - - ))} -
-
-
-
- {/* Notifications */} diff --git a/frontend/app/transactions/page.tsx b/frontend/app/transactions/page.tsx index d53c7d1..52183c9 100644 --- a/frontend/app/transactions/page.tsx +++ b/frontend/app/transactions/page.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { motion } from "motion/react"; -import { toast } from "sonner"; import { HugeiconsIcon } from "@hugeicons/react"; import { Activity01Icon, @@ -10,7 +9,6 @@ import { Briefcase01Icon, Bus01Icon, CreditCardIcon, - Delete01Icon, Invoice01Icon, MoneyReceive01Icon, Restaurant01Icon, @@ -62,7 +60,7 @@ function formatDate(dateStr: string) { } export default function TransactionsPage() { - const { filter, setFilter, getFiltered, deleteTransaction, fetchTransactions, isLoading, error } = useTransactionStore(); + const { filter, setFilter, getFiltered, fetchTransactions, isLoading, error } = useTransactionStore(); const transactions = getFiltered(); const [addOpen, setAddOpen] = useState(false); const [selectedTransaction, setSelectedTransaction] = useState(null); @@ -71,45 +69,41 @@ export default function TransactionsPage() { fetchTransactions(); }, [fetchTransactions]); - const handleDelete = async (tx: Transaction) => { - try { - await deleteTransaction(tx.id); - toast.success("Transaction deleted"); - - if (selectedTransaction?.id === tx.id) { - setSelectedTransaction(null); - } - } catch { - toast.error("Failed to delete transaction"); - } - }; - const filters = ["all", "income", "expense"] as const; return (
-
- {filters.map((value) => ( - - ))} -
+
+
+

Transactions

+
+ +
+
+ {filters.map((value) => ( + + ))} +
{/* Error state */} @@ -199,18 +193,6 @@ export default function TransactionsPage() { {tx.type === "income" ? "+" : "-"} {formatCurrency(tx.amount)} - - ); })} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index 6b5773b..4e896dc 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -33,10 +33,10 @@ export default function LoginForm() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, delay: 0.15 }} > -
+
-

Welcome back

-

Sign in to your PayPath account

+

Welcome back

+

Sign in to your PayPath account

@@ -68,7 +68,6 @@ export default function LoginForm() { value={email} onChange={(e) => setEmail(e.target.value)} required - autoComplete="email" className="pl-11" />
@@ -85,13 +84,12 @@ export default function LoginForm() { value={password} onChange={(e) => setPassword(e.target.value)} required - autoComplete="current-password" className="pl-11" />
- @@ -102,7 +100,7 @@ export default function LoginForm() { or
-

+

Don't have an account?{" "} Sign up diff --git a/frontend/components/auth/RegisterForm.tsx b/frontend/components/auth/RegisterForm.tsx index 66acab6..399828f 100644 --- a/frontend/components/auth/RegisterForm.tsx +++ b/frontend/components/auth/RegisterForm.tsx @@ -76,10 +76,10 @@ export default function RegisterForm() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, delay: 0.15 }} > -

+
-

Create your account

-

Start your financial journey with PayPath

+

Create your account

+

Start your financial journey with PayPath

@@ -117,7 +117,6 @@ export default function RegisterForm() { value={name} onChange={(e) => setName(e.target.value)} required - autoComplete="name" className="pl-11" />
@@ -134,7 +133,6 @@ export default function RegisterForm() { value={email} onChange={(e) => setEmail(e.target.value)} required - autoComplete="email" className="pl-11" />
@@ -151,7 +149,6 @@ export default function RegisterForm() { value={password} onChange={(e) => setPassword(e.target.value)} required - autoComplete="new-password" className="pl-11" />
@@ -161,9 +158,8 @@ export default function RegisterForm() { {[0, 1, 2, 3].map((i) => (
))}
@@ -185,14 +181,13 @@ export default function RegisterForm() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required - autoComplete="new-password" className="pl-11" />
- @@ -204,7 +199,7 @@ export default function RegisterForm() { or - + Already have an account?{" "} Sign in diff --git a/frontend/components/auth/ThemeProvider.tsx b/frontend/components/auth/ThemeProvider.tsx index 8e26b99..7e8223b 100644 --- a/frontend/components/auth/ThemeProvider.tsx +++ b/frontend/components/auth/ThemeProvider.tsx @@ -3,21 +3,21 @@ import { useEffect } from "react"; import { Toaster } from "sonner"; import { useThemeStore } from "@/store/themeStore"; +import { useNotificationStore } from "@/store/notificationStore"; export function ThemeProvider() { const initTheme = useThemeStore((s) => s.initTheme); - const theme = useThemeStore((s) => s.theme); + const hydrateNotifications = useNotificationStore((s) => s.hydrate); useEffect(() => { initTheme(); - }, [initTheme]); - - const toasterTheme = theme === "system" ? "system" : theme; + hydrateNotifications(); + }, [hydrateNotifications, initTheme]); return ( { + if (typeof window === "undefined") return true; + + const stored = localStorage.getItem("paypath_balance_visible"); + return stored === null ? true : stored === "true"; + }); const [addOpen, setAddOpen] = useState(false); const [addType, setAddType] = useState<"income" | "expense">("income"); const { getTotals, isLoading } = useTransactionStore(); const { balance, income, expense } = getTotals(); const animatedBalance = useCountUp(balance); + useEffect(() => { + localStorage.setItem("paypath_balance_visible", String(visible)); + }, [visible]); + const openAdd = (type: "income" | "expense") => { setAddType(type); setAddOpen(true); @@ -75,7 +84,7 @@ export default function BalanceCard() {

Total Balance

+ + + + + + ); +} diff --git a/frontend/components/goals/EditGoalDialog.tsx b/frontend/components/goals/EditGoalDialog.tsx index 680c7c2..5d2821a 100644 --- a/frontend/components/goals/EditGoalDialog.tsx +++ b/frontend/components/goals/EditGoalDialog.tsx @@ -6,6 +6,7 @@ import { useGoalStore, type Goal } from "@/store/goalStore"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { formatDecimalInput, parseDecimalInput } from "@/lib/number"; import { Dialog, DialogContent, @@ -23,6 +24,7 @@ interface EditGoalDialogProps { export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDialogProps) { const updateGoal = useGoalStore((state) => state.updateGoal); + const fetchGoals = useGoalStore((state) => state.fetchGoals); const [name, setName] = useState(""); const [target, setTarget] = useState(""); const [deadline, setDeadline] = useState(""); @@ -32,7 +34,7 @@ export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDia if (!goal) return; setName(goal.name); - setTarget(String(goal.targetAmount)); + setTarget(formatDecimalInput(String(goal.targetAmount))); setDeadline(goal.deadline); }, [goal]); @@ -52,11 +54,15 @@ export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDia return; } - const numTarget = parseFloat(target); - if (!numTarget || numTarget <= 0) { + const numTarget = parseDecimalInput(target); + if (!Number.isFinite(numTarget) || numTarget <= 0) { toast.error("Please enter a valid target amount"); return; } + if (numTarget < goal.currentAmount) { + toast.error("Target amount cannot be lower than the amount already contributed"); + return; + } if (!deadline) { toast.error("Please select a deadline"); @@ -70,11 +76,13 @@ export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDia targetAmount: numTarget, deadline, }); + await fetchGoals(); toast.success("Goal updated"); onOpenChange(false); resetState(); - } catch { - toast.error("Failed to update goal"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to update goal right now."; + toast.error(message); } finally { setSaving(false); } @@ -111,14 +119,19 @@ export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDia setTarget(event.target.value)} + onChange={(event) => setTarget(formatDecimalInput(event.target.value))} + placeholder={`${goal?.currentAmount ?? 0}`} className="text-lg font-display font-bold" required /> + {goal && ( +

+ Minimum allowed: {goal.currentAmount.toLocaleString("en-NG", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} +

+ )}
diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx index f455f68..a220749 100644 --- a/frontend/components/layout/Header.tsx +++ b/frontend/components/layout/Header.tsx @@ -1,33 +1,21 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; import { useAuthStore } from "@/store/authStore"; import { useNotificationStore } from "@/store/notificationStore"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Notification01Icon, Cancel01Icon, Tick01Icon } from "@hugeicons/core-free-icons"; +import { Notification01Icon } from "@hugeicons/core-free-icons"; interface HeaderProps { title: string; } -const typeStyles = { - success: "bg-primary/10 text-primary", - warning: "bg-yellow-400/10 text-yellow-400", - info: "bg-blue-400/10 text-blue-400", -}; - export default function Header({ title }: HeaderProps) { const { user } = useAuthStore(); - const { notifications, markAllRead, clearAll, unreadCount } = useNotificationStore(); - const [showNotif, setShowNotif] = useState(false); + const { unreadCount } = useNotificationStore(); const count = unreadCount(); - const handleOpen = () => { - setShowNotif((v) => !v); - }; - return (
@@ -36,85 +24,20 @@ export default function Header({ title }: HeaderProps) {

{title}

-
- - - {showNotif && ( - <> -
setShowNotif(false)} /> -
-
-

Notifications

-
- {notifications.length > 0 && ( - <> - - - - )} - -
-
- -
- {notifications.length === 0 ? ( -
- -

No notifications yet

-
- ) : ( - notifications.map((n) => ( -
-
- -
-
-

{n.title}

-

{n.message}

-

- {new Date(n.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} -

-
- {!n.read &&
} -
- )) - )} -
-
- - )} -
+ - + {user?.name?.charAt(0)?.toUpperCase() || "U"} diff --git a/frontend/components/settings/EditProfileDialog.tsx b/frontend/components/settings/EditProfileDialog.tsx index 667a94d..0ea43a2 100644 --- a/frontend/components/settings/EditProfileDialog.tsx +++ b/frontend/components/settings/EditProfileDialog.tsx @@ -24,16 +24,13 @@ interface EditProfileDialogProps { export default function EditProfileDialog({ open, onOpenChange }: EditProfileDialogProps) { const { user, updateUser } = useAuthStore(); const [name, setName] = useState(user?.name || ""); - const [email, setEmail] = useState(user?.email || ""); - const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl); + const [saving, setSaving] = useState(false); useEffect(() => { if (open) { setName(user?.name || ""); - setEmail(user?.email || ""); - setAvatarUrl(user?.avatarUrl); } - }, [open, user?.name, user?.email, user?.avatarUrl]); + }, [open, user?.name]); const handleAvatarChange = (file: File | null) => { if (!file) return; @@ -45,26 +42,25 @@ export default function EditProfileDialog({ open, onOpenChange }: EditProfileDia toast.error("Image must be under 2MB"); return; } - const reader = new FileReader(); - reader.onload = () => { - setAvatarUrl(typeof reader.result === "string" ? reader.result : undefined); - }; - reader.onerror = () => toast.error("Failed to read image"); - reader.readAsDataURL(file); + toast.info("Profile photo upload is coming soon"); }; - const handleSave = () => { + const handleSave = async () => { if (!name.trim()) { toast.error("Name cannot be empty"); return; } - if (!email.trim() || !email.includes("@")) { - toast.error("Please enter a valid email"); - return; + setSaving(true); + try { + await updateUser({ name: name.trim() }); + toast.success("Profile updated"); + onOpenChange(false); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update profile"; + toast.error(message); + } finally { + setSaving(false); } - updateUser({ name: name.trim(), email: email.trim(), avatarUrl }); - toast.success("Profile updated"); - onOpenChange(false); }; return ( @@ -77,7 +73,7 @@ export default function EditProfileDialog({ open, onOpenChange }: EditProfileDia
- + {name?.charAt(0)?.toUpperCase() || "U"} @@ -90,6 +86,7 @@ export default function EditProfileDialog({ open, onOpenChange }: EditProfileDia accept="image/*" onChange={(e) => handleAvatarChange(e.target.files?.[0] || null)} /> +

Photo upload is not available yet.

@@ -99,6 +96,7 @@ export default function EditProfileDialog({ open, onOpenChange }: EditProfileDia value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" + disabled={saving} />
@@ -106,17 +104,17 @@ export default function EditProfileDialog({ open, onOpenChange }: EditProfileDia setEmail(e.target.value)} - placeholder="you@example.com" + value={user?.email || ""} + readOnly + disabled />
- - + diff --git a/frontend/components/transactions/AddTransactionDialog.tsx b/frontend/components/transactions/AddTransactionDialog.tsx index d58939f..072c95b 100644 --- a/frontend/components/transactions/AddTransactionDialog.tsx +++ b/frontend/components/transactions/AddTransactionDialog.tsx @@ -6,6 +6,7 @@ import { useTransactionStore } from "@/store/transactionStore"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { formatDecimalInput, parseDecimalInput } from "@/lib/number"; import { Dialog, DialogContent, @@ -46,27 +47,11 @@ export default function AddTransactionDialog({ const [submitting, setSubmitting] = useState(false); - const normalizeAmount = (value: string) => value.replace(/[^\d.]/g, ""); - - const formatAmountForInput = (value: string) => { - if (!value) return ""; - const [intPartRaw, decPartRaw] = value.split("."); - const intPart = intPartRaw.replace(/\D/g, ""); - if (!intPart) return ""; - const withCommas = new Intl.NumberFormat("en-NG").format(Number(intPart)); - if (decPartRaw !== undefined) { - const decPart = decPartRaw.replace(/\D/g, "").slice(0, 2); - return decPart.length ? `${withCommas}.${decPart}` : `${withCommas}.`; - } - return withCommas; - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const cleanedAmount = normalizeAmount(amount); - const numAmount = parseFloat(cleanedAmount); - if (!numAmount || numAmount <= 0) { + const numAmount = parseDecimalInput(amount); + if (!Number.isFinite(numAmount) || numAmount <= 0) { toast.error("Please enter a valid amount"); return; } @@ -143,8 +128,7 @@ export default function AddTransactionDialog({ placeholder="0" value={amount} onChange={(e) => { - const raw = normalizeAmount(e.target.value); - setAmount(formatAmountForInput(raw)); + setAmount(formatDecimalInput(e.target.value)); }} className="text-lg font-display font-bold" required diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 6e567bd..8db012f 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -6,6 +6,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( { async function handleResponse(response: Response): Promise { if (!response.ok) { - const error = await response.json().catch(() => ({ message: "Something went wrong" })); - throw new Error(error.message || `Request failed with status ${response.status}`); + const error = await response.json().catch(() => ({ message: GENERIC_ERROR_MESSAGE })); + const message = typeof error.message === "string" ? error.message : GENERIC_ERROR_MESSAGE; + + if (response.status >= 500) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + if (isConnectionErrorMessage(message)) { + throw new Error(CONNECTION_ERROR_MESSAGE); + } + + throw new Error(message || GENERIC_ERROR_MESSAGE); } return response.json(); } export async function apiGet(path: string, options?: ApiOptions): Promise { - const response = await fetch(`${BASE_URL}${path}`, { - method: "GET", - headers: getHeaders(options), - }); - return handleResponse(response); + try { + const response = await fetch(`${BASE_URL}${path}`, { + method: "GET", + headers: getHeaders(options), + }); + return handleResponse(response); + } catch (error) { + if (error instanceof Error) { + if (isConnectionErrorMessage(error.message)) { + throw new Error(CONNECTION_ERROR_MESSAGE); + } + throw error; + } + throw new Error(CONNECTION_ERROR_MESSAGE); + } } export async function apiPost(path: string, body?: unknown, options?: ApiOptions): Promise { - const response = await fetch(`${BASE_URL}${path}`, { - method: "POST", - headers: getHeaders(options), - body: body ? JSON.stringify(body) : undefined, - }); - return handleResponse(response); + try { + const response = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: getHeaders(options), + body: body ? JSON.stringify(body) : undefined, + }); + return handleResponse(response); + } catch (error) { + if (error instanceof Error) { + if (isConnectionErrorMessage(error.message)) { + throw new Error(CONNECTION_ERROR_MESSAGE); + } + throw error; + } + throw new Error(CONNECTION_ERROR_MESSAGE); + } } export async function apiPut(path: string, body?: unknown, options?: ApiOptions): Promise { - const response = await fetch(`${BASE_URL}${path}`, { - method: "PUT", - headers: getHeaders(options), - body: body ? JSON.stringify(body) : undefined, - }); - return handleResponse(response); + try { + const response = await fetch(`${BASE_URL}${path}`, { + method: "PUT", + headers: getHeaders(options), + body: body ? JSON.stringify(body) : undefined, + }); + return handleResponse(response); + } catch (error) { + if (error instanceof Error) { + if (isConnectionErrorMessage(error.message)) { + throw new Error(CONNECTION_ERROR_MESSAGE); + } + throw error; + } + throw new Error(CONNECTION_ERROR_MESSAGE); + } } export async function apiDelete(path: string, options?: ApiOptions): Promise { - const response = await fetch(`${BASE_URL}${path}`, { - method: "DELETE", - headers: getHeaders(options), - }); - return handleResponse(response); + try { + const response = await fetch(`${BASE_URL}${path}`, { + method: "DELETE", + headers: getHeaders(options), + }); + return handleResponse(response); + } catch (error) { + if (error instanceof Error) { + if (isConnectionErrorMessage(error.message)) { + throw new Error(CONNECTION_ERROR_MESSAGE); + } + throw error; + } + throw new Error(CONNECTION_ERROR_MESSAGE); + } } diff --git a/frontend/lib/number.ts b/frontend/lib/number.ts new file mode 100644 index 0000000..19781a3 --- /dev/null +++ b/frontend/lib/number.ts @@ -0,0 +1,39 @@ +export function normalizeDecimalInput(value: string) { + const cleaned = value.replace(/[^\d.]/g, "") + const firstDotIndex = cleaned.indexOf(".") + + if (firstDotIndex === -1) { + return cleaned + } + + const integerPart = cleaned.slice(0, firstDotIndex) + const decimalPart = cleaned.slice(firstDotIndex + 1).replace(/\./g, "") + + return `${integerPart}.${decimalPart}` +} + +export function formatDecimalInput(value: string) { + if (!value) return "" + + const normalized = normalizeDecimalInput(value) + const hasDecimal = normalized.includes(".") + const [rawIntegerPart, rawDecimalPart = ""] = normalized.split(".") + const integerDigits = rawIntegerPart.replace(/\D/g, "") + const formattedInteger = integerDigits + ? new Intl.NumberFormat("en-NG", { maximumFractionDigits: 0 }).format(Number(integerDigits)) + : "0" + + if (!hasDecimal) { + return integerDigits ? formattedInteger : "" + } + + return `${formattedInteger}.${rawDecimalPart.replace(/\D/g, "")}` +} + +export function parseDecimalInput(value: string) { + const normalized = normalizeDecimalInput(value) + + if (!normalized || normalized === ".") return NaN + + return Number.parseFloat(normalized) +} diff --git a/frontend/store/authStore.ts b/frontend/store/authStore.ts index b6c8db7..477c872 100644 --- a/frontend/store/authStore.ts +++ b/frontend/store/authStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { apiPost, apiGet, setToken, getToken } from "@/lib/api"; +import { apiPost, apiGet, apiPut, setToken, getToken } from "@/lib/api"; interface User { id: string; @@ -18,24 +18,10 @@ interface AuthState { register: (name: string, email: string, password: string) => Promise; logout: () => void; fetchUser: () => Promise; - updateUser: (data: Partial>) => void; + updateUser: (data: Pick) => Promise; clearError: () => void; } -const PROFILE_KEY = "paypath_profile_overrides"; - -function saveOverrides(data: Partial>) { - localStorage.setItem(PROFILE_KEY, JSON.stringify(data)); -} - -function loadOverrides(): Partial> { - try { - return JSON.parse(localStorage.getItem(PROFILE_KEY) || "{}"); - } catch { - return {}; - } -} - export const useAuthStore = create((set) => ({ user: null, isAuthenticated: false, @@ -50,7 +36,7 @@ export const useAuthStore = create((set) => ({ password, }); setToken(data.token); - set({ user: { ...data.user, ...loadOverrides() }, isAuthenticated: true, isLoading: false }); + set({ user: data.user, isAuthenticated: true, isLoading: false }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Login failed"; set({ error: message, isLoading: false }); @@ -89,18 +75,16 @@ export const useAuthStore = create((set) => ({ set({ isLoading: true }); try { const data = await apiGet<{ user: User }>("/auth/me"); - set({ user: { ...data.user, ...loadOverrides() }, isAuthenticated: true, isLoading: false }); + set({ user: data.user, isAuthenticated: true, isLoading: false }); } catch { setToken(null); set({ user: null, isAuthenticated: false, isLoading: false }); } }, - updateUser: (data) => { - saveOverrides(data); - set((state) => ({ - user: state.user ? { ...state.user, ...data } : null, - })); + updateUser: async (data) => { + const res = await apiPut<{ user: User }>("/auth/profile", data); + set({ user: res.user }); }, clearError: () => set({ error: null }), diff --git a/frontend/store/goalStore.ts b/frontend/store/goalStore.ts index 090b50b..068dcdd 100644 --- a/frontend/store/goalStore.ts +++ b/frontend/store/goalStore.ts @@ -17,6 +17,7 @@ export interface Goal { currentAmount: number; deadline: string; progress?: number; + isComplete?: boolean; } interface GoalState { @@ -46,10 +47,12 @@ export const useGoalStore = create((set) => ({ currentAmount: Number(g.currentAmount), deadline: g.deadline?.split("T")[0] || g.deadline, progress: g.progress || 0, + isComplete: Number(g.currentAmount) >= Number(g.targetAmount), })); set({ goals, isLoading: false }); - } catch (e: any) { - set({ isLoading: false, error: e?.message || "Failed to load goals" }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to load goals"; + set({ isLoading: false, error: message }); } }, @@ -67,8 +70,9 @@ export const useGoalStore = create((set) => ({ currentAmount: Number(res.goal.currentAmount), deadline: res.goal.deadline?.split("T")[0] || res.goal.deadline, progress: 0, + isComplete: false, }; - set((state) => ({ goals: [...state.goals, goal] })); + set((state) => ({ goals: [goal, ...state.goals] })); } catch (error) { throw error; } @@ -90,6 +94,7 @@ export const useGoalStore = create((set) => ({ currentAmount: Number(res.goal.currentAmount), deadline: res.goal.deadline?.split("T")[0] || res.goal.deadline, progress: res.goal.progress || 0, + isComplete: Number(res.goal.currentAmount) >= Number(res.goal.targetAmount), }; set((state) => ({ @@ -112,7 +117,12 @@ export const useGoalStore = create((set) => ({ set((state) => { const updated = state.goals.map((g) => g.id === id - ? { ...g, currentAmount: Number(res.goal.currentAmount), progress: res.goal.progress || 0 } + ? { + ...g, + currentAmount: Number(res.goal.currentAmount), + progress: res.goal.progress || 0, + isComplete: Number(res.goal.currentAmount) >= Number(res.goal.targetAmount), + } : g ); // Notify if goal completed diff --git a/frontend/store/notificationStore.ts b/frontend/store/notificationStore.ts index b61a05f..a91cffb 100644 --- a/frontend/store/notificationStore.ts +++ b/frontend/store/notificationStore.ts @@ -11,29 +11,77 @@ export interface AppNotification { interface NotificationState { notifications: AppNotification[]; + hydrate: () => void; addNotification: (n: Omit) => void; markAllRead: () => void; clearAll: () => void; unreadCount: () => number; } +const NOTIFICATIONS_KEY = "paypath_notifications"; +const PUSH_NOTIFICATIONS_KEY = "paypath_push_notifications"; + +function saveNotifications(notifications: AppNotification[]) { + if (typeof window === "undefined") return; + + localStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify(notifications)); +} + +function loadNotifications(): AppNotification[] { + if (typeof window === "undefined") return []; + + try { + const stored = localStorage.getItem(NOTIFICATIONS_KEY); + if (!stored) return []; + + const parsed = JSON.parse(stored) as Array & { createdAt: string }>; + return parsed.map((notification) => ({ + ...notification, + createdAt: new Date(notification.createdAt), + })); + } catch { + return []; + } +} + +function isPushNotificationsEnabled() { + if (typeof window === "undefined") return true; + + const stored = localStorage.getItem(PUSH_NOTIFICATIONS_KEY); + return stored === null ? true : stored === "true"; +} + export const useNotificationStore = create((set, get) => ({ notifications: [], - addNotification: (n) => - set((state) => ({ - notifications: [ + hydrate: () => { + set({ notifications: loadNotifications() }); + }, + + addNotification: (n) => { + if (!isPushNotificationsEnabled()) return; + + set((state) => { + const notifications = [ { ...n, id: crypto.randomUUID(), read: false, createdAt: new Date() }, ...state.notifications, - ], - })), + ]; + saveNotifications(notifications); + return { notifications }; + }); + }, markAllRead: () => - set((state) => ({ - notifications: state.notifications.map((n) => ({ ...n, read: true })), - })), + set((state) => { + const notifications = state.notifications.map((n) => ({ ...n, read: true })); + saveNotifications(notifications); + return { notifications }; + }), - clearAll: () => set({ notifications: [] }), + clearAll: () => { + saveNotifications([]); + set({ notifications: [] }); + }, unreadCount: () => get().notifications.filter((n) => !n.read).length, })); diff --git a/frontend/store/themeStore.ts b/frontend/store/themeStore.ts index 4d2d598..d222d8e 100644 --- a/frontend/store/themeStore.ts +++ b/frontend/store/themeStore.ts @@ -1,34 +1,19 @@ import { create } from "zustand"; -type Theme = "dark" | "light" | "system"; - interface ThemeState { - theme: Theme; - setTheme: (theme: Theme) => void; + theme: "dark"; initTheme: () => void; } -function applyTheme(theme: Theme) { +function applyTheme() { const root = document.documentElement; - if (theme === "system") { - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - root.classList.toggle("dark", prefersDark); - } else { - root.classList.toggle("dark", theme === "dark"); - } + root.classList.add("dark"); } export const useThemeStore = create((set) => ({ theme: "dark", - setTheme: (theme) => { - localStorage.setItem("paypath_theme", theme); - applyTheme(theme); - set({ theme }); - }, initTheme: () => { - const stored = localStorage.getItem("paypath_theme") as Theme | null; - const theme = stored || "dark"; - applyTheme(theme); - set({ theme }); + applyTheme(); + set({ theme: "dark" }); }, })); diff --git a/frontend/store/transactionStore.ts b/frontend/store/transactionStore.ts index b4513dc..a3e655c 100644 --- a/frontend/store/transactionStore.ts +++ b/frontend/store/transactionStore.ts @@ -66,8 +66,9 @@ export const useTransactionStore = create((set, get) => ({ type: "warning", }) ); - } catch (e: any) { - set({ isLoading: false, error: e?.message || "Failed to load transactions" }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to load transactions"; + set({ isLoading: false, error: message }); } },