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:
+103
@@ -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
|
||||||
+12
-4
@@ -5,9 +5,12 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { ThemeProvider } from "@/context/ThemeContext";
|
import { ThemeProvider } from "@/context/ThemeContext";
|
||||||
|
import { VoiceProvider } from "@/context/VoiceProvider";
|
||||||
|
import { NotificationProvider } from "@/context/NotificationProvider";
|
||||||
import LandingPage from "@/pages/LandingPage";
|
import LandingPage from "@/pages/LandingPage";
|
||||||
import DashboardPage from "@/pages/DashboardPage";
|
import DashboardPage from "@/pages/DashboardPage";
|
||||||
import LoginPage from "@/pages/LoginPage";
|
import LoginPage from "@/pages/LoginPage";
|
||||||
|
import ProfessionalDirectory from "@/pages/ProfessionalDirectory";
|
||||||
// import OnboardingPage from "@/pages/OnboardingPage";
|
// import OnboardingPage from "@/pages/OnboardingPage";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ function Router() {
|
|||||||
<Route path="/login" component={LoginPage} />
|
<Route path="/login" component={LoginPage} />
|
||||||
<Route path="/register" component={LoginPage} />
|
<Route path="/register" component={LoginPage} />
|
||||||
<Route path="/dashboard" component={DashboardPage} />
|
<Route path="/dashboard" component={DashboardPage} />
|
||||||
|
<Route path="/professionals" component={ProfessionalDirectory} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
@@ -28,10 +32,14 @@ function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<TooltipProvider>
|
<NotificationProvider>
|
||||||
<Toaster />
|
<VoiceProvider>
|
||||||
<Router />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<Toaster />
|
||||||
|
<Router />
|
||||||
|
</TooltipProvider>
|
||||||
|
</VoiceProvider>
|
||||||
|
</NotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user