Skip to content

Commit 2c5c29e

Browse files
lekhanproCopilot
andcommitted
Upgrade AI chatbot, notifications, mobile layout, and investment UI
- Rewrite AIChatbot.tsx: 3-tab layout (Chat/Scorecard/Invest) with quick-chips, financial scorecard bars, and investment suggestion cards - Add computeFinancialScorecard() returning A-F grade + 5 component scores - Add getInvestmentSuggestions() with INR/USD/EUR locale-aware defaults - Extend InvestmentSuggestion interface: riskLevel, emoji, why fields - Add AppNotification type + interface to types.ts - Wire notifications into AppContextType (notifications, unreadCount, addNotification, markNotificationsRead) - Add notification state + handlers to AppContext.tsx - Add NotificationCenter.tsx: bell icon with unread badge + toast portal - Add vite/client types to tsconfig.json (fixes import.meta.env TS error) - Reduce spacing (space-y-6 → space-y-4) in BudgetView, Goals, Transactions for better mobile layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c4f8342 commit 2c5c29e

9 files changed

Lines changed: 685 additions & 204 deletions

File tree

web/components/AIChatbot.tsx

Lines changed: 305 additions & 198 deletions
Large diffs are not rendered by default.

web/components/BudgetView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const BudgetView: React.FC = () => {
4545
};
4646

