(null);
+
+ // Close calendar when clicking outside
+ React.useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
return (
@@ -34,65 +42,38 @@ export function DatePicker({ date, setDate, label }: DatePickerProps) {
{label}
)}
-
-
-
-
-
- {
- setDate(selectedDate);
- setOpen(false);
- }}
- initialFocus
- classNames={{
- months: "space-y-4",
- month: "space-y-4",
- caption: "flex justify-center pt-3 pb-5 relative items-center border-b border-gray-200 dark:border-gray-700 mb-4",
- caption_label: "text-lg font-bold text-gray-800 dark:text-gray-100",
- nav: "flex items-center justify-between absolute inset-0",
- nav_button: cn(
- "h-9 w-9 rounded-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-rose-50 dark:hover:bg-gray-600 hover:border-rose-300 dark:hover:border-rose-500 p-0 transition-all shadow-sm"
- ),
- nav_button_previous: "absolute left-0",
- nav_button_next: "absolute right-0",
- table: "w-full border-collapse space-y-3",
- head_row: "flex mb-3",
- head_cell: "text-gray-600 dark:text-gray-400 rounded-md w-11 font-semibold text-xs",
- row: "flex w-full mt-2",
- cell: cn(
- "relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
- "[&>button]:h-11 [&>button]:w-11 [&>button]:p-0 [&>button]:font-semibold [&>button]:cursor-pointer [&>button]:rounded-full [&>button]:transition-all"
- ),
- day: cn(
- "h-11 w-11 p-0 font-semibold aria-selected:opacity-100 hover:bg-rose-500 hover:text-white rounded-full transition-all cursor-pointer",
- "hover:scale-110 active:scale-95 hover:shadow-md"
- ),
- day_selected:
- "bg-rose-600 text-white hover:bg-rose-700 hover:text-white focus:bg-rose-600 focus:text-white font-bold shadow-xl scale-110 ring-4 ring-rose-200 dark:ring-rose-800",
- day_today: "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-bold border-2 border-blue-300 dark:border-blue-600",
- day_outside: "text-gray-300 dark:text-gray-600 opacity-50",
- day_disabled: "text-gray-200 dark:text-gray-700 opacity-30 cursor-not-allowed",
- day_range_middle:
- "aria-selected:bg-rose-100 dark:aria-selected:bg-rose-900/30 aria-selected:text-rose-700 dark:aria-selected:text-rose-300",
- day_hidden: "invisible",
- }}
- />
-
-
+
+
+ {isOpen && (
+
+ {
+ setDate(selectedDate || undefined);
+ if (selectedDate) {
+ setIsOpen(false);
+ }
+ }}
+ minDate={new Date()}
+ inline
+ calendarClassName="!border-0"
+ wrapperClassName="w-full"
+ />
+
+ )}
+
);
}
-
diff --git a/components/ForgotPasswordDialog.tsx b/components/ForgotPasswordDialog.tsx
index fee45f4..75047a8 100644
--- a/components/ForgotPasswordDialog.tsx
+++ b/components/ForgotPasswordDialog.tsx
@@ -461,3 +461,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa
+
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
index 5cd4fbe..5e814ab 100644
--- a/components/ui/popover.tsx
+++ b/components/ui/popover.tsx
@@ -30,7 +30,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[100] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
className
)}
{...props}
diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts
index a8ad586..b094928 100644
--- a/lib/actions/appointments.ts
+++ b/lib/actions/appointments.ts
@@ -51,16 +51,124 @@ export async function createAppointment(
throw new Error("Authentication required. Please log in to book an appointment.");
}
+ // Validate required fields
+ if (!input.first_name || !input.last_name || !input.email) {
+ throw new Error("First name, last name, and email are required");
+ }
+ if (!input.preferred_dates || input.preferred_dates.length === 0) {
+ throw new Error("At least one preferred date is required");
+ }
+ if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) {
+ throw new Error("At least one preferred time slot is required");
+ }
+
+ // Validate date format (YYYY-MM-DD)
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ for (const date of input.preferred_dates) {
+ if (!dateRegex.test(date)) {
+ throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`);
+ }
+ }
+
+ // Validate time slots
+ const validTimeSlots = ["morning", "afternoon", "evening"];
+ for (const slot of input.preferred_time_slots) {
+ if (!validTimeSlots.includes(slot)) {
+ throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`);
+ }
+ }
+
+ // Prepare the payload exactly as the API expects
+ // Only include fields that the API accepts - no jitsi_room_id or other fields
+ const payload: {
+ first_name: string;
+ last_name: string;
+ email: string;
+ preferred_dates: string[];
+ preferred_time_slots: string[];
+ phone?: string;
+ reason?: string;
+ } = {
+ first_name: input.first_name.trim(),
+ last_name: input.last_name.trim(),
+ email: input.email.trim().toLowerCase(),
+ preferred_dates: input.preferred_dates,
+ preferred_time_slots: input.preferred_time_slots,
+ };
+
+ // Only add optional fields if they have values
+ if (input.phone && input.phone.trim()) {
+ payload.phone = input.phone.trim();
+ }
+ if (input.reason && input.reason.trim()) {
+ payload.reason = input.reason.trim();
+ }
+
+ // Log the payload for debugging
+ console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2));
+ console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment);
+
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
- body: JSON.stringify(input),
+ body: JSON.stringify(payload),
});
- const data: AppointmentResponse = await response.json();
+ // Read response text first (can only be read once)
+ const responseText = await response.text();
+
+ // Check content type before parsing
+ const contentType = response.headers.get("content-type");
+ let data: any;
+
+ if (contentType && contentType.includes("application/json")) {
+ try {
+ if (!responseText) {
+ throw new Error(`Server returned empty response (${response.status})`);
+ }
+ data = JSON.parse(responseText);
+ } catch (e) {
+ // If JSON parsing fails, log the actual response
+ console.error("Failed to parse JSON response:", {
+ status: response.status,
+ statusText: response.statusText,
+ contentType,
+ url: API_ENDPOINTS.meetings.createAppointment,
+ preview: responseText.substring(0, 500)
+ });
+ throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
+ }
+ } else {
+ // Response is not JSON (likely HTML error page)
+ // Try to extract error message from HTML if possible
+ let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
+
+ // Try to find error details in HTML
+ const errorMatch = responseText.match(/]*>(.*?)<\/pre>/is) ||
+ responseText.match(/]*>(.*?)<\/h1>/is) ||
+ responseText.match(/]*>(.*?)<\/title>/is);
+
+ if (errorMatch && errorMatch[1]) {
+ const htmlError = errorMatch[1].replace(/<[^>]*>/g, '').trim();
+ if (htmlError) {
+ errorMessage += `. ${htmlError}`;
+ }
+ }
+
+ console.error("Non-JSON response received:", {
+ status: response.status,
+ statusText: response.statusText,
+ contentType,
+ url: API_ENDPOINTS.meetings.createAppointment,
+ payload: input,
+ preview: responseText.substring(0, 1000)
+ });
+
+ throw new Error(errorMessage);
+ }
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
@@ -235,10 +343,67 @@ export async function scheduleAppointment(
body: JSON.stringify(input),
});
- const data: AppointmentResponse = await response.json();
+ let data: any;
+ const contentType = response.headers.get("content-type");
+
+ if (contentType && contentType.includes("application/json")) {
+ try {
+ const text = await response.text();
+ data = text ? JSON.parse(text) : {};
+ } catch (e) {
+ data = {};
+ }
+ } else {
+ const text = await response.text();
+ data = text || {};
+ }
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
+ // Try to extract detailed error information
+ let errorMessage = `Failed to schedule appointment (${response.status})`;
+
+ if (data && Object.keys(data).length > 0) {
+ // Check for common error formats
+ if (data.detail) {
+ errorMessage = Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
+ } else if (data.message) {
+ errorMessage = Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
+ } else if (data.error) {
+ errorMessage = Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
+ } else if (typeof data === "string") {
+ errorMessage = data;
+ } else {
+ // Check for field-specific errors
+ const fieldErrors: string[] = [];
+ Object.keys(data).forEach((key) => {
+ if (key !== "detail" && key !== "message" && key !== "error") {
+ const fieldError = data[key];
+ if (Array.isArray(fieldError)) {
+ fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
+ } else if (typeof fieldError === "string") {
+ fieldErrors.push(`${key}: ${fieldError}`);
+ }
+ }
+ });
+ if (fieldErrors.length > 0) {
+ errorMessage = fieldErrors.join(". ");
+ } else {
+ // If we have data but can't parse it, show the status
+ errorMessage = `Server error: ${response.status} ${response.statusText}`;
+ }
+ }
+ } else {
+ // No data in response
+ errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
+ }
+
+ console.error("Schedule appointment error:", {
+ status: response.status,
+ statusText: response.statusText,
+ data,
+ errorMessage,
+ });
+
throw new Error(errorMessage);
}
diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts
index 229e722..ddd67c3 100644
--- a/lib/actions/auth.ts
+++ b/lib/actions/auth.ts
@@ -413,7 +413,7 @@ export async function updateProfile(input: UpdateProfileInput): Promise {
}
const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
- method: "PATCH",
+ method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
diff --git a/package.json b/package.json
index 3a51670..86d2034 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10",
+ "@types/react-datepicker": "^7.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -27,6 +28,7 @@
"next": "16.0.1",
"next-themes": "^0.4.6",
"react": "19.2.0",
+ "react-datepicker": "^8.9.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9562850..f09397d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.10
version: 5.90.10(react@19.2.0)
+ '@types/react-datepicker':
+ specifier: ^7.0.0
+ version: 7.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -53,6 +56,9 @@ importers:
react:
specifier: 19.2.0
version: 19.2.0
+ react-datepicker:
+ specifier: ^8.9.0
+ version: 8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-day-picker:
specifier: ^9.11.1
version: 9.11.1(react@19.2.0)
@@ -241,6 +247,12 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/react@0.27.16':
+ resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==}
+ peerDependencies:
+ react: '>=17.0.0'
+ react-dom: '>=17.0.0'
+
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
@@ -946,6 +958,10 @@ packages:
'@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
+ '@types/react-datepicker@7.0.0':
+ resolution: {integrity: sha512-4tWwOUq589tozyQPBVEqGNng5DaZkomx5IVNuur868yYdgjH6RaL373/HKiVt1IDoNNXYiTGspm1F7kjrarM8Q==}
+ deprecated: This is a stub types definition. react-datepicker provides its own type definitions, so you do not need this installed.
+
'@types/react-dom@19.2.2':
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
peerDependencies:
@@ -2334,6 +2350,12 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ react-datepicker@8.9.0:
+ resolution: {integrity: sha512-yoRsGxjqVRjk8iUBssrW9jcinTeyP9mAfTpuzdKvlESOUjdrY0sfDTzIZWJAn38jvNcxW1dnDmW1CinjiFdxYQ==}
+ peerDependencies:
+ react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
react-day-picker@9.11.1:
resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==}
engines: {node: '>=18'}
@@ -2576,6 +2598,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tabbable@6.3.0:
+ resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
+
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -2937,6 +2962,14 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
+ '@floating-ui/react@0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@floating-ui/utils': 0.2.10
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ tabbable: 6.3.0
+
'@floating-ui/utils@0.2.10': {}
'@humanfs/core@0.19.1': {}
@@ -3562,6 +3595,13 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/react-datepicker@7.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ react-datepicker: 8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ transitivePeerDependencies:
+ - react
+ - react-dom
+
'@types/react-dom@19.2.2(@types/react@19.2.2)':
dependencies:
'@types/react': 19.2.2
@@ -5310,6 +5350,14 @@ snapshots:
queue-microtask@1.2.3: {}
+ react-datepicker@8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ '@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ clsx: 2.1.1
+ date-fns: 4.1.0
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
react-day-picker@9.11.1(react@19.2.0):
dependencies:
'@date-fns/tz': 1.4.1
@@ -5654,6 +5702,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ tabbable@6.3.0: {}
+
tailwind-merge@3.3.1: {}
tailwindcss@4.1.16: {}