2025-02-21 20:20:12 +00:00
|
|
|
// public/public.js (for index.html)
|
|
|
|
|
|
|
|
// Convert time string (e.g., '9:30am' or '2:00pm') to minutes since midnight
|
|
|
|
function timeToMinutes(timeStr) {
|
|
|
|
const match = timeStr.match(/([0-9]{1,2}):([0-9]{2})(am|pm)/i);
|
|
|
|
if (!match) {
|
|
|
|
console.error('Invalid time format:', timeStr);
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
const [_, hours, minutes, period] = match;
|
|
|
|
let totalHours = parseInt(hours);
|
|
|
|
if (period.toLowerCase() === 'pm' && totalHours !== 12) totalHours += 12;
|
|
|
|
if (period.toLowerCase() === 'am' && totalHours === 12) totalHours = 0;
|
|
|
|
return totalHours * 60 + parseInt(minutes);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get UID from URL path
|
|
|
|
function getUidFromPath() {
|
|
|
|
const path = window.location.pathname.substring(1); // Remove leading slash
|
|
|
|
return path || null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate UID format
|
|
|
|
function isValidUid(uid) {
|
|
|
|
return /^[a-z0-9-]+$/.test(uid);
|
|
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
2025-02-25 05:27:29 +00:00
|
|
|
// Add custom CSS for the current date to make it more distinguishable
|
|
|
|
const style = document.createElement('style');
|
|
|
|
style.textContent = `
|
|
|
|
.date-item.current {
|
|
|
|
border: 2px solid #ffc107 !important; /* Yellow border */
|
|
|
|
background-color: rgba(255, 193, 7, 0.1) !important; /* Light yellow background */
|
|
|
|
font-weight: bold !important;
|
|
|
|
color: #212529 !important; /* Ensure text is visible */
|
|
|
|
}
|
|
|
|
|
|
|
|
/* Ensure current date styling takes precedence but doesn't interfere with selection */
|
|
|
|
.date-item.current.selected {
|
|
|
|
border: 2px solid #0d6efd !important; /* Blue border for selected */
|
|
|
|
background-color: #0d6efd !important; /* Dark blue background when selected */
|
|
|
|
color: white !important; /* White text for better contrast */
|
|
|
|
font-weight: bold !important;
|
|
|
|
}
|
2025-02-25 05:36:59 +00:00
|
|
|
|
|
|
|
/* Prevent hover effects on empty calendar cells */
|
|
|
|
.date-item.empty-cell {
|
|
|
|
cursor: default !important;
|
|
|
|
pointer-events: none !important;
|
|
|
|
}
|
|
|
|
|
|
|
|
.date-item.empty-cell:hover {
|
|
|
|
background-color: transparent !important;
|
|
|
|
transform: none !important;
|
|
|
|
}
|
2025-02-25 05:27:29 +00:00
|
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
2025-02-21 20:20:12 +00:00
|
|
|
const uid = getUidFromPath();
|
|
|
|
const mainContent = document.getElementById('mainContent');
|
|
|
|
const uidError = document.getElementById('uidError');
|
|
|
|
|
|
|
|
// Check if we have a valid UID
|
|
|
|
if (!uid || !isValidUid(uid)) {
|
|
|
|
mainContent.style.display = 'none';
|
|
|
|
uidError.style.display = 'block';
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Hide error, show main content
|
|
|
|
uidError.style.display = 'none';
|
|
|
|
mainContent.style.display = 'block';
|
|
|
|
|
|
|
|
const calendarDates = document.getElementById('calendarDates');
|
|
|
|
const monthYear = document.getElementById('monthYear');
|
|
|
|
const prevMonthBtn = document.getElementById('prevMonth');
|
|
|
|
const nextMonthBtn = document.getElementById('nextMonth');
|
|
|
|
const currentTimeElement = document.getElementById('currentTime');
|
|
|
|
const timeSlotsContainer = document.getElementById('timeSlots');
|
|
|
|
|
|
|
|
// Check if navigation buttons exist before adding event listeners
|
|
|
|
if (!prevMonthBtn || !nextMonthBtn) {
|
|
|
|
console.error('Navigation buttons (prevMonth or nextMonth) not found in the DOM');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let currentDate = new Date();
|
|
|
|
let currentMonth = currentDate.getMonth();
|
|
|
|
let currentYear = currentDate.getFullYear();
|
|
|
|
let selectedDate = null; // Track selected date
|
|
|
|
|
|
|
|
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
|
|
|
|
|
|
// Fetch available dates from the API
|
|
|
|
async function fetchAvailability() {
|
|
|
|
try {
|
|
|
|
const response = await fetch(`/api/availability/${uid}`);
|
|
|
|
if (response.status === 404) {
|
|
|
|
// UID doesn't exist, show error page
|
|
|
|
mainContent.style.display = 'none';
|
|
|
|
uidError.style.display = 'block';
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
if (!response.ok) {
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
}
|
|
|
|
const availability = await response.json();
|
|
|
|
console.log('Fetched availability for UID:', uid, availability);
|
|
|
|
return availability;
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Error fetching availability:', error);
|
|
|
|
return {};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Render the calendar with available dates highlighted and automatically select/display the current date
|
|
|
|
async function renderCalendar(month, year) {
|
|
|
|
const availability = await fetchAvailability();
|
|
|
|
if (availability === null) return; // UID doesn't exist
|
|
|
|
|
|
|
|
calendarDates.innerHTML = '';
|
|
|
|
monthYear.textContent = `${months[month]} ${year}`;
|
|
|
|
|
|
|
|
const firstDay = new Date(year, month, 1).getDay();
|
|
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
|
|
const today = new Date();
|
2025-02-25 05:27:29 +00:00
|
|
|
const currentMinutes = today.getHours() * 60 + today.getMinutes();
|
2025-02-21 20:20:12 +00:00
|
|
|
|
|
|
|
// Create blank cells for days before the first day of the month
|
|
|
|
for (let i = 0; i < firstDay; i++) {
|
|
|
|
const blank = document.createElement('div');
|
2025-02-25 05:36:59 +00:00
|
|
|
blank.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center', 'text-muted', 'empty-cell');
|
|
|
|
blank.style.cursor = 'default'; // Remove pointer cursor
|
|
|
|
blank.style.pointerEvents = 'none'; // Disable hover and click events
|
2025-02-21 20:20:12 +00:00
|
|
|
calendarDates.appendChild(blank);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Populate the calendar with days
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
|
|
const day = document.createElement('div');
|
|
|
|
day.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center');
|
|
|
|
day.textContent = i;
|
|
|
|
const date = new Date(year, month, i);
|
|
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
|
|
|
|
|
|
// Start of today (midnight)
|
|
|
|
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
|
|
|
|
|
|
// Check if date is in the past
|
|
|
|
const isPastDate = date < startOfToday;
|
2025-02-25 05:27:29 +00:00
|
|
|
const isToday = date.toDateString() === today.toDateString();
|
2025-02-21 20:20:12 +00:00
|
|
|
|
2025-02-25 05:27:29 +00:00
|
|
|
if (isToday) {
|
2025-02-21 20:20:12 +00:00
|
|
|
day.classList.add('current');
|
|
|
|
}
|
2025-02-25 05:27:29 +00:00
|
|
|
|
|
|
|
// Check if the date has available time slots
|
|
|
|
let hasAvailableTimes = false;
|
2025-02-21 20:20:12 +00:00
|
|
|
if (availability[dateStr]) {
|
2025-02-25 05:27:29 +00:00
|
|
|
let times = [];
|
|
|
|
if (typeof availability[dateStr] === 'string') {
|
|
|
|
times = availability[dateStr].split(',').map(t => t.trim());
|
|
|
|
} else if (Array.isArray(availability[dateStr])) {
|
|
|
|
times = availability[dateStr].map(t => t.trim());
|
|
|
|
}
|
|
|
|
|
|
|
|
// For today, filter out past times
|
|
|
|
if (isToday) {
|
|
|
|
times = times.filter(time => timeToMinutes(time) > currentMinutes);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only mark as available if there are valid times
|
|
|
|
hasAvailableTimes = times.length > 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only add 'available' class if the date is not in the past AND has available times
|
|
|
|
if (hasAvailableTimes && !isPastDate) {
|
2025-02-21 20:20:12 +00:00
|
|
|
day.classList.add('available');
|
|
|
|
}
|
2025-02-25 05:27:29 +00:00
|
|
|
|
2025-02-21 20:20:12 +00:00
|
|
|
if (isPastDate) {
|
|
|
|
day.classList.add('disabled');
|
|
|
|
} else {
|
|
|
|
day.addEventListener('click', () => selectDate(date, firstDay));
|
|
|
|
}
|
|
|
|
calendarDates.appendChild(day);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Auto-select today if we're in the current month/year
|
|
|
|
if (today.getMonth() === month && today.getFullYear() === year) {
|
|
|
|
await selectDate(today, firstDay);
|
|
|
|
// Also update time slots directly since we're on today's date
|
|
|
|
await updateTimeSlots(today);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle date selection
|
|
|
|
async function selectDate(date, firstDay) {
|
|
|
|
// Check if the date is in the past
|
|
|
|
const today = new Date();
|
|
|
|
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
|
|
if (date < startOfToday) {
|
|
|
|
console.warn('Attempted to select a past date');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
selectedDate = date;
|
|
|
|
const dateItems = document.querySelectorAll('#calendarDates .date-item');
|
|
|
|
dateItems.forEach(item => item.classList.remove('selected')); // Ensure only one date is selected
|
|
|
|
// Find the exact date item for the clicked or auto-selected date
|
|
|
|
const selectedDay = Array.from(dateItems).find(item => {
|
|
|
|
const dayText = item.textContent;
|
|
|
|
const itemDate = new Date(currentYear, currentMonth, parseInt(dayText));
|
|
|
|
return itemDate.toDateString() === date.toDateString();
|
|
|
|
});
|
|
|
|
if (selectedDay) selectedDay.classList.add('selected');
|
|
|
|
await updateTimeSlots(date); // Update time slots based on selected date, ensuring async completion
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update time slots based on selected date
|
|
|
|
async function updateTimeSlots(date) {
|
|
|
|
const availability = await fetchAvailability();
|
|
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
|
|
console.log('Looking for date:', dateStr);
|
|
|
|
console.log('Available dates:', Object.keys(availability));
|
|
|
|
let times = [];
|
|
|
|
if (availability[dateStr]) {
|
|
|
|
console.log('Found times for date:', availability[dateStr]);
|
|
|
|
// Ensure availability[dateStr] is a string or array before processing
|
|
|
|
if (typeof availability[dateStr] === 'string') {
|
|
|
|
times = availability[dateStr].split(',').map(t => t.trim());
|
|
|
|
} else if (Array.isArray(availability[dateStr])) {
|
|
|
|
times = availability[dateStr].map(t => t.trim());
|
|
|
|
} else {
|
|
|
|
console.warn(`Unexpected data format for date ${dateStr}:`, availability[dateStr]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter out past times if it's today
|
|
|
|
const today = new Date();
|
|
|
|
const isToday = date.toDateString() === today.toDateString();
|
|
|
|
console.log('Time filtering - Today:', today.toDateString());
|
|
|
|
console.log('Time filtering - Selected:', date.toDateString());
|
|
|
|
console.log('Is today?', isToday);
|
|
|
|
if (isToday) {
|
|
|
|
const currentMinutes = today.getHours() * 60 + today.getMinutes();
|
|
|
|
console.log('Current minutes:', currentMinutes);
|
|
|
|
console.log('Times before filtering:', times);
|
|
|
|
times = times.filter(time => {
|
|
|
|
const timeMin = timeToMinutes(time);
|
|
|
|
console.log(`Time ${time} = ${timeMin} minutes`);
|
|
|
|
return timeMin > currentMinutes;
|
|
|
|
});
|
|
|
|
console.log('Times after filtering:', times);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort times chronologically
|
|
|
|
times.sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clear existing time slots or message
|
|
|
|
timeSlotsContainer.innerHTML = '';
|
|
|
|
|
|
|
|
if (times.length === 0) {
|
|
|
|
// Display "No availability on selected date." if no times are available
|
|
|
|
const noAvailabilityMsg = document.createElement('p');
|
|
|
|
noAvailabilityMsg.classList.add('text-muted', 'text-center', 'fw-bold', 'fs-5', 'py-3');
|
|
|
|
noAvailabilityMsg.textContent = 'No availability on selected date.';
|
|
|
|
timeSlotsContainer.appendChild(noAvailabilityMsg);
|
|
|
|
} else {
|
|
|
|
// Populate time slots dynamically if times are available
|
|
|
|
times.forEach(time => {
|
|
|
|
const button = document.createElement('button');
|
|
|
|
button.classList.add('time-slot', 'btn', 'rounded-pill', 'w-100', 'mb-2');
|
|
|
|
button.classList.add('btn-outline-primary', 'available');
|
|
|
|
button.textContent = time;
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
const allTimeSlots = timeSlotsContainer.querySelectorAll('.time-slot');
|
|
|
|
allTimeSlots.forEach(s => s.classList.remove('selected'));
|
|
|
|
button.classList.add('selected');
|
|
|
|
|
|
|
|
// Format date as MM/DD/YY
|
|
|
|
const [year, month, day] = dateStr.split('-');
|
|
|
|
const formattedDate = `${month}/${day}/${year.slice(2)}`;
|
|
|
|
|
|
|
|
// Show booking modal
|
|
|
|
document.getElementById('selectedTime').value = time;
|
|
|
|
document.getElementById('selectedDate').value = dateStr;
|
|
|
|
document.getElementById('bookingModalLabel').textContent = `Confirm Appointment for ${formattedDate} at ${time} EST`;
|
|
|
|
|
|
|
|
// Clear previous form data
|
|
|
|
document.getElementById('bookingForm').reset();
|
|
|
|
|
|
|
|
// Show modal
|
|
|
|
const bookingModal = new bootstrap.Modal(document.getElementById('bookingModal'));
|
|
|
|
bookingModal.show();
|
|
|
|
});
|
|
|
|
timeSlotsContainer.appendChild(button);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Navigate to previous month
|
|
|
|
prevMonthBtn.addEventListener('click', () => {
|
|
|
|
currentMonth--;
|
|
|
|
if (currentMonth < 0) {
|
|
|
|
currentMonth = 11;
|
|
|
|
currentYear--;
|
|
|
|
}
|
|
|
|
renderCalendar(currentMonth, currentYear);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Navigate to next month
|
|
|
|
nextMonthBtn.addEventListener('click', () => {
|
|
|
|
currentMonth++;
|
|
|
|
if (currentMonth > 11) {
|
|
|
|
currentMonth = 0;
|
|
|
|
currentYear++;
|
|
|
|
}
|
|
|
|
renderCalendar(currentMonth, currentYear);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Update current time in Eastern Time (US & Canada)
|
|
|
|
function updateCurrentTime() {
|
|
|
|
const options = {
|
|
|
|
timeZone: 'America/New_York',
|
|
|
|
hour: '2-digit',
|
|
|
|
minute: '2-digit',
|
|
|
|
hour12: true
|
|
|
|
};
|
|
|
|
const time = new Date().toLocaleTimeString('en-US', options);
|
|
|
|
currentTimeElement.textContent = time;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update time every second
|
|
|
|
setInterval(updateCurrentTime, 1000);
|
|
|
|
updateCurrentTime(); // Initial call
|
|
|
|
|
|
|
|
// Form validation and submission
|
|
|
|
const form = document.getElementById('bookingForm');
|
|
|
|
const submitBtn = document.getElementById('submitBooking');
|
|
|
|
|
|
|
|
// Email validation function
|
|
|
|
function isValidEmail(email) {
|
|
|
|
const re = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
|
|
return re.test(email);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Phone validation function (optional field)
|
|
|
|
function isValidPhone(phone) {
|
|
|
|
if (!phone) return true; // Optional field
|
|
|
|
const re = /^[0-9]{3}-[0-9]{3}-[0-9]{4}$/;
|
|
|
|
return re.test(phone);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Format phone number as user types
|
|
|
|
document.getElementById('phone').addEventListener('input', (e) => {
|
|
|
|
let value = e.target.value.replace(/\D/g, ''); // Remove non-digits
|
|
|
|
if (value.length >= 3) {
|
|
|
|
value = value.slice(0, 3) + '-' + value.slice(3);
|
|
|
|
if (value.length >= 7) {
|
|
|
|
value = value.slice(0, 7) + '-' + value.slice(7);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
e.target.value = value.slice(0, 12); // Limit to XXX-XXX-XXXX
|
|
|
|
});
|
|
|
|
|
|
|
|
// Validate fields on blur
|
|
|
|
document.getElementById('name').addEventListener('blur', (e) => {
|
|
|
|
if (!e.target.value.trim()) {
|
|
|
|
e.target.classList.add('is-invalid');
|
|
|
|
} else {
|
|
|
|
e.target.classList.remove('is-invalid');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById('email').addEventListener('blur', (e) => {
|
|
|
|
const email = e.target.value.trim();
|
|
|
|
if (!email || !isValidEmail(email)) {
|
|
|
|
e.target.classList.add('is-invalid');
|
|
|
|
} else {
|
|
|
|
e.target.classList.remove('is-invalid');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById('phone').addEventListener('blur', (e) => {
|
|
|
|
const phone = e.target.value.trim();
|
|
|
|
if (phone && !isValidPhone(phone)) {
|
|
|
|
e.target.classList.add('is-invalid');
|
|
|
|
} else {
|
|
|
|
e.target.classList.remove('is-invalid');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
submitBtn.addEventListener('click', async (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
const name = document.getElementById('name').value.trim();
|
|
|
|
const email = document.getElementById('email').value.trim();
|
|
|
|
const phone = document.getElementById('phone').value.trim();
|
|
|
|
const notes = document.getElementById('notes').value.trim();
|
|
|
|
const selectedTime = document.getElementById('selectedTime').value;
|
|
|
|
const selectedDate = document.getElementById('selectedDate').value;
|
|
|
|
|
|
|
|
// Clear previous validation state
|
|
|
|
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
|
|
|
|
|
|
|
|
// Validate required fields
|
|
|
|
let hasErrors = false;
|
|
|
|
|
|
|
|
if (!name) {
|
|
|
|
document.getElementById('name').classList.add('is-invalid');
|
|
|
|
hasErrors = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!email || !isValidEmail(email)) {
|
|
|
|
document.getElementById('email').classList.add('is-invalid');
|
|
|
|
hasErrors = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate phone if provided
|
|
|
|
if (phone && !isValidPhone(phone)) {
|
|
|
|
document.getElementById('phone').classList.add('is-invalid');
|
|
|
|
hasErrors = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (hasErrors) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prepare booking data
|
|
|
|
const bookingData = {
|
|
|
|
name,
|
|
|
|
email,
|
|
|
|
phone: phone || null,
|
|
|
|
notes: notes || null,
|
|
|
|
date: selectedDate,
|
|
|
|
time: selectedTime
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
const response = await fetch('/api/book', {
|
|
|
|
method: 'POST',
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
},
|
|
|
|
body: JSON.stringify(bookingData)
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
throw new Error('Booking failed');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Close modal and show success message
|
|
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('bookingModal'));
|
|
|
|
modal.hide();
|
|
|
|
alert('Appointment scheduled successfully!');
|
|
|
|
|
|
|
|
// Refresh the calendar
|
|
|
|
await renderCalendar(currentMonth, currentYear);
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Error booking appointment:', error);
|
|
|
|
alert('Failed to schedule appointment. Please try again.');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Initial render and setup
|
|
|
|
await renderCalendar(currentMonth, currentYear);
|
|
|
|
|
|
|
|
// Force an immediate update of time slots for today's date
|
|
|
|
const today = new Date();
|
|
|
|
selectedDate = today; // Ensure selectedDate is set
|
|
|
|
await updateTimeSlots(today);
|
|
|
|
|
|
|
|
// Double-check that time slots are updated
|
|
|
|
if (!timeSlotsContainer.hasChildNodes()) {
|
|
|
|
const noAvailabilityMsg = document.createElement('p');
|
|
|
|
noAvailabilityMsg.classList.add('text-muted', 'text-center', 'fw-bold', 'fs-5', 'py-3');
|
|
|
|
noAvailabilityMsg.textContent = 'No availability on selected date.';
|
|
|
|
timeSlotsContainer.appendChild(noAvailabilityMsg);
|
|
|
|
}
|
|
|
|
});
|