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 { 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>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
</TooltipProvider>
|
||||
<NotificationProvider>
|
||||
<VoiceProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
</TooltipProvider>
|
||||
</VoiceProvider>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</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