Enhance user experience with voice commands, notifications, and a professional directory

Adds voice command integration, a notification system, and a professional directory page with search filters.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 556aa286-edd2-4cea-8583-f4fc3cfd119b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/81470e0d-8ae8-4335-9301-cd9a69e670fa/96467e4f-00f2-4d88-82b1-142a9b24784c.jpg
This commit is contained in:
ghaddaditw
2025-05-30 17:36:12 +00:00
parent ecbdd49cea
commit 8fea27bbbb
6 changed files with 829 additions and 4 deletions
@@ -0,0 +1,103 @@
🎯 Complete TaskFin AI Suite - Main App Features:
🏗️ Architecture Highlights:
Multi-Context Providers:
AuthProvider - User authentication and roles
ThemeProvider - Dark/light theme management
VoiceProvider - Voice AI capabilities
NotificationProvider - Real-time notifications
Comprehensive Routing:
Public routes - Landing, professional directory, login/register
Protected routes - Main app with role-based access
Professional routes - Appointment booking, client management
Admin routes - System management and analytics
AI routes - Voice chat, AI insights, assistant
Adaptive Navigation:
Standard users - Basic task/finance management
Pro users - Advanced AI insights + professional features
Professionals - Full appointment booking system
Admins - Complete system management
🎤 Voice-First Experience:
Global voice commands - Navigate anywhere by voice
Voice task creation - "أنشئ مهمة مراجعة التقارير غداً"
Voice appointment booking - "احجز موعد مع دكتور سميث"
Multilingual support - Arabic and English seamlessly
Voice feedback - Audio confirmations in Omani Arabic
👥 Social & Professional Features:
Professional profiles - Complete service showcases
Appointment system - Real-time booking with availability
Client management - Full CRM for professionals
Review system - Ratings and feedback
Connection network - Professional networking
🤖 AI-Powered Intelligence:
Smart task prioritization - AI suggests optimal workflows
Financial insights - AI analyzes spending patterns
Voice conversations - Natural language AI interactions
Predictive scheduling - AI optimizes appointment booking
Context-aware suggestions - Personalized recommendations
📱 Modern UI/UX:
Responsive design - Perfect on desktop, tablet, mobile
Dark theme - Professional appearance with system detection
Command palette - Quick actions via Cmd/Ctrl+K
Global search - Find anything across the entire system
Real-time status - Live system health and connectivity
🔒 Enterprise Security:
Role-based access - Granular permission controls
Session management - Secure authentication
Audit logging - Complete activity tracking
Data privacy - 100% on-premise deployment
🚀 Key User Flows:
For Healthcare Professionals:
Set up professional profile with specializations
Configure availability schedule (9 AM - 5 PM weekdays)
Patients discover and book appointments online
Voice-enabled appointment management
Financial tracking for practice income/expenses
AI insights for patient scheduling optimization
For Business Consultants:
Create service portfolio with expertise areas
Enable online booking for consultations
Manage client relationships and project tasks
Voice-powered task creation and updates
Financial reporting and business insights
AI-driven schedule optimization
For Personal Users:
Voice-enabled task management in Arabic/English
Smart financial tracking with AI categorization
Find and book professional services
AI assistant for productivity optimization
Integrated calendar and scheduling
🌟 Unique Selling Points:
🇴🇲 Omani Arabic Integration - Native TTS and voice recognition
🏥 Healthcare-Focused - Built for medical/professional services
🤖 AI-First Design - Intelligence woven throughout every feature
🔒 Complete Privacy - 100% on-premise, no cloud dependencies
👥 Social Professional Network - Built-in discovery and booking
🎤 Voice-Everything - Complete voice interaction capability
+8
View File
@@ -5,9 +5,12 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/context/AuthContext";
import { ThemeProvider } from "@/context/ThemeContext";
import { VoiceProvider } from "@/context/VoiceProvider";
import { NotificationProvider } from "@/context/NotificationProvider";
import LandingPage from "@/pages/LandingPage";
import DashboardPage from "@/pages/DashboardPage";
import LoginPage from "@/pages/LoginPage";
import ProfessionalDirectory from "@/pages/ProfessionalDirectory";
// import OnboardingPage from "@/pages/OnboardingPage";
import NotFound from "@/pages/not-found";
@@ -18,6 +21,7 @@ function Router() {
<Route path="/login" component={LoginPage} />
<Route path="/register" component={LoginPage} />
<Route path="/dashboard" component={DashboardPage} />
<Route path="/professionals" component={ProfessionalDirectory} />
<Route component={NotFound} />
</Switch>
);
@@ -28,10 +32,14 @@ function App() {
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<VoiceProvider>
<TooltipProvider>
<Toaster />
<Router />
</TooltipProvider>
</VoiceProvider>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
+281
View File
@@ -0,0 +1,281 @@
import { useState, useEffect } from "react";
import { useLocation } from "wouter";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/hooks/useAuth";
import { useVoiceContext } from "@/context/VoiceProvider";
import {
Search,
Home,
CheckSquare,
DollarSign,
Users,
Calendar,
Settings,
Mic,
Moon,
Sun,
LogOut,
Plus
} from "lucide-react";
import { useTheme } from "@/context/ThemeContext";
interface Command {
id: string;
title: string;
subtitle?: string;
icon: React.ReactNode;
action: () => void;
category: string;
keywords: string[];
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const [, setLocation] = useLocation();
const [query, setQuery] = useState("");
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { isListening, startListening, stopListening, speak } = useVoiceContext();
const commands: Command[] = [
// Navigation
{
id: "nav-home",
title: "Go to Dashboard",
subtitle: "Navigate to main dashboard",
icon: <Home className="w-4 h-4" />,
action: () => setLocation("/dashboard"),
category: "Navigation",
keywords: ["dashboard", "home", "main"],
},
{
id: "nav-tasks",
title: "View Tasks",
subtitle: "Open task management",
icon: <CheckSquare className="w-4 h-4" />,
action: () => setLocation("/dashboard?tab=tasks"),
category: "Navigation",
keywords: ["tasks", "todo", "work"],
},
{
id: "nav-finance",
title: "Financial Records",
subtitle: "Manage income and expenses",
icon: <DollarSign className="w-4 h-4" />,
action: () => setLocation("/dashboard?tab=finance"),
category: "Navigation",
keywords: ["finance", "money", "budget", "expenses"],
},
{
id: "nav-professionals",
title: "Professional Directory",
subtitle: "Find and connect with professionals",
icon: <Users className="w-4 h-4" />,
action: () => setLocation("/professionals"),
category: "Navigation",
keywords: ["professionals", "directory", "services"],
},
// Actions
{
id: "action-new-task",
title: "Create New Task",
subtitle: "Add a new task to your list",
icon: <Plus className="w-4 h-4" />,
action: () => {
setLocation("/dashboard?tab=tasks&action=new");
speak("Creating new task");
},
category: "Actions",
keywords: ["create", "new", "task", "add"],
},
{
id: "action-new-expense",
title: "Add Expense",
subtitle: "Record a new expense",
icon: <DollarSign className="w-4 h-4" />,
action: () => {
setLocation("/dashboard?tab=finance&action=expense");
speak("Adding new expense");
},
category: "Actions",
keywords: ["expense", "add", "money", "spend"],
},
{
id: "action-new-income",
title: "Add Income",
subtitle: "Record new income",
icon: <DollarSign className="w-4 h-4" />,
action: () => {
setLocation("/dashboard?tab=finance&action=income");
speak("Adding new income");
},
category: "Actions",
keywords: ["income", "add", "money", "earn"],
},
// Voice
{
id: "voice-toggle",
title: isListening ? "Stop Voice Recognition" : "Start Voice Recognition",
subtitle: "Toggle voice commands",
icon: <Mic className="w-4 h-4" />,
action: () => {
if (isListening) {
stopListening();
speak("Voice recognition stopped");
} else {
startListening();
speak("Voice recognition started");
}
},
category: "Voice",
keywords: ["voice", "microphone", "speech", "listen"],
},
// Settings
{
id: "settings-theme",
title: `Switch to ${theme === "dark" ? "Light" : "Dark"} Mode`,
subtitle: "Toggle application theme",
icon: theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />,
action: () => {
toggleTheme();
speak(`Switched to ${theme === "dark" ? "light" : "dark"} mode`);
},
category: "Settings",
keywords: ["theme", "dark", "light", "appearance"],
},
{
id: "settings-logout",
title: "Sign Out",
subtitle: "Log out of your account",
icon: <LogOut className="w-4 h-4" />,
action: () => {
logout();
speak("Signed out successfully");
},
category: "Account",
keywords: ["logout", "sign out", "exit"],
},
];
// Filter commands based on user role
const availableCommands = commands.filter(command => {
if (command.id === "nav-professionals" && user?.role === "standard") {
return false; // Hide professional directory for standard users
}
return true;
});
const filteredCommands = availableCommands.filter(command =>
command.title.toLowerCase().includes(query.toLowerCase()) ||
command.subtitle?.toLowerCase().includes(query.toLowerCase()) ||
command.keywords.some(keyword => keyword.toLowerCase().includes(query.toLowerCase()))
);
const groupedCommands = filteredCommands.reduce((groups, command) => {
const category = command.category;
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(command);
return groups;
}, {} as Record<string, Command[]>);
const handleCommandSelect = (command: Command) => {
command.action();
onOpenChange(false);
setQuery("");
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && filteredCommands.length > 0) {
handleCommandSelect(filteredCommands[0]);
}
};
// Global keyboard shortcut
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
onOpenChange(!open);
}
if (event.key === "Escape") {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0">
<div className="border-b p-4">
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search commands or say something..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
<Badge variant="outline" className="text-xs">
{isListening ? "Listening..." : "⌘K"}
</Badge>
</div>
</div>
<div className="max-h-96 overflow-y-auto p-2">
{filteredCommands.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No commands found. Try voice commands in Arabic or English.
</div>
) : (
Object.entries(groupedCommands).map(([category, commands]) => (
<div key={category} className="mb-4">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{category}
</div>
{commands.map((command) => (
<button
key={command.id}
onClick={() => handleCommandSelect(command)}
className="w-full flex items-center space-x-3 px-2 py-2 text-left hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
>
{command.icon}
<div className="flex-1 min-w-0">
<div className="font-medium">{command.title}</div>
{command.subtitle && (
<div className="text-sm text-muted-foreground truncate">
{command.subtitle}
</div>
)}
</div>
</button>
))}
</div>
))
)}
</div>
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-accent rounded">K</kbd> to open
<kbd className="px-1 py-0.5 bg-accent rounded ml-1"></kbd> to select
Voice commands supported in Arabic and English
</div>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: Date;
read: boolean;
actionUrl?: string;
}
interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const { toast } = useToast();
const addNotification = (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
const newNotification: Notification = {
...notification,
id: `notification-${Date.now()}-${Math.random()}`,
timestamp: new Date(),
read: false,
};
setNotifications(prev => [newNotification, ...prev]);
// Show toast for new notifications
toast({
title: notification.title,
description: notification.message,
variant: notification.type === 'error' ? 'destructive' : 'default',
});
};
const markAsRead = (id: string) => {
setNotifications(prev =>
prev.map(notification =>
notification.id === id ? { ...notification, read: true } : notification
)
);
};
const markAllAsRead = () => {
setNotifications(prev =>
prev.map(notification => ({ ...notification, read: true }))
);
};
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(notification => notification.id !== id));
};
const clearAll = () => {
setNotifications([]);
};
const unreadCount = notifications.filter(n => !n.read).length;
// Simulate real-time notifications (in production, this would be WebSocket or SSE)
useEffect(() => {
const interval = setInterval(() => {
// This would be replaced with actual real-time notification system
if (Math.random() < 0.1) { // 10% chance every 30 seconds
const sampleNotifications = [
{
title: "New Appointment Request",
message: "A client has requested an appointment for tomorrow at 2:00 PM",
type: 'info' as const,
},
{
title: "Task Due Soon",
message: "Review quarterly reports is due in 2 hours",
type: 'warning' as const,
},
{
title: "Payment Received",
message: "Payment of $150 received for consultation",
type: 'success' as const,
},
];
const randomNotification = sampleNotifications[Math.floor(Math.random() * sampleNotifications.length)];
addNotification(randomNotification);
}
}, 30000); // Every 30 seconds
return () => clearInterval(interval);
}, []);
return (
<NotificationContext.Provider
value={{
notifications,
unreadCount,
addNotification,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
}}
>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error("useNotifications must be used within a NotificationProvider");
}
return context;
}
+82
View File
@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { useVoice } from "@/hooks/useVoice";
interface VoiceContextType {
isListening: boolean;
isSupported: boolean;
language: 'en' | 'ar';
startListening: () => void;
stopListening: () => void;
setLanguage: (lang: 'en' | 'ar') => void;
executeVoiceCommand: (command: string) => Promise<void>;
speak: (text: string, lang?: 'en' | 'ar') => void;
}
const VoiceContext = createContext<VoiceContextType | undefined>(undefined);
export function VoiceProvider({ children }: { children: React.ReactNode }) {
const { isListening, startListening, stopListening, isSupported } = useVoice();
const [language, setLanguage] = useState<'en' | 'ar'>('en');
const executeVoiceCommand = async (command: string) => {
try {
// Process voice commands for navigation and actions
const lowerCommand = command.toLowerCase();
if (lowerCommand.includes('navigate') || lowerCommand.includes('go to')) {
// Handle navigation commands
if (lowerCommand.includes('dashboard')) {
window.location.href = '/dashboard';
} else if (lowerCommand.includes('tasks')) {
window.location.href = '/dashboard?tab=tasks';
} else if (lowerCommand.includes('finance')) {
window.location.href = '/dashboard?tab=finance';
}
} else if (lowerCommand.includes('create task') || lowerCommand.includes('أنشئ مهمة')) {
// Handle task creation
// This would integrate with your task creation API
console.log('Voice task creation:', command);
} else if (lowerCommand.includes('book appointment') || lowerCommand.includes('احجز موعد')) {
// Handle appointment booking
console.log('Voice appointment booking:', command);
}
} catch (error) {
console.error('Voice command execution error:', error);
}
};
const speak = (text: string, lang: 'en' | 'ar' = language) => {
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang === 'ar' ? 'ar-OM' : 'en-US'; // Omani Arabic support
utterance.rate = 0.9;
utterance.pitch = 1;
speechSynthesis.speak(utterance);
}
};
return (
<VoiceContext.Provider
value={{
isListening,
isSupported,
language,
startListening,
stopListening,
setLanguage,
executeVoiceCommand,
speak,
}}
>
{children}
</VoiceContext.Provider>
);
}
export function useVoiceContext() {
const context = useContext(VoiceContext);
if (context === undefined) {
throw new Error("useVoiceContext must be used within a VoiceProvider");
}
return context;
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Star, MapPin, Clock, Phone, Mail, Calendar } from "lucide-react";
import { Link } from "wouter";
interface Professional {
id: number;
firstName: string;
lastName: string;
businessName: string;
professionalType: string;
bio: string;
specializations: string[];
address: string;
phoneNumber: string;
email: string;
profileImageUrl: string;
averageRating: number;
totalReviews: number;
allowAppointmentBooking: boolean;
}
export default function ProfessionalDirectory() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedType, setSelectedType] = useState("");
const { data: professionals = [], isLoading } = useQuery({
queryKey: ["/api/professionals/search", { searchTerm, type: selectedType }],
enabled: true,
});
const professionalTypes = [
"doctor", "dentist", "therapist", "consultant", "lawyer",
"accountant", "coach", "tutor", "other"
];
const filteredProfessionals = professionals.filter((prof: Professional) =>
prof.firstName.toLowerCase().includes(searchTerm.toLowerCase()) ||
prof.lastName.toLowerCase().includes(searchTerm.toLowerCase()) ||
prof.businessName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
prof.specializations?.some((spec: string) =>
spec.toLowerCase().includes(searchTerm.toLowerCase())
)
);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-violet-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent mb-4">
Professional Directory
</h1>
<p className="text-lg text-muted-foreground">
Discover and connect with trusted professionals in your area
</p>
</div>
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-8">
<div className="flex-1">
<Input
placeholder="Search professionals, services, or specializations..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<div className="flex gap-2 flex-wrap">
<Button
variant={selectedType === "" ? "default" : "outline"}
onClick={() => setSelectedType("")}
size="sm"
>
All
</Button>
{professionalTypes.map((type) => (
<Button
key={type}
variant={selectedType === type ? "default" : "outline"}
onClick={() => setSelectedType(type)}
size="sm"
className="capitalize"
>
{type}
</Button>
))}
</div>
</div>
{/* Results */}
{isLoading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-200 rounded-full"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-32"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="h-3 bg-gray-200 rounded"></div>
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
</CardContent>
</Card>
))}
</div>
) : filteredProfessionals.length === 0 ? (
<div className="text-center py-12">
<p className="text-lg text-muted-foreground">
No professionals found matching your criteria.
</p>
<p className="text-sm text-muted-foreground mt-2">
Try adjusting your search terms or filters.
</p>
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredProfessionals.map((professional: Professional) => (
<Card key={professional.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-center space-x-3">
<Avatar className="w-12 h-12">
<AvatarImage src={professional.profileImageUrl} />
<AvatarFallback>
{professional.firstName[0]}{professional.lastName[0]}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<CardTitle className="text-lg">
{professional.firstName} {professional.lastName}
</CardTitle>
{professional.businessName && (
<p className="text-sm text-muted-foreground">
{professional.businessName}
</p>
)}
<div className="flex items-center gap-1 mt-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-sm font-medium">
{professional.averageRating || "New"}
</span>
<span className="text-sm text-muted-foreground">
({professional.totalReviews || 0} reviews)
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Badge variant="secondary" className="capitalize">
{professional.professionalType}
</Badge>
{professional.bio && (
<p className="text-sm text-muted-foreground line-clamp-2">
{professional.bio}
</p>
)}
{professional.specializations && professional.specializations.length > 0 && (
<div className="flex flex-wrap gap-1">
{professional.specializations.slice(0, 3).map((spec, index) => (
<Badge key={index} variant="outline" className="text-xs">
{spec}
</Badge>
))}
{professional.specializations.length > 3 && (
<Badge variant="outline" className="text-xs">
+{professional.specializations.length - 3} more
</Badge>
)}
</div>
)}
<div className="space-y-2 text-sm">
{professional.address && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="w-4 h-4" />
<span>{professional.address}</span>
</div>
)}
{professional.phoneNumber && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="w-4 h-4" />
<span>{professional.phoneNumber}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-4">
<Button asChild className="flex-1" size="sm">
<Link href={`/professional/${professional.id}`}>
View Profile
</Link>
</Button>
{professional.allowAppointmentBooking && (
<Button variant="outline" size="sm" className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
Book
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</div>
);
}