4747
return (
48-
<div className="space-y-6 animate-slide-up">
48+
<div className="space-y-4 animate-slide-up">
4949
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5050
<div>
5151
<p className="text-gray-500 dark:text-gray-400">Monthly controls</p>

web/components/Goals.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const Goals: React.FC = () => {
3737
};
3838

3939
return (
40-
<div className="space-y-6 animate-slide-up">
40+
<div className="space-y-4 animate-slide-up">
4141
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
4242
<div>
4343
<p className="text-gray-500 dark:text-gray-400">Long-range planning</p>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// components/NotificationCenter.tsx - In-app notification bell + toasts
2+
import React, { useContext, useEffect, useRef, useState } from 'react';
3+
import { AppContext } from '../context/AppContext';
4+
import { AppNotification } from '../types';
5+
6+
// ─── Toast Portal ─────────────────────────────────────────────────────────────
7+
8+
interface Toast extends AppNotification {
9+
removing?: boolean;
10+
}
11+
12+
interface ToastContainerProps {
13+
toasts: Toast[];
14+
onDismiss: (id: string) => void;
15+
}
16+
17+
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
18+
const toastColors: Record<string, string> = {
19+
budget_exceeded: 'border-red-500 bg-red-50 dark:bg-red-950/40',
20+
budget_alert: 'border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30',
21+
goal_achieved: 'border-green-500 bg-green-50 dark:bg-green-950/30',
22+
goal_progress: 'border-blue-500 bg-blue-50 dark:bg-blue-950/30',
23+
savings_milestone: 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30',
24+
tip: 'border-amber-400 bg-amber-50 dark:bg-amber-950/30',
25+
info: 'border-gray-400 bg-gray-50 dark:bg-zinc-800',
26+
};
27+
28+
if (toasts.length === 0) return null;
29+
30+
return (
31+
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 w-[92vw] max-w-sm pointer-events-none">
32+
{toasts.map(toast => (
33+
<div
34+
key={toast.id}
35+
className={`pointer-events-auto flex items-start gap-3 p-3 rounded-xl border-l-4 shadow-lg backdrop-blur-sm transition-all duration-300 animate-slide-down ${toastColors[toast.type] || toastColors.info}`}
36+
>
37+
<span className="text-xl flex-shrink-0 mt-0.5">{toast.icon}</span>
38+
<div className="flex-1 min-w-0">
39+
<p className="text-sm font-semibold text-gray-900 dark:text-white">{toast.title}</p>
40+
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{toast.message}</p>
41+
</div>
42+
<button
43+
onClick={() => onDismiss(toast.id)}
44+
className="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 mt-0.5"
45+
>
46+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
47+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
48+
</svg>
49+
</button>
50+
</div>
51+
))}
52+
</div>
53+
);
54+
};
55+
56+
// ─── Notification Bell + Dropdown ─────────────────────────────────────────────
57+
58+
export const NotificationBell: React.FC = () => {
59+
const { notifications, markNotificationsRead, unreadCount } = useContext(AppContext)!;
60+
const [open, setOpen] = useState(false);
61+
const ref = useRef<HTMLDivElement>(null);
62+
63+
useEffect(() => {
64+
const handler = (e: MouseEvent) => {
65+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
66+
};
67+
document.addEventListener('mousedown', handler);
68+
return () => document.removeEventListener('mousedown', handler);
69+
}, []);
70+
71+
const handleOpen = () => {
72+
setOpen(v => !v);
73+
if (!open && unreadCount > 0) markNotificationsRead();
74+
};
75+
76+
const timeAgo = (ts: number) => {
77+
const diff = Date.now() - ts;
78+
if (diff < 60000) return 'just now';
79+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
80+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
81+
return `${Math.floor(diff / 86400000)}d ago`;
82+
};
83+
84+
const typeColors: Record<string, string> = {
85+
budget_exceeded: 'text-red-500',
86+
budget_alert: 'text-yellow-500',
87+
goal_achieved: 'text-green-500',
88+
goal_progress: 'text-blue-500',
89+
savings_milestone: 'text-emerald-500',
90+
tip: 'text-amber-500',
91+
info: 'text-gray-500',
92+
};
93+
94+
return (
95+
<div ref={ref} className="relative">
96+
<button
97+
onClick={handleOpen}
98+
className={`relative p-2 rounded-xl transition-colors ${open ? 'bg-blue-100 dark:bg-zinc-800 text-blue-600 dark:text-white' : 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-400'}`}
99+
aria-label="Notifications"
100+
>
101+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
102+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
103+
</svg>
104+
{unreadCount > 0 && (
105+
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
106+
{unreadCount > 9 ? '9+' : unreadCount}
107+
</span>
108+
)}
109+
</button>
110+
111+
{open && (
112+
<div className="absolute right-0 top-full mt-2 w-80 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-2xl overflow-hidden z-50">
113+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-zinc-800">
114+
<h3 className="font-semibold text-sm text-gray-900 dark:text-white">Notifications</h3>
115+
{notifications.length > 0 && (
116+
<button
117+
onClick={markNotificationsRead}
118+
className="text-xs text-blue-500 hover:text-blue-600 font-medium"
119+
>
120+
Mark all read
121+
</button>
122+
)}
123+
</div>
124+
125+
<div className="max-h-80 overflow-y-auto divide-y divide-gray-100 dark:divide-zinc-800">
126+
{notifications.length === 0 ? (
127+
<div className="px-4 py-8 text-center">
128+
<span className="text-3xl">🔔</span>
129+
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">No notifications yet</p>
130+
</div>
131+
) : (
132+
notifications.slice(0, 20).map(n => (
133+
<div key={n.id} className={`flex gap-3 px-4 py-3 ${!n.read ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''}`}>
134+
<span className="text-lg flex-shrink-0">{n.icon}</span>
135+
<div className="flex-1 min-w-0">
136+
<p className={`text-sm font-medium ${typeColors[n.type] || 'text-gray-700 dark:text-gray-300'}`}>{n.title}</p>
137+
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{n.message}</p>
138+
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">{timeAgo(n.timestamp)}</p>
139+
</div>
140+
{!n.read && <div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 flex-shrink-0" />}
141+
</div>
142+
))
143+
)}
144+
</div>
145+
</div>
146+
)}
147+
</div>
148+
);
149+
};

web/components/Transactions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const Transactions: React.FC = () => {
7373
}, [categories, filteredTransactions]);
7474

7575
return (
76-
<div className="space-y-6 animate-slide-up">
76+
<div className="space-y-4 animate-slide-up">
7777
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
7878
<div>
7979
<p className="text-gray-500 dark:text-gray-400">Activity ledger</p>

web/context/AppContext.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
subscribeToTransactions,
1313
} from '../lib/database';
1414
import { DEFAULT_CATEGORIES, SUPPORTED_CURRENCIES, generateId } from '../constants';
15-
import { AppContextType, Budget, Category, Currency, Goal, Transaction, UserSettings, ViewType } from '../types';
15+
import { AppContextType, AppNotification, Budget, Category, Currency, Goal, Transaction, UserSettings, ViewType } from '../types';
1616

1717
export const AppContext = createContext<AppContextType | undefined>(undefined);
1818

@@ -37,7 +37,7 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
3737
const categories = useMemo(() => [...DEFAULT_CATEGORIES, ...customCategories], [customCategories]);
3838

3939
useEffect(() => {
40-
const unsubscribe = onAuthStateChanged(auth, (user) => {
40+
const unsubscribe = onAuthStateChanged(auth, (user: User | null) => {
4141
setFirebaseUser(user);
4242
if (!user) {
4343
setTransactions([]);
@@ -274,6 +274,21 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
274274
currency: currency.code,
275275
}).format(amount);
276276

277+
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([]);
278+
279+
const unreadCount = appNotifications.filter(n => !n.read).length;
280+
281+
const addNotification = useCallback((n: Omit<AppNotification, 'id' | 'timestamp' | 'read'>) => {
282+
setAppNotifications(prev => [
283+
{ ...n, id: generateId(), timestamp: Date.now(), read: false },
284+
...prev.slice(0, 49),
285+
]);
286+
}, []);
287+
288+
const markNotificationsRead = useCallback(() => {
289+
setAppNotifications(prev => prev.map(n => ({ ...n, read: true })));
290+
}, []);
291+
277292
const contextValue: AppContextType = {
278293
transactions,
279294
budgets,
@@ -314,6 +329,10 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
314329
formatCurrency,
315330
addCustomCategory,
316331
removeCustomCategory,
332+
notifications: appNotifications,
333+
unreadCount,
334+
addNotification,
335+
markNotificationsRead,
317336
};
318337

319338
if (isLoading) {

0 commit comments

Comments
 (0)