feat/authentication #21
@ -30,7 +30,9 @@ import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LoginDialog } from "@/components/LoginDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAppointments } from "@/hooks/useAppointments";
|
||||
import { toast } from "sonner";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
interface User {
|
||||
ID: number;
|
||||
@ -77,6 +79,7 @@ export default function BookNowPage() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const { create, isCreating } = useAppointments();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
@ -86,7 +89,6 @@ export default function BookNowPage() {
|
||||
preferredTimes: [] as string[],
|
||||
message: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
@ -100,131 +102,123 @@ export default function BookNowPage() {
|
||||
// Handle submit button click
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Open login dialog instead of submitting directly
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isAuthenticated) {
|
||||
// Open login dialog if not authenticated
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If authenticated, proceed with booking
|
||||
await submitBooking();
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
// Close login dialog
|
||||
setShowLoginDialog(false);
|
||||
// After successful login, proceed with booking submission
|
||||
await submitBooking();
|
||||
};
|
||||
|
||||
const submitBooking = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (formData.preferredDays.length === 0) {
|
||||
setError("Please select at least one available day.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.preferredTimes.length === 0) {
|
||||
setError("Please select at least one preferred time.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, we'll use the first selected day and first selected time
|
||||
// This can be adjusted based on your backend requirements
|
||||
const firstDay = formData.preferredDays[0];
|
||||
const firstTime = formData.preferredTimes[0];
|
||||
const timeMap: { [key: string]: string } = {
|
||||
morning: "09:00",
|
||||
lunchtime: "12:00",
|
||||
afternoon: "14:00",
|
||||
};
|
||||
const time24 = timeMap[firstTime] || "09:00";
|
||||
|
||||
// Get next occurrence of the first selected day
|
||||
// Convert day names to dates (YYYY-MM-DD format)
|
||||
// Get next occurrence of each selected day
|
||||
const today = new Date();
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const targetDayIndex = days.indexOf(firstDay);
|
||||
const preferredDates: string[] = [];
|
||||
|
||||
formData.preferredDays.forEach((dayName) => {
|
||||
const targetDayIndex = days.indexOf(dayName);
|
||||
if (targetDayIndex === -1) return;
|
||||
|
||||
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7;
|
||||
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
||||
|
||||
const targetDate = new Date(today);
|
||||
targetDate.setDate(today.getDate() + daysUntilTarget);
|
||||
const dateString = targetDate.toISOString().split("T")[0];
|
||||
preferredDates.push(dateString);
|
||||
});
|
||||
|
||||
// Combine date and time into scheduled_at (ISO format)
|
||||
const dateTimeString = `${dateString}T${time24}:00Z`;
|
||||
// Map time slots - API expects "morning", "afternoon", "evening"
|
||||
// Form has "morning", "lunchtime", "afternoon"
|
||||
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
|
||||
morning: "morning",
|
||||
lunchtime: "afternoon", // Map lunchtime to afternoon
|
||||
afternoon: "afternoon",
|
||||
};
|
||||
|
||||
// Prepare request payload
|
||||
const preferredTimeSlots = formData.preferredTimes
|
||||
.map((time) => timeSlotMap[time] || "morning")
|
||||
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
|
||||
|
||||
// Prepare request payload according to API spec
|
||||
const payload = {
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
scheduled_at: dateTimeString,
|
||||
duration: 60, // Default to 60 minutes
|
||||
preferred_days: formData.preferredDays,
|
||||
preferred_times: formData.preferredTimes,
|
||||
notes: formData.message || "",
|
||||
preferred_dates: preferredDates,
|
||||
preferred_time_slots: preferredTimeSlots,
|
||||
...(formData.phone && { phone: formData.phone }),
|
||||
...(formData.message && { reason: formData.message }),
|
||||
};
|
||||
|
||||
// Simulate API call - Replace with actual API endpoint
|
||||
const response = await fetch("/api/bookings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// Fallback to mock data if API is not available
|
||||
return null;
|
||||
});
|
||||
// Call the actual API using the hook
|
||||
const appointmentData = await create(payload);
|
||||
|
||||
let bookingData: Booking;
|
||||
|
||||
if (response && response.ok) {
|
||||
const data: BookingsResponse = await response.json();
|
||||
bookingData = data.bookings[0];
|
||||
} else {
|
||||
// Mock response for development - matches the API structure provided
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
bookingData = {
|
||||
ID: Math.floor(Math.random() * 1000),
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString(),
|
||||
// Convert API response to Booking format for display
|
||||
const bookingData: Booking = {
|
||||
ID: parseInt(appointmentData.id) || Math.floor(Math.random() * 1000),
|
||||
CreatedAt: appointmentData.created_at || new Date().toISOString(),
|
||||
UpdatedAt: appointmentData.updated_at || new Date().toISOString(),
|
||||
DeletedAt: null,
|
||||
user_id: 1,
|
||||
user_id: 0, // API doesn't return user_id in this response
|
||||
user: {
|
||||
ID: 1,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString(),
|
||||
DeletedAt: null,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
ID: 0,
|
||||
first_name: appointmentData.first_name,
|
||||
last_name: appointmentData.last_name,
|
||||
email: appointmentData.email,
|
||||
phone: appointmentData.phone || "",
|
||||
location: "",
|
||||
date_of_birth: "0001-01-01T00:00:00Z",
|
||||
is_admin: false,
|
||||
bookings: null,
|
||||
},
|
||||
scheduled_at: dateTimeString,
|
||||
duration: 60,
|
||||
status: "scheduled",
|
||||
jitsi_room_id: `booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
jitsi_room_url: `https://meet.jit.si/booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
scheduled_at: appointmentData.scheduled_datetime || "",
|
||||
duration: appointmentData.scheduled_duration || 60,
|
||||
status: appointmentData.status || "pending_review",
|
||||
jitsi_room_id: appointmentData.jitsi_room_id || "",
|
||||
jitsi_room_url: appointmentData.jitsi_meet_url || "",
|
||||
payment_id: "",
|
||||
payment_status: "pending",
|
||||
amount: 52,
|
||||
notes: formData.message || "Initial consultation session",
|
||||
amount: 0,
|
||||
notes: appointmentData.reason || "",
|
||||
};
|
||||
}
|
||||
|
||||
setBooking(bookingData);
|
||||
setLoading(false);
|
||||
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
|
||||
|
||||
// Redirect to home after 2 seconds
|
||||
// Redirect to user dashboard after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 2000);
|
||||
router.push("/user/dashboard");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setError("Failed to submit booking. Please try again.");
|
||||
setLoading(false);
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
console.error("Booking error:", err);
|
||||
}
|
||||
};
|
||||
@ -638,10 +632,10 @@ export default function BookNowPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
disabled={isCreating}
|
||||
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
|
||||
207
hooks/useAppointments.ts
Normal file
207
hooks/useAppointments.ts
Normal file
@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
createAppointment,
|
||||
getAvailableDates,
|
||||
listAppointments,
|
||||
getUserAppointments,
|
||||
getAppointmentDetail,
|
||||
scheduleAppointment,
|
||||
rejectAppointment,
|
||||
getAdminAvailability,
|
||||
updateAdminAvailability,
|
||||
getAppointmentStats,
|
||||
getJitsiMeetingInfo,
|
||||
} from "@/lib/actions/appointments";
|
||||
import type {
|
||||
CreateAppointmentInput,
|
||||
ScheduleAppointmentInput,
|
||||
RejectAppointmentInput,
|
||||
UpdateAvailabilityInput,
|
||||
} from "@/lib/schema/appointments";
|
||||
import type {
|
||||
Appointment,
|
||||
AdminAvailability,
|
||||
AppointmentStats,
|
||||
JitsiMeetingInfo,
|
||||
} from "@/lib/models/appointments";
|
||||
|
||||
export function useAppointments() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get available dates query
|
||||
const availableDatesQuery = useQuery<string[]>({
|
||||
queryKey: ["appointments", "available-dates"],
|
||||
queryFn: () => getAvailableDates(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// List appointments query
|
||||
const appointmentsQuery = useQuery<Appointment[]>({
|
||||
queryKey: ["appointments", "list"],
|
||||
queryFn: () => listAppointments(),
|
||||
enabled: false, // Only fetch when explicitly called
|
||||
});
|
||||
|
||||
// Get user appointments query
|
||||
const userAppointmentsQuery = useQuery<Appointment[]>({
|
||||
queryKey: ["appointments", "user"],
|
||||
queryFn: () => getUserAppointments(),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
// Get appointment detail query
|
||||
const useAppointmentDetail = (id: string | null) => {
|
||||
return useQuery<Appointment>({
|
||||
queryKey: ["appointments", "detail", id],
|
||||
queryFn: () => getAppointmentDetail(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
// Get admin availability query
|
||||
const adminAvailabilityQuery = useQuery<AdminAvailability>({
|
||||
queryKey: ["appointments", "admin", "availability"],
|
||||
queryFn: () => getAdminAvailability(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Get appointment stats query
|
||||
const appointmentStatsQuery = useQuery<AppointmentStats>({
|
||||
queryKey: ["appointments", "stats"],
|
||||
queryFn: () => getAppointmentStats(),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
});
|
||||
|
||||
// Get Jitsi meeting info query
|
||||
const useJitsiMeetingInfo = (id: string | null) => {
|
||||
return useQuery<JitsiMeetingInfo>({
|
||||
queryKey: ["appointments", "jitsi", id],
|
||||
queryFn: () => getJitsiMeetingInfo(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
// Create appointment mutation
|
||||
const createAppointmentMutation = useMutation({
|
||||
mutationFn: (input: CreateAppointmentInput) => createAppointment(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Schedule appointment mutation
|
||||
const scheduleAppointmentMutation = useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: ScheduleAppointmentInput }) =>
|
||||
scheduleAppointment(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Reject appointment mutation
|
||||
const rejectAppointmentMutation = useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: RejectAppointmentInput }) =>
|
||||
rejectAppointment(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Update admin availability mutation
|
||||
const updateAdminAvailabilityMutation = useMutation({
|
||||
mutationFn: (input: UpdateAvailabilityInput) => updateAdminAvailability(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments", "admin", "availability"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments", "available-dates"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Convenience functions
|
||||
const create = useCallback(
|
||||
async (input: CreateAppointmentInput) => {
|
||||
return await createAppointmentMutation.mutateAsync(input);
|
||||
},
|
||||
[createAppointmentMutation]
|
||||
);
|
||||
|
||||
const schedule = useCallback(
|
||||
async (id: string, input: ScheduleAppointmentInput) => {
|
||||
return await scheduleAppointmentMutation.mutateAsync({ id, input });
|
||||
},
|
||||
[scheduleAppointmentMutation]
|
||||
);
|
||||
|
||||
const reject = useCallback(
|
||||
async (id: string, input: RejectAppointmentInput) => {
|
||||
return await rejectAppointmentMutation.mutateAsync({ id, input });
|
||||
},
|
||||
[rejectAppointmentMutation]
|
||||
);
|
||||
|
||||
const updateAvailability = useCallback(
|
||||
async (input: UpdateAvailabilityInput) => {
|
||||
return await updateAdminAvailabilityMutation.mutateAsync(input);
|
||||
},
|
||||
[updateAdminAvailabilityMutation]
|
||||
);
|
||||
|
||||
const fetchAppointments = useCallback(
|
||||
async (email?: string) => {
|
||||
const data = await listAppointments(email);
|
||||
queryClient.setQueryData(["appointments", "list"], data);
|
||||
return data;
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
return {
|
||||
// Queries
|
||||
availableDates: availableDatesQuery.data || [],
|
||||
appointments: appointmentsQuery.data || [],
|
||||
userAppointments: userAppointmentsQuery.data || [],
|
||||
adminAvailability: adminAvailabilityQuery.data,
|
||||
appointmentStats: appointmentStatsQuery.data,
|
||||
|
||||
// Query states
|
||||
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||
isLoadingAppointments: appointmentsQuery.isLoading,
|
||||
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||
isLoadingStats: appointmentStatsQuery.isLoading,
|
||||
|
||||
// Query refetch functions
|
||||
refetchAvailableDates: availableDatesQuery.refetch,
|
||||
refetchAppointments: appointmentsQuery.refetch,
|
||||
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||
refetchStats: appointmentStatsQuery.refetch,
|
||||
|
||||
// Hooks for specific queries
|
||||
useAppointmentDetail,
|
||||
useJitsiMeetingInfo,
|
||||
|
||||
// Mutations
|
||||
create,
|
||||
schedule,
|
||||
reject,
|
||||
updateAvailability,
|
||||
fetchAppointments,
|
||||
|
||||
// Mutation states
|
||||
isCreating: createAppointmentMutation.isPending,
|
||||
isScheduling: scheduleAppointmentMutation.isPending,
|
||||
isRejecting: rejectAppointmentMutation.isPending,
|
||||
isUpdatingAvailability: updateAdminAvailabilityMutation.isPending,
|
||||
|
||||
// Direct mutation access (if needed)
|
||||
createAppointmentMutation,
|
||||
scheduleAppointmentMutation,
|
||||
rejectAppointmentMutation,
|
||||
updateAdminAvailabilityMutation,
|
||||
};
|
||||
}
|
||||
|
||||
364
lib/actions/appointments.ts
Normal file
364
lib/actions/appointments.ts
Normal file
@ -0,0 +1,364 @@
|
||||
import { API_ENDPOINTS } from "@/lib/api_urls";
|
||||
import { getStoredTokens } from "./auth";
|
||||
import type {
|
||||
CreateAppointmentInput,
|
||||
ScheduleAppointmentInput,
|
||||
RejectAppointmentInput,
|
||||
UpdateAvailabilityInput,
|
||||
} from "@/lib/schema/appointments";
|
||||
import type {
|
||||
Appointment,
|
||||
AppointmentResponse,
|
||||
AppointmentsListResponse,
|
||||
AvailableDatesResponse,
|
||||
AdminAvailability,
|
||||
AppointmentStats,
|
||||
JitsiMeetingInfo,
|
||||
ApiError,
|
||||
} from "@/lib/models/appointments";
|
||||
|
||||
// Helper function to extract error message from API response
|
||||
function extractErrorMessage(error: ApiError): string {
|
||||
if (error.detail) {
|
||||
if (Array.isArray(error.detail)) {
|
||||
return error.detail.join(", ");
|
||||
}
|
||||
return String(error.detail);
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
if (Array.isArray(error.message)) {
|
||||
return error.message.join(", ");
|
||||
}
|
||||
return String(error.message);
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
return "An error occurred while creating the appointment";
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
export async function createAppointment(
|
||||
input: CreateAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required. Please log in to book an appointment.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle different response formats
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
if ((data as any).data) {
|
||||
return (data as any).data;
|
||||
}
|
||||
|
||||
// If appointment is returned directly
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
// Get available dates
|
||||
export async function getAvailableDates(): Promise<string[]> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data: AvailableDatesResponse | string[] = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// API returns array of dates in YYYY-MM-DD format
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return (data as AvailableDatesResponse).dates || [];
|
||||
}
|
||||
|
||||
// List appointments (Admin sees all, users see their own)
|
||||
export async function listAppointments(email?: string): Promise<Appointment[]> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const url = email
|
||||
? `${API_ENDPOINTS.meetings.listAppointments}?email=${encodeURIComponent(email)}`
|
||||
: API_ENDPOINTS.meetings.listAppointments;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppointmentsListResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data.appointments || [];
|
||||
}
|
||||
|
||||
// Get user appointments
|
||||
export async function getUserAppointments(): Promise<Appointment[]> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.userAppointments, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppointmentsListResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data.appointments || [];
|
||||
}
|
||||
|
||||
// Get appointment detail
|
||||
export async function getAppointmentDetail(id: string): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
// Schedule appointment (Admin only)
|
||||
export async function scheduleAppointment(
|
||||
id: string,
|
||||
input: ScheduleAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
// Reject appointment (Admin only)
|
||||
export async function rejectAppointment(
|
||||
id: string,
|
||||
input: RejectAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
// Get admin availability
|
||||
export async function getAdminAvailability(): Promise<AdminAvailability> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: AdminAvailability = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Update admin availability
|
||||
export async function updateAdminAvailability(
|
||||
input: UpdateAvailabilityInput
|
||||
): Promise<AdminAvailability> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data: AdminAvailability = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get appointment stats (Admin only)
|
||||
export async function getAppointmentStats(): Promise<AppointmentStats> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}stats/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppointmentStats = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get Jitsi meeting info
|
||||
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/jitsi-meeting/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data: JitsiMeetingInfo = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
meetings: {
|
||||
base: `${API_BASE_URL}/meetings/`,
|
||||
availableDates: `${API_BASE_URL}/meetings/appointments/available-dates/`,
|
||||
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
||||
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
||||
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
78
lib/models/appointments.ts
Normal file
78
lib/models/appointments.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// Appointment Models
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
reason?: string;
|
||||
preferred_dates: string[]; // YYYY-MM-DD format
|
||||
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
|
||||
status: "pending_review" | "scheduled" | "rejected";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
scheduled_datetime?: string;
|
||||
scheduled_duration?: number;
|
||||
rejection_reason?: string;
|
||||
jitsi_meet_url?: string;
|
||||
jitsi_room_id?: string;
|
||||
has_jitsi_meeting?: boolean;
|
||||
can_join_meeting?: boolean;
|
||||
meeting_status?: string;
|
||||
}
|
||||
|
||||
export interface AppointmentResponse {
|
||||
appointment?: Appointment;
|
||||
message?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AppointmentsListResponse {
|
||||
appointments: Appointment[];
|
||||
count?: number;
|
||||
next?: string | null;
|
||||
previous?: string | null;
|
||||
}
|
||||
|
||||
export interface AvailableDatesResponse {
|
||||
dates: string[]; // YYYY-MM-DD format
|
||||
available_days?: number[]; // 0-6 (Monday-Sunday)
|
||||
available_days_display?: string[];
|
||||
}
|
||||
|
||||
export interface AdminAvailability {
|
||||
available_days: number[]; // 0-6 (Monday-Sunday)
|
||||
available_days_display: string[];
|
||||
}
|
||||
|
||||
export interface AppointmentStats {
|
||||
total_requests: number;
|
||||
pending_review: number;
|
||||
scheduled: number;
|
||||
rejected: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface JitsiMeetingInfo {
|
||||
meeting_url: string;
|
||||
room_id: string;
|
||||
scheduled_time: string;
|
||||
duration: string;
|
||||
can_join: boolean;
|
||||
meeting_status: string;
|
||||
join_instructions: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail?: string | string[];
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
preferred_dates?: string[];
|
||||
preferred_time_slots?: string[];
|
||||
email?: string[];
|
||||
first_name?: string[];
|
||||
last_name?: string[];
|
||||
[key: string]: string | string[] | undefined;
|
||||
}
|
||||
|
||||
43
lib/schema/appointments.ts
Normal file
43
lib/schema/appointments.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Create Appointment Schema
|
||||
export const createAppointmentSchema = z.object({
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
preferred_dates: z
|
||||
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
|
||||
.min(1, "At least one preferred date is required"),
|
||||
preferred_time_slots: z
|
||||
.array(z.enum(["morning", "afternoon", "evening"]))
|
||||
.min(1, "At least one preferred time slot is required"),
|
||||
phone: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
|
||||
|
||||
// Schedule Appointment Schema (Admin only)
|
||||
export const scheduleAppointmentSchema = z.object({
|
||||
scheduled_datetime: z.string().datetime("Invalid datetime format"),
|
||||
scheduled_duration: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type ScheduleAppointmentInput = z.infer<typeof scheduleAppointmentSchema>;
|
||||
|
||||
// Reject Appointment Schema (Admin only)
|
||||
export const rejectAppointmentSchema = z.object({
|
||||
rejection_reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
|
||||
|
||||
// Update Admin Availability Schema
|
||||
export const updateAvailabilitySchema = z.object({
|
||||
available_days: z
|
||||
.array(z.number().int().min(0).max(6))
|
||||
.min(1, "At least one day must be selected"),
|
||||
});
|
||||
|
||||
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user