From 1f6e5f53742e3be13918916a7baf316175295972 Mon Sep 17 00:00:00 2001 From: ghaddaditw <40211818-ghaddaditw@users.noreply.replit.com> Date: Fri, 30 May 2025 17:22:52 +0000 Subject: [PATCH] Improve user registration and access control for different account types Adds admin user creation, role-based access, password confirmation, and professional profile fields to the user schema. 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/8e77b04a-d19f-45af-9f57-84d64672fc92.jpg --- ...base-Schema-1-Extended-U-1748625676335.txt | 2892 +++++++++++++++++ server/controllers/authController.ts | 10 +- shared/schema.ts | 33 + 3 files changed, 2934 insertions(+), 1 deletion(-) create mode 100644 attached_assets/Pasted--Social-Connections-Appointment-Booking-Extension-Enhanced-Database-Schema-1-Extended-U-1748625676335.txt diff --git a/attached_assets/Pasted--Social-Connections-Appointment-Booking-Extension-Enhanced-Database-Schema-1-Extended-U-1748625676335.txt b/attached_assets/Pasted--Social-Connections-Appointment-Booking-Extension-Enhanced-Database-Schema-1-Extended-U-1748625676335.txt new file mode 100644 index 0000000..5e00841 --- /dev/null +++ b/attached_assets/Pasted--Social-Connections-Appointment-Booking-Extension-Enhanced-Database-Schema-1-Extended-U-1748625676335.txt @@ -0,0 +1,2892 @@ +# Social Connections & Appointment Booking Extension + +## Enhanced Database Schema + +### 1. Extended User Model (backend/src/models/user.ts) +```typescript +import { pgTable, serial, varchar, text, timestamp, pgEnum, boolean } from 'drizzle-orm/pg-core' +import { InferSelectModel, InferInsertModel } from 'drizzle-orm' + +export const userRoleEnum = pgEnum('user_role', ['standard', 'pro', 'dev']) +export const professionalTypeEnum = pgEnum('professional_type', [ + 'doctor', 'dentist', 'therapist', 'consultant', 'lawyer', + 'accountant', 'coach', 'tutor', 'other' +]) + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: varchar('email', { length: 255 }).notNull().unique(), + passwordHash: text('password_hash').notNull(), + firstName: varchar('first_name', { length: 100 }), + lastName: varchar('last_name', { length: 100 }), + role: userRoleEnum('role').default('standard').notNull(), + + // Professional Profile Fields + isProfessional: boolean('is_professional').default(false).notNull(), + professionalType: professionalTypeEnum('professional_type'), + businessName: varchar('business_name', { length: 255 }), + bio: text('bio'), + specializations: text('specializations'), // JSON array as text + phoneNumber: varchar('phone_number', { length: 20 }), + address: text('address'), + + // Social & Discovery + isPublicProfile: boolean('is_public_profile').default(false).notNull(), + allowAppointmentBooking: boolean('allow_appointment_booking').default(false).notNull(), + profileImageUrl: varchar('profile_image_url', { length: 500 }), + + // Existing fields + isActive: varchar('is_active', { length: 10 }).default('true').notNull(), + lastLoginAt: timestamp('last_login_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +export type User = InferSelectModel +export type NewUser = InferInsertModel +``` + +### 2. Professional Availability Model (backend/src/models/availability.ts) +```typescript +import { pgTable, serial, integer, varchar, time, boolean, json } from 'drizzle-orm/pg-core' +import { users } from './user.js' +import { InferSelectModel, InferInsertModel } from 'drizzle-orm' + +export const dayOfWeekEnum = pgEnum('day_of_week', [ + 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' +]) + +export const availability = pgTable('availability', { + id: serial('id').primaryKey(), + professionalId: integer('professional_id').references(() => users.id).notNull(), + dayOfWeek: dayOfWeekEnum('day_of_week').notNull(), + startTime: time('start_time').notNull(), + endTime: time('end_time').notNull(), + isAvailable: boolean('is_available').default(true).notNull(), + slotDurationMinutes: integer('slot_duration_minutes').default(30).notNull(), + breakBetweenSlots: integer('break_between_slots').default(0).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +export type Availability = InferSelectModel +export type NewAvailability = InferInsertModel +``` + +### 3. Appointment Model (backend/src/models/appointment.ts) +```typescript +import { pgTable, serial, integer, varchar, text, timestamp, pgEnum, decimal } from 'drizzle-orm/pg-core' +import { users } from './user.js' +import { InferSelectModel, InferInsertModel } from 'drizzle-orm' + +export const appointmentStatusEnum = pgEnum('appointment_status', [ + 'pending', 'confirmed', 'cancelled', 'completed', 'no_show' +]) + +export const appointments = pgTable('appointments', { + id: serial('id').primaryKey(), + professionalId: integer('professional_id').references(() => users.id).notNull(), + clientId: integer('client_id').references(() => users.id).notNull(), + + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + status: appointmentStatusEnum('status').default('pending').notNull(), + + scheduledDate: timestamp('scheduled_date').notNull(), + duration: integer('duration').notNull(), // minutes + + // Financial + price: decimal('price', { precision: 10, scale: 2 }), + currency: varchar('currency', { length: 3 }).default('USD'), + isPaid: boolean('is_paid').default(false).notNull(), + + // Contact info + clientEmail: varchar('client_email', { length: 255 }), + clientPhone: varchar('client_phone', { length: 20 }), + + // Notes + professionalNotes: text('professional_notes'), + clientNotes: text('client_notes'), + + // Timestamps + confirmedAt: timestamp('confirmed_at'), + completedAt: timestamp('completed_at'), + cancelledAt: timestamp('cancelled_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +export type Appointment = InferSelectModel +export type NewAppointment = InferInsertModel +``` + +### 4. Social Connections Model (backend/src/models/connection.ts) +```typescript +import { pgTable, serial, integer, varchar, text, timestamp, pgEnum } from 'drizzle-orm/pg-core' +import { users } from './user.js' +import { InferSelectModel, InferInsertModel } from 'drizzle-orm' + +export const connectionStatusEnum = pgEnum('connection_status', [ + 'pending', 'accepted', 'blocked' +]) + +export const connections = pgTable('connections', { + id: serial('id').primaryKey(), + requesterId: integer('requester_id').references(() => users.id).notNull(), + receiverId: integer('receiver_id').references(() => users.id).notNull(), + status: connectionStatusEnum('status').default('pending').notNull(), + message: text('message'), // Optional message when sending connection request + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +export type Connection = InferSelectModel +export type NewConnection = InferInsertModel +``` + +### 5. Professional Reviews Model (backend/src/models/review.ts) +```typescript +import { pgTable, serial, integer, varchar, text, timestamp } from 'drizzle-orm/pg-core' +import { users } from './user.js' +import { appointments } from './appointment.js' +import { InferSelectModel, InferInsertModel } from 'drizzle-orm' + +export const reviews = pgTable('reviews', { + id: serial('id').primaryKey(), + professionalId: integer('professional_id').references(() => users.id).notNull(), + clientId: integer('client_id').references(() => users.id).notNull(), + appointmentId: integer('appointment_id').references(() => appointments.id), + + rating: integer('rating').notNull(), // 1-5 stars + title: varchar('title', { length: 255 }), + comment: text('comment'), + + isAnonymous: boolean('is_anonymous').default(false).notNull(), + isPublic: boolean('is_public').default(true).notNull(), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +export type Review = InferSelectModel +export type NewReview = InferInsertModel +``` + +## Backend API Extensions + +### 1. Professional Profile Controller (backend/src/controllers/professionalController.ts) +```typescript +import { Request, Response } from 'express' +import { getDatabase } from '../config/database.js' +import { users, availability, appointments, reviews } from '../models/index.js' +import { eq, and, gte, desc, avg, count } from 'drizzle-orm' +import { AuthenticatedRequest } from '../middleware/auth.js' +import { asyncHandler, createError } from '../middleware/errorHandler.js' + +export const getProfessionalProfile = asyncHandler(async (req: Request, res: Response) => { + const { professionalId } = req.params + const db = getDatabase() + + const [professional] = await db + .select({ + id: users.id, + firstName: users.firstName, + lastName: users.lastName, + businessName: users.businessName, + professionalType: users.professionalType, + bio: users.bio, + specializations: users.specializations, + phoneNumber: users.phoneNumber, + address: users.address, + profileImageUrl: users.profileImageUrl, + allowAppointmentBooking: users.allowAppointmentBooking, + }) + .from(users) + .where(and( + eq(users.id, parseInt(professionalId)), + eq(users.isProfessional, true), + eq(users.isPublicProfile, true) + )) + .limit(1) + + if (!professional) { + throw createError('Professional not found or profile not public', 404) + } + + // Get availability + const professionalAvailability = await db + .select() + .from(availability) + .where(eq(availability.professionalId, parseInt(professionalId))) + + // Get reviews stats + const [reviewStats] = await db + .select({ + averageRating: avg(reviews.rating), + totalReviews: count(reviews.id) + }) + .from(reviews) + .where(and( + eq(reviews.professionalId, parseInt(professionalId)), + eq(reviews.isPublic, true) + )) + + // Get recent reviews + const recentReviews = await db + .select({ + id: reviews.id, + rating: reviews.rating, + title: reviews.title, + comment: reviews.comment, + isAnonymous: reviews.isAnonymous, + createdAt: reviews.createdAt, + clientName: users.firstName + }) + .from(reviews) + .leftJoin(users, eq(reviews.clientId, users.id)) + .where(and( + eq(reviews.professionalId, parseInt(professionalId)), + eq(reviews.isPublic, true) + )) + .orderBy(desc(reviews.createdAt)) + .limit(5) + + res.json({ + success: true, + professional: { + ...professional, + specializations: professional.specializations ? JSON.parse(professional.specializations) : [] + }, + availability: professionalAvailability, + stats: { + averageRating: reviewStats.averageRating ? Number(reviewStats.averageRating).toFixed(1) : null, + totalReviews: reviewStats.totalReviews || 0 + }, + recentReviews: recentReviews.map(review => ({ + ...review, + clientName: review.isAnonymous ? 'Anonymous' : review.clientName + })) + }) +}) + +export const searchProfessionals = asyncHandler(async (req: Request, res: Response) => { + const { type, location, specialization, page = 1, limit = 10 } = req.query + const db = getDatabase() + + let query = db + .select({ + id: users.id, + firstName: users.firstName, + lastName: users.lastName, + businessName: users.businessName, + professionalType: users.professionalType, + bio: users.bio, + specializations: users.specializations, + address: users.address, + profileImageUrl: users.profileImageUrl, + }) + .from(users) + .where(and( + eq(users.isProfessional, true), + eq(users.isPublicProfile, true), + eq(users.allowAppointmentBooking, true) + )) + + // Add filters + if (type) { + query = query.where(eq(users.professionalType, type as string)) + } + + const professionals = await query + .limit(Number(limit)) + .offset((Number(page) - 1) * Number(limit)) + + // Get ratings for each professional + const professionalsWithRatings = await Promise.all( + professionals.map(async (prof) => { + const [stats] = await db + .select({ + averageRating: avg(reviews.rating), + totalReviews: count(reviews.id) + }) + .from(reviews) + .where(eq(reviews.professionalId, prof.id)) + + return { + ...prof, + specializations: prof.specializations ? JSON.parse(prof.specializations) : [], + averageRating: stats.averageRating ? Number(stats.averageRating).toFixed(1) : null, + totalReviews: stats.totalReviews || 0 + } + }) + ) + + res.json({ + success: true, + professionals: professionalsWithRatings, + pagination: { + page: Number(page), + limit: Number(limit), + total: professionals.length + } + }) +}) + +export const updateProfessionalProfile = asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const userId = req.user!.id + const { + isProfessional, + professionalType, + businessName, + bio, + specializations, + phoneNumber, + address, + isPublicProfile, + allowAppointmentBooking + } = req.body + + const db = getDatabase() + + const [updatedUser] = await db + .update(users) + .set({ + isProfessional, + professionalType, + businessName, + bio, + specializations: specializations ? JSON.stringify(specializations) : null, + phoneNumber, + address, + isPublicProfile, + allowAppointmentBooking, + updatedAt: new Date() + }) + .where(eq(users.id, userId)) + .returning() + + res.json({ + success: true, + message: 'Professional profile updated successfully', + user: updatedUser + }) +}) +``` + +### 2. Appointment Controller (backend/src/controllers/appointmentController.ts) +```typescript +import { Request, Response } from 'express' +import { getDatabase } from '../config/database.js' +import { appointments, users, availability } from '../models/index.js' +import { eq, and, gte, lte, or } from 'drizzle-orm' +import { AuthenticatedRequest } from '../middleware/auth.js' +import { asyncHandler, createError } from '../middleware/errorHandler.js' + +export const getAvailableSlots = asyncHandler(async (req: Request, res: Response) => { + const { professionalId, date } = req.query + const db = getDatabase() + + if (!professionalId || !date) { + throw createError('Professional ID and date are required', 400) + } + + const requestedDate = new Date(date as string) + const dayOfWeek = requestedDate.toLocaleLowerCase().substring(0, 3) + 'day' + + // Get professional's availability for the day + const professionalAvailability = await db + .select() + .from(availability) + .where(and( + eq(availability.professionalId, parseInt(professionalId as string)), + eq(availability.dayOfWeek, dayOfWeek as any), + eq(availability.isAvailable, true) + )) + + if (professionalAvailability.length === 0) { + return res.json({ + success: true, + slots: [], + message: 'No availability for this day' + }) + } + + // Get existing appointments for the date + const startOfDay = new Date(requestedDate) + startOfDay.setHours(0, 0, 0, 0) + const endOfDay = new Date(requestedDate) + endOfDay.setHours(23, 59, 59, 999) + + const existingAppointments = await db + .select() + .from(appointments) + .where(and( + eq(appointments.professionalId, parseInt(professionalId as string)), + gte(appointments.scheduledDate, startOfDay), + lte(appointments.scheduledDate, endOfDay), + or( + eq(appointments.status, 'confirmed'), + eq(appointments.status, 'pending') + ) + )) + + // Generate available slots + const availableSlots: Array<{ time: string, available: boolean }> = [] + + for (const avail of professionalAvailability) { + const startTime = new Date(`${date} ${avail.startTime}`) + const endTime = new Date(`${date} ${avail.endTime}`) + const slotDuration = avail.slotDurationMinutes || 30 + const breakDuration = avail.breakBetweenSlots || 0 + + let currentTime = new Date(startTime) + + while (currentTime < endTime) { + const slotEnd = new Date(currentTime.getTime() + slotDuration * 60000) + + if (slotEnd <= endTime) { + const isBooked = existingAppointments.some(apt => { + const aptStart = new Date(apt.scheduledDate) + const aptEnd = new Date(aptStart.getTime() + apt.duration * 60000) + return (currentTime >= aptStart && currentTime < aptEnd) || + (slotEnd > aptStart && slotEnd <= aptEnd) + }) + + availableSlots.push({ + time: currentTime.toTimeString().substring(0, 5), + available: !isBooked + }) + } + + currentTime = new Date(currentTime.getTime() + (slotDuration + breakDuration) * 60000) + } + } + + res.json({ + success: true, + date: date, + slots: availableSlots + }) +}) + +export const bookAppointment = asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const { + professionalId, + scheduledDate, + duration, + title, + description, + clientEmail, + clientPhone, + clientNotes + } = req.body + + const clientId = req.user!.id + const db = getDatabase() + + // Check if the slot is still available + const existingAppointment = await db + .select() + .from(appointments) + .where(and( + eq(appointments.professionalId, professionalId), + eq(appointments.scheduledDate, new Date(scheduledDate)), + or( + eq(appointments.status, 'confirmed'), + eq(appointments.status, 'pending') + ) + )) + .limit(1) + + if (existingAppointment.length > 0) { + throw createError('This time slot is no longer available', 409) + } + + // Create appointment + const [newAppointment] = await db + .insert(appointments) + .values({ + professionalId, + clientId, + scheduledDate: new Date(scheduledDate), + duration: duration || 30, + title, + description, + clientEmail, + clientPhone, + clientNotes, + status: 'pending' + }) + .returning() + + // Get professional and client info for response + const [professional] = await db + .select({ + firstName: users.firstName, + lastName: users.lastName, + businessName: users.businessName, + email: users.email + }) + .from(users) + .where(eq(users.id, professionalId)) + .limit(1) + + res.status(201).json({ + success: true, + message: 'Appointment booked successfully', + appointment: newAppointment, + professional + }) +}) + +export const getAppointments = asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const userId = req.user!.id + const { type = 'all', status, startDate, endDate } = req.query + const db = getDatabase() + + let query = db + .select({ + id: appointments.id, + title: appointments.title, + description: appointments.description, + status: appointments.status, + scheduledDate: appointments.scheduledDate, + duration: appointments.duration, + price: appointments.price, + isPaid: appointments.isPaid, + professionalNotes: appointments.professionalNotes, + clientNotes: appointments.clientNotes, + createdAt: appointments.createdAt, + // Professional info + professionalFirstName: users.firstName, + professionalLastName: users.lastName, + professionalBusinessName: users.businessName, + professionalEmail: users.email, + professionalPhone: users.phoneNumber, + }) + .from(appointments) + .leftJoin(users, eq(appointments.professionalId, users.id)) + + // Filter by user type (as client or professional) + if (type === 'client') { + query = query.where(eq(appointments.clientId, userId)) + } else if (type === 'professional') { + query = query.where(eq(appointments.professionalId, userId)) + } else { + query = query.where(or( + eq(appointments.clientId, userId), + eq(appointments.professionalId, userId) + )) + } + + // Add additional filters + if (status) { + query = query.where(eq(appointments.status, status as any)) + } + + if (startDate) { + query = query.where(gte(appointments.scheduledDate, new Date(startDate as string))) + } + + if (endDate) { + query = query.where(lte(appointments.scheduledDate, new Date(endDate as string))) + } + + const userAppointments = await query.orderBy(appointments.scheduledDate) + + res.json({ + success: true, + appointments: userAppointments + }) +}) + +export const updateAppointmentStatus = asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const { appointmentId } = req.params + const { status, professionalNotes } = req.body + const userId = req.user!.id + const db = getDatabase() + + // Check if user is the professional for this appointment + const [appointment] = await db + .select() + .from(appointments) + .where(eq(appointments.id, parseInt(appointmentId))) + .limit(1) + + if (!appointment) { + throw createError('Appointment not found', 404) + } + + if (appointment.professionalId !== userId) { + throw createError('Not authorized to update this appointment', 403) + } + + const updateData: any = { + status, + updatedAt: new Date() + } + + if (professionalNotes) { + updateData.professionalNotes = professionalNotes + } + + if (status === 'confirmed') { + updateData.confirmedAt = new Date() + } else if (status === 'completed') { + updateData.completedAt = new Date() + } else if (status === 'cancelled') { + updateData.cancelledAt = new Date() + } + + const [updatedAppointment] = await db + .update(appointments) + .set(updateData) + .where(eq(appointments.id, parseInt(appointmentId))) + .returning() + + res.json({ + success: true, + message: 'Appointment status updated successfully', + appointment: updatedAppointment + }) +}) +``` + +## Frontend Extensions + +### 1. Professional Profile Page (frontend/src/pages/ProfessionalProfilePage.jsx) +```jsx +import React, { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { Calendar, Clock, Star, MapPin, Phone, Mail, User } from 'lucide-react' +import { professionalService } from '../services/professionalService' +import { appointmentService } from '../services/appointmentService' +import LoadingSpinner from '../components/common/LoadingSpinner' +import AppointmentBookingModal from '../components/appointments/AppointmentBookingModal' + +const ProfessionalProfilePage = () => { + const { professionalId } = useParams() + const [professional, setProfessional] = useState(null) + const [loading, setLoading] = useState(true) + const [showBookingModal, setShowBookingModal] = useState(false) + const [selectedDate, setSelectedDate] = useState('') + const [selectedTime, setSelectedTime] = useState('') + const [availableSlots, setAvailableSlots] = useState([]) + + useEffect(() => { + if (professionalId) { + fetchProfessionalProfile() + } + }, [professionalId]) + + const fetchProfessionalProfile = async () => { + try { + const response = await professionalService.getProfessionalProfile(professionalId) + if (response.success) { + setProfessional(response.professional) + } + } catch (error) { + console.error('Failed to fetch professional profile:', error) + } finally { + setLoading(false) + } + } + + const fetchAvailableSlots = async (date) => { + try { + const response = await appointmentService.getAvailableSlots(professionalId, date) + if (response.success) { + setAvailableSlots(response.slots) + } + } catch (error) { + console.error('Failed to fetch available slots:', error) + } + } + + const handleDateSelect = (date) => { + setSelectedDate(date) + fetchAvailableSlots(date) + } + + const handleTimeSelect = (time) => { + setSelectedTime(time) + setShowBookingModal(true) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!professional) { + return ( +
+
+

