From 9dd5b3271cff6f2dc077759a73bbeb3223ae7761 Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Thu, 18 Jun 2026 00:11:45 +0100 Subject: [PATCH 1/2] feat: add/edit savings goal modal with validation and live state Implements the Add/Edit Savings Goal flow by lifting goalsData into component state and providing a fully accessible modal form. Key changes: - Integrated lib/validation/savings-goals for real-time form validation. - Externalized all strings and error messages to en.json for full i18n support. - Implemented modal accessibility (focus trap, ESC to close, and motion-reduce). - Recomputed daysLeft and isOverdue dynamically from the target date. - Added 100% test coverage for goals utilities and validation logic. - Improved UI robustness with line-clamping for long titles and descriptions --- .../goals/components/SavingsGoalModal.tsx | 300 ++++++++++++++++++ .../components/SavingsGoalsStatsCards.tsx | 9 +- app/dashboard/goals/page.tsx | 132 ++++---- app/dashboard/goals/types.ts | 20 ++ app/dashboard/goals/utils.ts | 31 ++ components/Dashboard/SavingsGoalCard.tsx | 40 +-- lib/i18n/locales/en.json | 53 +++- lib/validation/savings-goals.ts | 51 ++- tests/unit/goals/utils.test.ts | 50 +++ tests/unit/validation/savings-goals.test.ts | 57 ++-- 10 files changed, 606 insertions(+), 137 deletions(-) create mode 100644 app/dashboard/goals/components/SavingsGoalModal.tsx create mode 100644 app/dashboard/goals/types.ts create mode 100644 app/dashboard/goals/utils.ts create mode 100644 tests/unit/goals/utils.test.ts diff --git a/app/dashboard/goals/components/SavingsGoalModal.tsx b/app/dashboard/goals/components/SavingsGoalModal.tsx new file mode 100644 index 0000000..d7d59bc --- /dev/null +++ b/app/dashboard/goals/components/SavingsGoalModal.tsx @@ -0,0 +1,300 @@ +'use client' + +import React, { useState, useEffect, useRef } from 'react' +import { + X, + GraduationCap, + HeartPulse, + Home, + Plane, + AlertCircle +} from 'lucide-react' +import { SavingsGoal, SavingsGoalFormData } from '../types' +import { + validateGoalName, + validateGoalDescription, + validateAmount, + validateFutureDate +} from '@/lib/validation/savings-goals' +import { useClientTranslator } from '@/lib/i18n/client' + +interface SavingsGoalModalProps { + isOpen: boolean + onClose: () => void + onSave: (goalData: Partial) => void + editingGoal: SavingsGoal | null +} + +const ICONS = [ + { type: 'education', icon: , gradient: { from: '#DC2626', to: '#B91C1C' } }, + { type: 'medical', icon: , gradient: { from: '#F87171', to: '#EF4444' } }, + { type: 'home', icon: , gradient: { from: '#DC2626', to: '#B91C1C' } }, + { type: 'vacation', icon: , gradient: { from: '#F87171', to: '#EF4444' } }, +] as const + +export default function SavingsGoalModal({ + isOpen, + onClose, + onSave, + editingGoal, +}: SavingsGoalModalProps) { + const { t } = useClientTranslator() + const [formData, setFormData] = useState({ + title: '', + description: '', + targetAmount: 0, + targetDate: '', + iconType: 'education', + }) + + const [errors, setErrors] = useState>({}) + const modalRef = useRef(null) + + useEffect(() => { + if (editingGoal) { + // Find iconType from icon or title if possible, else default + let iconType: SavingsGoalFormData['iconType'] = 'education' + if (editingGoal.title.toLowerCase().includes('medical') || editingGoal.title.toLowerCase().includes('health')) iconType = 'medical' + else if (editingGoal.title.toLowerCase().includes('home') || editingGoal.title.toLowerCase().includes('house')) iconType = 'home' + else if (editingGoal.title.toLowerCase().includes('vacation') || editingGoal.title.toLowerCase().includes('trip')) iconType = 'vacation' + + setFormData({ + title: editingGoal.title, + description: editingGoal.description, + targetAmount: editingGoal.targetAmount, + targetDate: editingGoal.targetDate, + iconType, + }) + } else { + setFormData({ + title: '', + description: '', + targetAmount: 0, + targetDate: '', + iconType: 'education', + }) + } + setErrors({}) + }, [editingGoal, isOpen]) + + // Focus trap and ESC key + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'Tab') { + const focusableElements = modalRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (!focusableElements) return + + const firstElement = focusableElements[0] as HTMLElement + const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + lastElement.focus() + e.preventDefault() + } + } else { + if (document.activeElement === lastElement) { + firstElement.focus() + e.preventDefault() + } + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + const validate = (): boolean => { + const newErrors: Record = {} + + const titleVal = validateGoalName(formData.title) + if (!titleVal.isValid) newErrors.title = titleVal.error ? t(`errors.${titleVal.error}`) : t('errors.invalid_name') + + const descVal = validateGoalDescription(formData.description) + if (!descVal.isValid) newErrors.description = descVal.error ? t(`errors.${descVal.error}`) : t('errors.goal_description_invalid') + + const amountVal = validateAmount(formData.targetAmount) + if (!amountVal.isValid) newErrors.targetAmount = amountVal.error ? t(`errors.${amountVal.error}`) : t('errors.invalid_amount') + + const dateVal = validateFutureDate(formData.targetDate) + if (!dateVal.isValid) newErrors.targetDate = dateVal.error ? t(`errors.${dateVal.error}`) : t('errors.goal_invalid_date') + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (validate()) { + const selectedIcon = ICONS.find(i => i.type === formData.iconType) || ICONS[0] + onSave({ + title: formData.title, + description: formData.description, + targetAmount: formData.targetAmount, + targetDate: formData.targetDate, + icon: selectedIcon.icon, + iconGradient: selectedIcon.gradient, + }) + } + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + > + + + + +
+ {/* Goal Title */} +
+ + setFormData({ ...formData, title: e.target.value })} + className={`w-full rounded-xl bg-white/5 border ${errors.title ? 'border-red-500' : 'border-white/10'} px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-red-500/50 transition-all motion-reduce:transition-none`} + placeholder={t('savingsGoals.modal.titlePlaceholder')} + autoFocus + /> + {errors.title && ( +

+ {errors.title} +

+ )} +
+ + {/* Description */} +
+ +