Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 133 additions & 0 deletions src/app/components/notifications/RecommendationPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client';

import React from 'react';
import { Lightbulb, X, Check, ChevronRight } from 'lucide-react';
import { NotificationRecommendation } from '@/lib/notifications';

interface RecommendationPanelProps {
recommendations: NotificationRecommendation[];
onApply: (id: string) => Promise<void>;
onDismiss: (id: string) => void;
}

const impactConfig = {
high: {
label: 'High impact',
className: 'bg-red-100 text-red-700 border border-red-200',
},
medium: {
label: 'Medium impact',
className: 'bg-amber-100 text-amber-700 border border-amber-200',
},
low: {
label: 'Low impact',
className: 'bg-blue-100 text-blue-700 border border-blue-200',
},
} as const;

export default function RecommendationPanel({
recommendations,
onApply,
onDismiss,
}: RecommendationPanelProps) {
const [applying, setApplying] = React.useState<string | null>(null);

if (recommendations.length === 0) return null;

const handleApply = async (id: string) => {
setApplying(id);
try {
await onApply(id);
} finally {
setApplying(null);
}
};

return (
<div className="p-4 border-b bg-gradient-to-br from-indigo-50 to-blue-50">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Lightbulb size={16} className="text-indigo-600" />
<h3 className="text-sm font-semibold text-indigo-900">
Recommendations
</h3>
<span className="ml-auto text-xs text-indigo-600 font-medium bg-indigo-100 px-2 py-0.5 rounded-full">
{recommendations.length}
</span>
</div>

{/* Recommendation cards */}
<div className="space-y-2">
{recommendations.map((rec) => {
const impact = impactConfig[rec.impact];
const isApplying = applying === rec.id;

return (
<div
key={rec.id}
className="bg-white rounded-lg border border-indigo-100 shadow-sm overflow-hidden"
>
<div className="p-3">
{/* Impact badge + title row */}
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<span
className={`shrink-0 text-xs font-medium px-2 py-0.5 rounded-full ${impact.className}`}
>
{impact.label}
</span>
<p className="text-sm font-medium text-gray-900 truncate">
{rec.title}
</p>
</div>
{/* Dismiss */}
<button
onClick={() => onDismiss(rec.id)}
aria-label={`Dismiss recommendation: ${rec.title}`}
className="shrink-0 p-0.5 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<X size={14} />
</button>
</div>

{/* Description */}
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
{rec.description}
</p>

{/* Apply button */}
<button
onClick={() => handleApply(rec.id)}
disabled={isApplying}
aria-label={`Apply recommendation: ${rec.title}`}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium
transition-colors
${
isApplying
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}
`}
>
{isApplying ? (
<>
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin" />
Applying…
</>
) : (
<>
<Check size={12} />
Apply
<ChevronRight size={12} />
</>
)}
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
10 changes: 9 additions & 1 deletion src/app/components/notifications/UserPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AlertCircle,
} from 'lucide-react';
import { useNotifications } from '@/app/hooks/useNotifications';
import RecommendationPanel from './RecommendationPanel';
import {
UserNotificationPreferences,
NotificationChannel,
Expand Down Expand Up @@ -65,7 +66,7 @@ const channelIcons: Record<NotificationChannel, React.ReactNode> = {
};

export default function UserPreferences({ userId, onSave }: UserPreferencesProps) {
const { preferences, updatePreferences, isLoading } = useNotifications({ userId });
const { preferences, updatePreferences, isLoading, recommendations, applyRecommendation, dismissRecommendation } = useNotifications({ userId });

const [localPreferences, setLocalPreferences] = useState<UserNotificationPreferences | null>(
null,
Expand Down Expand Up @@ -188,6 +189,13 @@ export default function UserPreferences({ userId, onSave }: UserPreferencesProps
</p>
</div>

{/* Recommendation Engine Panel */}
<RecommendationPanel
recommendations={recommendations}
onApply={applyRecommendation}
onDismiss={dismissRecommendation}
/>

{/* Global Channel Settings */}
<div className="p-4 border-b">
<h3 className="text-sm font-medium text-gray-900 mb-3">Global Channels</h3>
Expand Down
59 changes: 59 additions & 0 deletions src/app/hooks/useNotifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
NotificationCategory,
UserNotificationPreferences,
NotificationAnalytics,
NotificationRecommendation,
generateNotificationId,
shouldSendNotification,
calculateAnalytics,
Expand All @@ -16,6 +17,7 @@ import {
createDefaultPreferences,
validatePreferences,
NotificationService,
generateRecommendations,
} from '@/lib/notifications';

interface UseNotificationsOptions {
Expand Down Expand Up @@ -69,9 +71,15 @@ interface UseNotificationsReturn {
notification: AppNotification,
channels: NotificationChannel[],
) => Promise<Record<NotificationChannel, boolean>>;

// Recommendations
recommendations: NotificationRecommendation[];
applyRecommendation: (id: string) => Promise<void>;
dismissRecommendation: (id: string) => void;
}

const PREFERENCES_STORAGE_KEY = 'notification_preferences_v1';
const DISMISSED_RECOMMENDATIONS_KEY = 'dismissed_recommendations_v1';

export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
const { userId = 'default', enableAnalytics = true } = options;
Expand All @@ -88,6 +96,14 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
const [preferences, setPreferences] = useState<UserNotificationPreferences | null>(null);
const [analytics, setAnalytics] = useState<NotificationAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem(DISMISSED_RECOMMENDATIONS_KEY);
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
} catch {
return new Set<string>();
}
});

// Load preferences from localStorage
useEffect(() => {
Expand Down Expand Up @@ -322,6 +338,44 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
// Computed values
const unreadCount = useMemo(() => notifications.filter((n) => !n.read).length, [notifications]);

// Recommendations — recompute whenever analytics or preferences change
const recommendations = useMemo<NotificationRecommendation[]>(() => {
if (!analytics || !preferences) return [];
const all = generateRecommendations(analytics, preferences);
return all.filter((r) => !dismissedIds.has(r.id));
}, [analytics, preferences, dismissedIds]);

// Apply a recommendation: merge its patch into preferences then auto-dismiss
const applyRecommendation = useCallback(
async (id: string) => {
const rec = recommendations.find((r) => r.id === id);
if (!rec) return;
await updatePreferences(rec.preferencePatch);
// Dismiss after applying so it no longer appears
setDismissedIds((prev) => {
const next = new Set(prev);
next.add(id);
try {
localStorage.setItem(DISMISSED_RECOMMENDATIONS_KEY, JSON.stringify([...next]));
} catch { /* ignore */ }
return next;
});
},
[recommendations, updatePreferences],
);

// Dismiss without applying
const dismissRecommendation = useCallback((id: string) => {
setDismissedIds((prev) => {
const next = new Set(prev);
next.add(id);
try {
localStorage.setItem(DISMISSED_RECOMMENDATIONS_KEY, JSON.stringify([...next]));
} catch { /* ignore */ }
return next;
});
}, []);

return {
// State
notifications,
Expand Down Expand Up @@ -351,6 +405,11 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
// Multi-channel delivery
sendToChannel,
sendToAllChannels,

// Recommendations
recommendations,
applyRecommendation,
dismissRecommendation,
};
}

Expand Down
Loading
Loading