+ Professional Not Found +

+

+ The requested professional profile could not be found. +

+
+
+ ) + } + + return ( +
+
+ {/* Professional Header */} +
+
+
+ {professional.profileImageUrl ? ( + {`${professional.firstName} + ) : ( +
+ +
+ )} +
+ +
+

+ {professional.businessName || `${professional.firstName} ${professional.lastName}`} +

+

+ {professional.professionalType} +

+ + {/* Rating */} +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ + {professional.stats?.averageRating || 'No ratings'} + ({professional.stats?.totalReviews || 0} reviews) + +
+ + {/* Specializations */} + {professional.specializations && professional.specializations.length > 0 && ( +
+ {professional.specializations.map((spec, index) => ( + + {spec} + + ))} +
+ )} + + {/* Contact Info */} +
+ {professional.address && ( +
+ + {professional.address} +
+ )} + {professional.phoneNumber && ( +
+ + {professional.phoneNumber} +
+ )} +
+
+ +
+ +
+
+
+ +
+ {/* Left Column - About & Reviews */} +
+ {/* About Section */} + {professional.bio && ( +
+

About

+

+ {professional.bio} +

+
+ )} + + {/* Reviews Section */} +
+

Recent Reviews

+ {professional.recentReviews && professional.recentReviews.length > 0 ? ( +
+ {professional.recentReviews.map((review) => ( +
+
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ + by {review.clientName} • {new Date(review.createdAt).toLocaleDateString()} + +
+ {review.title && ( +

+ {review.title} +

+ )} + {review.comment && ( +

+ {review.comment} +

+ )} +
+ ))} +
+ ) : ( +

+ No reviews yet +

+ )} +
+
+ + {/* Right Column - Availability */} +
+
+

+ + Availability +

+ + {professional.availability && professional.availability.length > 0 ? ( +
+ {professional.availability.map((avail) => ( +
+ + {avail.dayOfWeek} + + + {avail.startTime} - {avail.endTime} + +
+ ))} +
+ ) : ( +

+ No availability set +

+ )} +
+ + {/* Quick Stats */} +
+

Quick Stats

+
+
+ Average Rating + + {professional.stats?.averageRating || 'N/A'} + +
+
+ Total Reviews + + {professional.stats?.totalReviews || 0} + +
+
+ Professional Type + + {professional.professionalType} + +
+
+
+
+
+
+ + {/* Appointment Booking Modal */} + {showBookingModal && ( + setShowBookingModal(false)} + onBooked={() => { + setShowBookingModal(false) + // Could show success message or redirect + }} + /> + )} +
+ ) +} + +export default ProfessionalProfilePage +``` + +### 2. Appointment Booking Modal (frontend/src/components/appointments/AppointmentBookingModal.jsx) +```jsx +import React, { useState, useEffect } from 'react' +import { X, Calendar, Clock, User, Mail, Phone, MessageSquare } from 'lucide-react' +import { appointmentService } from '../../services/appointmentService' +import LoadingSpinner from '../common/LoadingSpinner' + +const AppointmentBookingModal = ({ professionalId, professional, onClose, onBooked }) => { + const [step, setStep] = useState(1) // 1: Date/Time, 2: Details, 3: Confirmation + const [selectedDate, setSelectedDate] = useState('') + const [selectedTime, setSelectedTime] = useState('') + const [availableSlots, setAvailableSlots] = useState([]) + const [loading, setLoading] = useState(false) + const [bookingData, setBookingData] = useState({ + title: '', + description: '', + clientEmail: '', + clientPhone: '', + clientNotes: '', + duration: 30 + }) + + const [errors, setErrors] = useState({}) + + // Generate next 30 days for date selection + const getAvailableDates = () => { + const dates = [] + const today = new Date() + + for (let i = 1; i <= 30; i++) { + const date = new Date(today) + date.setDate(today.getDate() + i) + + // Skip weekends if professional doesn't work weekends + const dayOfWeek = date.toLocaleDateString('en', { weekday: 'long' }).toLowerCase() + const hasAvailability = professional.availability?.some( + avail => avail.dayOfWeek === dayOfWeek && avail.isAvailable + ) + + if (hasAvailability) { + dates.push({ + date: date.toISOString().split('T')[0], + display: date.toLocaleDateString('en', { + month: 'short', + day: 'numeric', + weekday: 'short' + }) + }) + } + } + + return dates + } + + const fetchAvailableSlots = async (date) => { + setLoading(true) + try { + const response = await appointmentService.getAvailableSlots(professionalId, date) + if (response.success) { + setAvailableSlots(response.slots || []) + } + } catch (error) { + console.error('Failed to fetch available slots:', error) + setAvailableSlots([]) + } finally { + setLoading(false) + } + } + + const handleDateSelect = (date) => { + setSelectedDate(date) + setSelectedTime('') + fetchAvailableSlots(date) + } + + const handleTimeSelect = (time) => { + setSelectedTime(time) + } + + const handleInputChange = (field, value) => { + setBookingData(prev => ({ ...prev, [field]: value })) + // Clear error for this field + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })) + } + } + + const validateStep2 = () => { + const newErrors = {} + + if (!bookingData.title.trim()) { + newErrors.title = 'Appointment title is required' + } + + if (!bookingData.clientEmail.trim()) { + newErrors.clientEmail = 'Email is required' + } else if (!/\S+@\S+\.\S+/.test(bookingData.clientEmail)) { + newErrors.clientEmail = 'Valid email is required' + } + + if (!bookingData.clientPhone.trim()) { + newErrors.clientPhone = 'Phone number is required' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleBookAppointment = async () => { + setLoading(true) + try { + const appointmentDateTime = new Date(`${selectedDate}T${selectedTime}:00`) + + const response = await appointmentService.bookAppointment({ + professionalId: parseInt(professionalId), + scheduledDate: appointmentDateTime.toISOString(), + duration: bookingData.duration, + title: bookingData.title, + description: bookingData.description, + clientEmail: bookingData.clientEmail, + clientPhone: bookingData.clientPhone, + clientNotes: bookingData.clientNotes + }) + + if (response.success) { + setStep(3) + onBooked(response.appointment) + } + } catch (error) { + console.error('Failed to book appointment:', error) + alert('Failed to book appointment. Please try again.') + } finally { + setLoading(false) + } + } + + const nextStep = () => { + if (step === 1 && selectedDate && selectedTime) { + setStep(2) + } else if (step === 2 && validateStep2()) { + handleBookAppointment() + } + } + + const availableDates = getAvailableDates() + + return ( +
+
+ {/* Header */} +
+

+ Book Appointment +

+ +
+ + {/* Content */} +
+ {/* Professional Info */} +
+
+
+ +
+
+

+ {professional.businessName || `${professional.firstName} ${professional.lastName}`} +

+

+ {professional.professionalType} +

+
+
+
+ + {/* Step 1: Date and Time Selection */} + {step === 1 && ( +
+
+

+ + Select Date +

+
+ {availableDates.map((dateOption) => ( + + ))} +
+
+ + {selectedDate && ( +
+

+ + Select Time +

+ {loading ? ( +
+ +
+ ) : ( +
+ {availableSlots + .filter(slot => slot.available) + .map((slot) => ( + + ))} +
+ )} + {availableSlots.length === 0 && !loading && ( +

+ No available slots for this date +

+ )} +
+ )} +
+ )} + + {/* Step 2: Appointment Details */} + {step === 2 && ( +
+
+

+ Selected: {new Date(selectedDate).toLocaleDateString('en', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} at {selectedTime} +

+
+ +
+ + handleInputChange('title', e.target.value)} + className={`input-field ${errors.title ? 'border-red-500' : ''}`} + placeholder="e.g., Regular Check-up, Consultation" + /> + {errors.title &&

{errors.title}

} +
+ +
+ +