Compare commits
No commits in common. "f015a9079122491da945e973814c868de60ffeb3" and "f3410581f8ea8017b49279cd564b8bac33f8f808" have entirely different histories.
f015a90791
...
f3410581f8
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,32 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.next/
|
|
||||||
|
|
||||||
# IDE and editor files
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS generated files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
95
README.md
95
README.md
@ -1,95 +1,2 @@
|
|||||||
# Quick Start
|
# freesched
|
||||||
|
|
||||||
`npm install`
|
|
||||||
|
|
||||||
`node server.js`
|
|
||||||
|
|
||||||
Browse to http://localhost:3000/admin for admin interface.
|
|
||||||
|
|
||||||
Create a UID and follow link in admin interface for frontend.
|
|
||||||
|
|
||||||
# Introducing FreeSched: A Simpler Alternative to Cal.com
|
|
||||||
You’ve asked for a much simpler, easy-to-use version of Cal.com, named "FreeSched," that retains the clean and intuitive UI/UX of Cal.com and mirrors the simplicity of Calendly.com. Below, I’ll outline what FreeSched is, its core features, how it works, and why it’s a streamlined alternative to Cal.com—all while keeping the design aesthetic you love.
|
|
||||||
|
|
||||||
What is FreeSched?
|
|
||||||
FreeSched is a lightweight, open-source scheduling tool designed to make booking meetings as simple as possible. Inspired by Cal.com (an open-source scheduling infrastructure) and Calendly.com (a popular scheduling platform), FreeSched strips away complexity to focus on the essentials. It’s perfect for individuals—like freelancers, consultants, or anyone needing quick one-on-one scheduling—while maintaining a modern, minimalistic interface similar to Cal.com and Calendly.
|
|
||||||
|
|
||||||
Key Features of FreeSched
|
|
||||||
FreeSched focuses on simplicity without sacrificing usability. Here’s what it offers:
|
|
||||||
|
|
||||||
Single Event Type
|
|
||||||
Users set one type of event with a fixed duration (e.g., 30 minutes).
|
|
||||||
No need to juggle multiple event types—just one straightforward option.
|
|
||||||
Basic Availability Settings
|
|
||||||
Set your available days and hours using a simple weekly grid (e.g., Monday to Friday, 9 AM to 5 PM).
|
|
||||||
Availability is divided into 30-minute slots for easy selection.
|
|
||||||
Google Calendar Integration
|
|
||||||
Connects to your Google Calendar to check existing events and prevent double-booking.
|
|
||||||
Only shows time slots when you’re truly free.
|
|
||||||
Simple Booking Page
|
|
||||||
A clean, public page (e.g., freesched.example.com/yourname) where others can pick a date and time.
|
|
||||||
Invitees provide their name and email to confirm the booking.
|
|
||||||
Email Notifications
|
|
||||||
Sends confirmation emails to you and the invitee with all the details.
|
|
||||||
Keeps everyone in the loop without extra effort.
|
|
||||||
Time Zone Support
|
|
||||||
Automatically adjusts available times to the invitee’s time zone.
|
|
||||||
No more confusion for international scheduling.
|
|
||||||
How FreeSched Works
|
|
||||||
FreeSched is designed to be intuitive for both the user (you) and the invitee (the person booking with you). Here’s the process:
|
|
||||||
|
|
||||||
For You (The User):
|
|
||||||
Sign Up and Set Up
|
|
||||||
Log in with Google (which also links your Google Calendar).
|
|
||||||
Set your availability (e.g., weekdays from 9 AM to 5 PM) and event duration (e.g., 30 minutes).
|
|
||||||
Share Your Booking Link
|
|
||||||
Get a unique URL (e.g., freesched.example.com/yourname) to share via email, social media, or anywhere.
|
|
||||||
Manage Bookings
|
|
||||||
View upcoming bookings in a simple dashboard.
|
|
||||||
Cancel bookings if needed, with automatic notifications sent to both parties.
|
|
||||||
For Invitees (The Booker):
|
|
||||||
Visit Your Booking Page
|
|
||||||
They open your link and see a calendar with your available dates.
|
|
||||||
Pick a Time
|
|
||||||
They select a date and choose from your available 30-minute slots, adjusted to their time zone.
|
|
||||||
Confirm the Booking
|
|
||||||
They enter their name and email, hit confirm, and receive an email with the details.
|
|
||||||
Once booked, the event is added to your Google Calendar, and both of you get a confirmation email. That’s it—no fuss, no extra steps.
|
|
||||||
|
|
||||||
UI/UX: Inspired by Cal.com and Calendly
|
|
||||||
FreeSched keeps the polished, user-friendly design of Cal.com and Calendly:
|
|
||||||
|
|
||||||
Clean Layout: A minimalistic interface with plenty of whitespace, clear fonts, and a simple color scheme.
|
|
||||||
Intuitive Navigation: Easy-to-use calendar for picking dates and a straightforward list of time slots.
|
|
||||||
Responsive Design: Works seamlessly on desktops, tablets, and phones.
|
|
||||||
Booking Flow: Select a date → pick a time → enter details → confirm—all in a few clicks.
|
|
||||||
Think of Calendly’s booking page or Cal.com’s sleek dashboard, but pared down to the essentials. The focus is on clarity and ease, avoiding overwhelming options or cluttered screens.
|
|
||||||
|
|
||||||
How It’s Simpler Than Cal.com
|
|
||||||
Cal.com is a powerful scheduling infrastructure with tons of features, but that can make it overkill for simple needs. FreeSched simplifies things:
|
|
||||||
|
|
||||||
Feature Cal.com FreeSched
|
|
||||||
Event Types Multiple (15-min, 30-min, etc.) One fixed duration (e.g., 30 min)
|
|
||||||
Availability Detailed rules and buffers Basic weekly grid
|
|
||||||
Integrations Multiple calendars, payments, etc. Google Calendar only
|
|
||||||
Team Support Yes (shared schedules, etc.) Individual use only
|
|
||||||
Customization Highly customizable Simple, uniform design
|
|
||||||
FreeSched cuts out the extras to deliver a tool that’s fast to set up and easy to use, while still looking and feeling like its more complex cousins.
|
|
||||||
|
|
||||||
Technical Overview (For the Curious)
|
|
||||||
FreeSched is built with modern, open-source tools:
|
|
||||||
|
|
||||||
Frontend: Next.js (a React framework for fast, server-rendered pages).
|
|
||||||
Backend: Node.js with Express.js for handling bookings and logic.
|
|
||||||
Database: PostgreSQL with Prisma to store user data and bookings.
|
|
||||||
Authentication: NextAuth.js with Google OAuth for login and calendar access.
|
|
||||||
Emails: SendGrid or Nodemailer for notifications.
|
|
||||||
This keeps FreeSched lightweight, secure, and easy to maintain, with a tech stack similar to Cal.com but scaled back for simplicity.
|
|
||||||
|
|
||||||
Why Use FreeSched?
|
|
||||||
FreeSched is for anyone who wants a free, no-nonsense scheduling tool:
|
|
||||||
|
|
||||||
Perfect For: Freelancers, tutors, consultants, or anyone needing simple one-on-one bookings.
|
|
||||||
Benefits: Quick setup, no learning curve, and a professional look inspired by Cal.com and Calendly.
|
|
||||||
Open-Source: Like Cal.com, it’s free to use and customizable if you’re a developer.
|
|
||||||
It’s scheduling made easy—just share a link and let others book you, all wrapped in a familiar, user-friendly package.
|
|
BIN
db/freesched.db
BIN
db/freesched.db
Binary file not shown.
19
db/init.js
19
db/init.js
@ -1,19 +0,0 @@
|
|||||||
// db/init.js
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const db = new sqlite3.Database('db/freesched.db');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS availability (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL UNIQUE,
|
|
||||||
times TEXT NOT NULL
|
|
||||||
)`);
|
|
||||||
|
|
||||||
// Example initial data for April 2021 (matching screenshot)
|
|
||||||
db.run(`INSERT OR IGNORE INTO availability (date, times) VALUES (?, ?)`,
|
|
||||||
['2025-02-21', '9:30am,10:00am,10:30am,11:00am,11:30am,2:00pm']);
|
|
||||||
db.run(`INSERT OR IGNORE INTO availability (date, times) VALUES (?, ?)`,
|
|
||||||
['2025-02-22', '10:00am,11:00am,2:00pm']);
|
|
||||||
});
|
|
||||||
|
|
||||||
db.close();
|
|
38
favicon.ico
38
favicon.ico
@ -1,38 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Martian background (reddish-orange) -->
|
|
||||||
<rect width="64" height="64" fill="#B22222" />
|
|
||||||
|
|
||||||
<!-- Calendar grid (white, tilted 15 degrees) -->
|
|
||||||
<g transform="rotate(15 32 32)">
|
|
||||||
<rect x="20" y="15" width="24" height="24" fill="none" stroke="#FFFFFF" stroke-width="2" />
|
|
||||||
<!-- Horizontal lines -->
|
|
||||||
<line x1="20" y1="21" x2="44" y2="21" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<line x1="20" y1="27" x2="44" y2="27" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<line x1="20" y1="33" x2="44" y2="33" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<line x1="20" y1="39" x2="44" y2="39" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<!-- Vertical lines -->
|
|
||||||
<line x1="26" y1="15" x2="26" y2="39" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<line x1="32" y1="15" x2="32" y2="39" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<line x1="38" y1="15" x2="38" y2="39" stroke="#FFFFFF" stroke-width="1" />
|
|
||||||
<!-- Date "21" in the center square -->
|
|
||||||
<text x="32" y="34" fill="#000000" font-family="Arial" font-size="12" font-weight="bold" text-anchor="middle" dominant-baseline="middle">21</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Mars silhouette (red circle with subtle craters) -->
|
|
||||||
<circle cx="50" cy="14" r="7" fill="#FF4500" />
|
|
||||||
<circle cx="48" cy="12" r="1" fill="#8B0000" /> <!-- Crater 1 -->
|
|
||||||
<circle cx="50" cy="15" r="1" fill="#8B0000" /> <!-- Crater 2 -->
|
|
||||||
|
|
||||||
<!-- FreeSched branding ("FS" in white, bottom-left) -->
|
|
||||||
<text x="8" y="58" fill="#FFFFFF" font-family="Roboto" font-size="8" font-weight="bold">FS</text>
|
|
||||||
|
|
||||||
<!-- Optional subtle glow around calendar for visibility -->
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="1" result="coloredBlur"/>
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="coloredBlur"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<rect x="20" y="15" width="24" height="24" fill="none" stroke="#FFFFFF" stroke-width="2" filter="url(#glow)" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.0 KiB |
2322
package-lock.json
generated
2322
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "freesched-app",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "FreeSched Meeting Scheduler",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"sqlite3": "^5.1.6"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
826
public/admin.js
826
public/admin.js
@ -1,826 +0,0 @@
|
|||||||
// public/admin.js (for admin.html)
|
|
||||||
|
|
||||||
// Global variables
|
|
||||||
let currentAdminDate = new Date();
|
|
||||||
let currentAdminMonth = currentAdminDate.getMonth();
|
|
||||||
let currentAdminYear = currentAdminDate.getFullYear();
|
|
||||||
let selectedAdminDate = null;
|
|
||||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
||||||
|
|
||||||
// Generate all possible time slots in chronological order (8am to 5pm)
|
|
||||||
const timeSlotsData = [
|
|
||||||
'8:00am', '9:00am', '10:00am', '11:00am', '12:00pm',
|
|
||||||
'1:00pm', '2:00pm', '3:00pm', '4:00pm', '5:00pm'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper function to convert time string to minutes since midnight
|
|
||||||
function timeToMinutes(timeStr) {
|
|
||||||
const match = timeStr.match(/([0-9]+)(?::([0-9]+))?(am|pm)/i);
|
|
||||||
if (!match) return 0;
|
|
||||||
|
|
||||||
let hours = parseInt(match[1]);
|
|
||||||
const minutes = parseInt(match[2] || '0');
|
|
||||||
const meridian = match[3].toLowerCase();
|
|
||||||
|
|
||||||
if (meridian === 'pm' && hours !== 12) {
|
|
||||||
hours += 12;
|
|
||||||
} else if (meridian === 'am' && hours === 12) {
|
|
||||||
hours = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to ensure consistent time format
|
|
||||||
function formatTimeSlot(hour) {
|
|
||||||
const meridian = hour >= 12 ? 'pm' : 'am';
|
|
||||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
|
||||||
return `${displayHour}:00${meridian}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the admin calendar with available dates highlighted
|
|
||||||
async function renderAdminCalendar(month, year) {
|
|
||||||
const adminCalendarDates = document.getElementById('adminCalendarDates');
|
|
||||||
const adminMonthYear = document.getElementById('adminMonthYear');
|
|
||||||
if (!adminCalendarDates || !adminMonthYear) {
|
|
||||||
console.error('Required calendar elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing calendar
|
|
||||||
adminCalendarDates.innerHTML = '';
|
|
||||||
adminMonthYear.textContent = `${months[month]} ${year}`;
|
|
||||||
|
|
||||||
// Get current UID and availability
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) {
|
|
||||||
// If no UID selected, just show empty calendar
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDay = new Date(year, month, 1).getDay();
|
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
||||||
const today = new Date();
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
|
|
||||||
// Start of today (midnight)
|
|
||||||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
||||||
|
|
||||||
// Create blank cells for days before the first day of the month
|
|
||||||
for (let i = 0; i < firstDay; i++) {
|
|
||||||
const blank = document.createElement('div');
|
|
||||||
blank.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center', 'text-muted');
|
|
||||||
adminCalendarDates.appendChild(blank);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any existing selected date
|
|
||||||
const existingSelected = adminCalendarDates.querySelector('.selected');
|
|
||||||
if (existingSelected) {
|
|
||||||
existingSelected.classList.remove('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the calendar with days
|
|
||||||
let todayElement = null;
|
|
||||||
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];
|
|
||||||
|
|
||||||
// Check if date is in the past
|
|
||||||
const isPastDate = date < startOfToday;
|
|
||||||
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
|
||||||
day.classList.add('current');
|
|
||||||
todayElement = day;
|
|
||||||
}
|
|
||||||
if (availability[dateStr]) {
|
|
||||||
day.classList.add('available');
|
|
||||||
}
|
|
||||||
if (isPastDate) {
|
|
||||||
day.classList.add('disabled');
|
|
||||||
} else {
|
|
||||||
day.addEventListener('click', () => selectAdminDate(date, firstDay));
|
|
||||||
}
|
|
||||||
adminCalendarDates.appendChild(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a date was previously selected, re-select it
|
|
||||||
if (selectedAdminDate &&
|
|
||||||
selectedAdminDate.getMonth() === month &&
|
|
||||||
selectedAdminDate.getFullYear() === year) {
|
|
||||||
selectAdminDate(selectedAdminDate, firstDay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle admin date selection
|
|
||||||
async function selectAdminDate(date, firstDay) {
|
|
||||||
selectedAdminDate = date;
|
|
||||||
const calendarDates = document.getElementById('adminCalendarDates');
|
|
||||||
if (!calendarDates) return;
|
|
||||||
|
|
||||||
// Remove selected class from all dates
|
|
||||||
const allDates = calendarDates.querySelectorAll('.date-item');
|
|
||||||
allDates.forEach(item => item.classList.remove('selected'));
|
|
||||||
|
|
||||||
// Add selected class to the clicked date
|
|
||||||
const selectedDay = Array.from(allDates).find(item => {
|
|
||||||
if (!item.textContent) return false;
|
|
||||||
const dayText = item.textContent;
|
|
||||||
const itemDate = new Date(currentAdminYear, currentAdminMonth, parseInt(dayText));
|
|
||||||
return itemDate.toDateString() === date.toDateString();
|
|
||||||
});
|
|
||||||
if (selectedDay) selectedDay.classList.add('selected');
|
|
||||||
const selectedDateDisplay = document.getElementById('selectedDateDisplay');
|
|
||||||
if (selectedDateDisplay) {
|
|
||||||
selectedDateDisplay.textContent = `Selected Date: ${date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time slots for the selected date
|
|
||||||
await updateTimeSlots(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time slots based on selected date
|
|
||||||
async function updateTimeSlots(date) {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
let availableTimes = [];
|
|
||||||
|
|
||||||
// Handle case where availability might be undefined or null
|
|
||||||
if (availability && availability[dateStr]) {
|
|
||||||
if (typeof availability[dateStr] === 'string') {
|
|
||||||
availableTimes = availability[dateStr].split(',').map(t => t.trim());
|
|
||||||
} else if (Array.isArray(availability[dateStr])) {
|
|
||||||
availableTimes = availability[dateStr].map(t => t.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort available times chronologically
|
|
||||||
availableTimes.sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
||||||
|
|
||||||
const timeSlots = document.getElementById('timeSlots');
|
|
||||||
if (!timeSlots) return;
|
|
||||||
|
|
||||||
timeSlots.innerHTML = '';
|
|
||||||
|
|
||||||
// Get current time in minutes for comparison
|
|
||||||
const now = new Date();
|
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
// Create a map of all time slots with their status
|
|
||||||
const allSlots = new Map();
|
|
||||||
timeSlotsData.forEach(time => {
|
|
||||||
const timeInMinutes = timeToMinutes(time);
|
|
||||||
// Skip times that are in the past on the current day
|
|
||||||
if (isToday && timeInMinutes <= currentMinutes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
allSlots.set(time, availableTimes.includes(time));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort all slots chronologically
|
|
||||||
const sortedSlots = Array.from(allSlots.entries())
|
|
||||||
.sort((a, b) => timeToMinutes(a[0]) - timeToMinutes(b[0]));
|
|
||||||
|
|
||||||
// Display all slots in chronological order
|
|
||||||
sortedSlots.forEach(([time, isAvailable]) => {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.classList.add('time-slot', 'btn', 'rounded-pill', 'w-100', 'mb-2', 'py-2');
|
|
||||||
button.textContent = time;
|
|
||||||
|
|
||||||
if (isAvailable) {
|
|
||||||
button.classList.add('btn-primary', 'available');
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
button.classList.remove('btn-primary', 'available');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
removeTime(dateStr, time);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
button.classList.add('btn-primary', 'available');
|
|
||||||
addSingleTime(dateStr, time);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlots.appendChild(button);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch available dates from the API
|
|
||||||
async function fetchAvailability() {
|
|
||||||
try {
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) return {};
|
|
||||||
|
|
||||||
const response = await fetch(`/api/availability/${selectedUid}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const availability = await response.json();
|
|
||||||
return availability;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching availability:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to update calendar UI after availability changes
|
|
||||||
async function updateAvailabilityUI() {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
if (selectedAdminDate) {
|
|
||||||
await updateTimeSlots(selectedAdminDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarDates = document.getElementById('adminCalendarDates');
|
|
||||||
if (calendarDates) {
|
|
||||||
const allDateElements = calendarDates.querySelectorAll('.date-item');
|
|
||||||
allDateElements.forEach(dateElement => {
|
|
||||||
if (!dateElement.textContent) return; // Skip empty cells
|
|
||||||
const day = parseInt(dateElement.textContent);
|
|
||||||
const date = new Date(currentAdminYear, currentAdminMonth, day);
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (availability[dateStr]) {
|
|
||||||
dateElement.classList.add('available');
|
|
||||||
} else {
|
|
||||||
dateElement.classList.remove('available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UID management functions
|
|
||||||
async function loadUids() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/uids');
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to fetch UIDs');
|
|
||||||
}
|
|
||||||
const uids = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('uidSelect');
|
|
||||||
select.innerHTML = '<option value="">Select a Schedule UID...</option>';
|
|
||||||
|
|
||||||
uids.forEach(uidObj => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = uidObj.uid;
|
|
||||||
option.textContent = uidObj.uid;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading UIDs:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUid(uid) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/uids', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ uid })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to create UID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload UIDs and select the new one
|
|
||||||
await loadUids();
|
|
||||||
|
|
||||||
// Select the new UID and trigger change event
|
|
||||||
const select = document.getElementById('uidSelect');
|
|
||||||
select.value = uid;
|
|
||||||
select.dispatchEvent(new Event('change'));
|
|
||||||
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating UID:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUid(uid) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/uids/${uid}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete UID');
|
|
||||||
await loadUids();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting UID:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Add a single time for the selected date
|
|
||||||
async function addSingleTime(dateStr, time) {
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) {
|
|
||||||
alert('Please select a UID first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
let times = [];
|
|
||||||
if (availability && availability[dateStr]) {
|
|
||||||
if (typeof availability[dateStr] === 'string') {
|
|
||||||
times = availability[dateStr].split(',').map(t => t.trim());
|
|
||||||
} else if (Array.isArray(availability[dateStr])) {
|
|
||||||
times = [...availability[dateStr]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
times.push(time);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/availability/${selectedUid}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ date: dateStr, times: [...new Set(times)] }) // Remove duplicates
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log(result.message);
|
|
||||||
await updateAvailabilityUI();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding time slot:', error);
|
|
||||||
alert('Failed to add time slot. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove a single time for the selected date
|
|
||||||
async function removeTime(dateStr, time) {
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) {
|
|
||||||
alert('Please select a UID first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
let times = [];
|
|
||||||
if (availability && availability[dateStr]) {
|
|
||||||
if (typeof availability[dateStr] === 'string') {
|
|
||||||
times = availability[dateStr].split(',').filter(t => t.trim() !== time);
|
|
||||||
} else if (Array.isArray(availability[dateStr])) {
|
|
||||||
times = availability[dateStr].filter(t => t.trim() !== time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/availability/${selectedUid}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ date: dateStr, times })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
await updateAvailabilityUI();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error removing time slot:', error);
|
|
||||||
alert('Failed to remove time slot. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to convert time string to minutes since midnight
|
|
||||||
function timeToMinutes(timeStr) {
|
|
||||||
const match = timeStr.match(/([0-9]+)(?::([0-9]+))?(am|pm)/i);
|
|
||||||
if (!match) return 0;
|
|
||||||
|
|
||||||
let [_, hours, minutes, period] = match;
|
|
||||||
hours = parseInt(hours);
|
|
||||||
minutes = minutes ? parseInt(minutes) : 0;
|
|
||||||
period = period.toLowerCase();
|
|
||||||
|
|
||||||
if (period === 'pm' && hours !== 12) hours += 12;
|
|
||||||
if (period === 'am' && hours === 12) hours = 0;
|
|
||||||
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to ensure consistent time format
|
|
||||||
function formatTimeSlot(hour) {
|
|
||||||
const period = hour < 12 ? 'am' : 'pm';
|
|
||||||
const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour);
|
|
||||||
return `${displayHour}:00${period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle admin date selection
|
|
||||||
async function selectAdminDate(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selected date and time slots
|
|
||||||
selectedAdminDate = date;
|
|
||||||
await updateTimeSlots(date);
|
|
||||||
const dateItems = document.querySelectorAll('#adminCalendarDates .date-item');
|
|
||||||
dateItems.forEach(item => item.classList.remove('selected')); // Ensure only one date is selected
|
|
||||||
// Find the exact date item for the clicked date
|
|
||||||
const selectedDay = Array.from(dateItems).find(item => {
|
|
||||||
const dayText = item.textContent;
|
|
||||||
const itemDate = new Date(currentAdminYear, currentAdminMonth, parseInt(dayText));
|
|
||||||
return itemDate.toDateString() === date.toDateString();
|
|
||||||
});
|
|
||||||
if (selectedDay) selectedDay.classList.add('selected');
|
|
||||||
const selectedDateDisplay = document.getElementById('selectedDateDisplay');
|
|
||||||
if (selectedDateDisplay) {
|
|
||||||
selectedDateDisplay.textContent = `Selected Date: ${date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time slots based on selected date
|
|
||||||
async function updateTimeSlots(date) {
|
|
||||||
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
let availableTimes = [];
|
|
||||||
|
|
||||||
// Handle case where availability might be undefined or null
|
|
||||||
if (availability && availability[dateStr]) {
|
|
||||||
if (typeof availability[dateStr] === 'string') {
|
|
||||||
availableTimes = availability[dateStr].split(',').map(t => t.trim());
|
|
||||||
} else if (Array.isArray(availability[dateStr])) {
|
|
||||||
availableTimes = availability[dateStr].map(t => t.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort available times chronologically
|
|
||||||
availableTimes.sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
||||||
|
|
||||||
const timeSlots = document.getElementById('timeSlots');
|
|
||||||
if (!timeSlots) return;
|
|
||||||
|
|
||||||
timeSlots.innerHTML = '';
|
|
||||||
|
|
||||||
// Get current time in minutes for comparison
|
|
||||||
const now = new Date();
|
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
// Create a map of all time slots with their status
|
|
||||||
const allSlots = new Map();
|
|
||||||
timeSlotsData.forEach(time => {
|
|
||||||
const timeInMinutes = timeToMinutes(time);
|
|
||||||
// Skip times that are in the past on the current day
|
|
||||||
if (isToday && timeInMinutes <= currentMinutes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
allSlots.set(time, availableTimes.includes(time));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort all slots chronologically
|
|
||||||
const sortedSlots = Array.from(allSlots.entries())
|
|
||||||
.sort((a, b) => timeToMinutes(a[0]) - timeToMinutes(b[0]));
|
|
||||||
|
|
||||||
// Display all slots in chronological order
|
|
||||||
sortedSlots.forEach(([time, isAvailable]) => {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.classList.add('time-slot', 'btn', 'rounded-pill', 'w-100', 'mb-2', 'py-2');
|
|
||||||
button.textContent = time;
|
|
||||||
|
|
||||||
if (isAvailable) {
|
|
||||||
button.classList.add('btn-primary', 'available');
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
button.classList.remove('btn-primary', 'available');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
removeTime(dateStr, time);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
button.classList.add('btn-primary', 'available');
|
|
||||||
addSingleTime(dateStr, time);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlots.appendChild(button);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch available dates from the API
|
|
||||||
async function fetchAvailability() {
|
|
||||||
try {
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) return {};
|
|
||||||
|
|
||||||
const response = await fetch(`/api/availability/${selectedUid}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const availability = await response.json();
|
|
||||||
return availability;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching availability:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to update calendar UI after availability changes
|
|
||||||
async function updateAvailabilityUI() {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
if (selectedAdminDate) {
|
|
||||||
await updateTimeSlots(selectedAdminDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarDates = document.getElementById('adminCalendarDates');
|
|
||||||
if (calendarDates) {
|
|
||||||
const allDateElements = calendarDates.querySelectorAll('.date-item');
|
|
||||||
allDateElements.forEach(dateElement => {
|
|
||||||
if (!dateElement.textContent) return; // Skip empty cells
|
|
||||||
const day = parseInt(dateElement.textContent);
|
|
||||||
const date = new Date(currentAdminYear, currentAdminMonth, day);
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (availability[dateStr]) {
|
|
||||||
dateElement.classList.add('available');
|
|
||||||
} else {
|
|
||||||
dateElement.classList.remove('available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
// Load existing UIDs
|
|
||||||
await loadUids();
|
|
||||||
|
|
||||||
// Handle UID selection
|
|
||||||
const uidSelect = document.getElementById('uidSelect');
|
|
||||||
const deleteUidBtn = document.getElementById('deleteUid');
|
|
||||||
const uidUrl = document.getElementById('uidUrl');
|
|
||||||
|
|
||||||
uidSelect.addEventListener('change', async () => {
|
|
||||||
const selectedUid = uidSelect.value;
|
|
||||||
deleteUidBtn.disabled = !selectedUid;
|
|
||||||
flushDatabaseBtn.disabled = !selectedUid;
|
|
||||||
|
|
||||||
// Clear current selections
|
|
||||||
selectedAdminDate = null;
|
|
||||||
const selectedDateElem = document.getElementById('selectedDateDisplay');
|
|
||||||
const timeSlotsElem = document.getElementById('timeSlots');
|
|
||||||
const calendarDatesElem = document.getElementById('adminCalendarDates');
|
|
||||||
const adminMonthYear = document.getElementById('adminMonthYear');
|
|
||||||
|
|
||||||
// Clear all UI elements
|
|
||||||
if (selectedDateElem) selectedDateElem.textContent = '';
|
|
||||||
if (timeSlotsElem) timeSlotsElem.innerHTML = '';
|
|
||||||
if (calendarDatesElem) calendarDatesElem.innerHTML = '';
|
|
||||||
if (adminMonthYear) adminMonthYear.textContent = '';
|
|
||||||
|
|
||||||
if (selectedUid) {
|
|
||||||
const url = `${window.location.origin}/${selectedUid}`;
|
|
||||||
uidUrl.textContent = url;
|
|
||||||
uidUrl.href = url;
|
|
||||||
// Refresh calendar with new UID's data
|
|
||||||
await renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
// Show availability for current date
|
|
||||||
if (selectedAdminDate) {
|
|
||||||
await updateTimeSlots(selectedAdminDate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uidUrl.textContent = 'Select a UID first';
|
|
||||||
uidUrl.href = '#';
|
|
||||||
// Add placeholder text
|
|
||||||
if (selectedDateElem) selectedDateElem.textContent = 'Please select a UID to view the calendar';
|
|
||||||
await renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle new UID creation
|
|
||||||
const newUidInput = document.getElementById('newUid');
|
|
||||||
const createUidBtn = document.getElementById('createUid');
|
|
||||||
|
|
||||||
createUidBtn.addEventListener('click', async () => {
|
|
||||||
const uid = newUidInput.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!/^[a-z0-9-]+$/.test(uid)) {
|
|
||||||
alert('UID can only contain lowercase letters, numbers, and hyphens');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await createUid(uid)) {
|
|
||||||
newUidInput.value = '';
|
|
||||||
} else {
|
|
||||||
alert('Failed to create UID. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle UID deletion
|
|
||||||
deleteUidBtn.addEventListener('click', async () => {
|
|
||||||
const uid = uidSelect.value;
|
|
||||||
if (!uid) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete UID: ${uid}?\nThis will remove all associated availability data.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await deleteUid(uid)) {
|
|
||||||
// Clear selection and trigger change event
|
|
||||||
uidSelect.value = '';
|
|
||||||
uidSelect.dispatchEvent(new Event('change'));
|
|
||||||
} else {
|
|
||||||
alert('Failed to delete UID. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize UI elements
|
|
||||||
const adminCalendarDates = document.getElementById('adminCalendarDates');
|
|
||||||
const adminMonthYear = document.getElementById('adminMonthYear');
|
|
||||||
const adminPrevMonthBtn = document.getElementById('adminPrevMonth');
|
|
||||||
const adminNextMonthBtn = document.getElementById('adminNextMonth');
|
|
||||||
const selectedDateDisplay = document.getElementById('selectedDateDisplay');
|
|
||||||
const timeSlots = document.getElementById('timeSlots');
|
|
||||||
const flushDatabaseBtn = document.getElementById('flushDatabase');
|
|
||||||
const resetDatabaseBtn = document.getElementById('resetDatabase');
|
|
||||||
|
|
||||||
// Handle database reset
|
|
||||||
resetDatabaseBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm('⚠️ WARNING: This will permanently delete ALL UIDs and their associated availability data. This action cannot be undone.\n\nAre you absolutely sure you want to proceed?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Double confirm for dangerous action
|
|
||||||
if (!confirm('Please confirm one more time that you want to reset the entire database. All data will be lost.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/reset', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to reset database');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload the page to reset all state
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting database:', error);
|
|
||||||
alert('Failed to reset database. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Check if a date has availability
|
|
||||||
async function checkAvailabilityForDate(date) {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
return !!availability[dateStr];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Flush (delete) all entries from the database
|
|
||||||
flushDatabaseBtn.addEventListener('click', async () => {
|
|
||||||
const selectedUid = document.getElementById('uidSelect').value;
|
|
||||||
if (!selectedUid) {
|
|
||||||
alert('Please select a UID first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete ALL availability entries for UID: ${selectedUid}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Attempting to delete all availability for UID: ${selectedUid}...`);
|
|
||||||
const response = await fetch(`/api/availability/${selectedUid}/flush`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log('Delete availability response:', result);
|
|
||||||
|
|
||||||
// Refresh data
|
|
||||||
await fetchAvailability(); // Refresh availability data
|
|
||||||
|
|
||||||
// Reset UI
|
|
||||||
selectedAdminDate = null; // Clear selected date
|
|
||||||
selectedDateDisplay.textContent = '';
|
|
||||||
timeSlots.innerHTML = ''; // Clear time slots
|
|
||||||
await renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting availability:', error);
|
|
||||||
alert('Failed to delete availability. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to previous month (admin)
|
|
||||||
adminPrevMonthBtn.addEventListener('click', () => {
|
|
||||||
currentAdminMonth--;
|
|
||||||
if (currentAdminMonth < 0) {
|
|
||||||
currentAdminMonth = 11;
|
|
||||||
currentAdminYear--;
|
|
||||||
}
|
|
||||||
renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to next month (admin)
|
|
||||||
adminNextMonthBtn.addEventListener('click', () => {
|
|
||||||
currentAdminMonth++;
|
|
||||||
if (currentAdminMonth > 11) {
|
|
||||||
currentAdminMonth = 0;
|
|
||||||
currentAdminYear++;
|
|
||||||
}
|
|
||||||
renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle global database flush
|
|
||||||
const flushGlobalDatabaseBtn = document.getElementById('flushGlobalDatabase');
|
|
||||||
if (flushGlobalDatabaseBtn) {
|
|
||||||
flushGlobalDatabaseBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm('WARNING: This will delete ALL UIDs and their availability data. This action cannot be undone. Are you sure you want to proceed?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/flush-global', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log('Global database flush response:', result);
|
|
||||||
alert(result.message || 'Database flushed successfully!');
|
|
||||||
|
|
||||||
// Reset everything
|
|
||||||
selectedAdminDate = null;
|
|
||||||
const selectedDateElem = document.getElementById('selectedDateDisplay');
|
|
||||||
const timeSlotsElem = document.getElementById('timeSlots');
|
|
||||||
const calendarDatesElem = document.getElementById('adminCalendarDates');
|
|
||||||
if (selectedDateElem) selectedDateElem.textContent = '';
|
|
||||||
if (timeSlotsElem) timeSlotsElem.innerHTML = '';
|
|
||||||
if (calendarDatesElem) calendarDatesElem.innerHTML = '';
|
|
||||||
|
|
||||||
// Reload UIDs
|
|
||||||
await loadUids();
|
|
||||||
|
|
||||||
// Update UID URL display
|
|
||||||
const uidUrl = document.getElementById('uidUrl');
|
|
||||||
if (uidUrl) uidUrl.textContent = 'Select a UID first';
|
|
||||||
|
|
||||||
// Disable delete button
|
|
||||||
const deleteUidBtn = document.getElementById('deleteUid');
|
|
||||||
if (deleteUidBtn) deleteUidBtn.disabled = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error flushing database:', error);
|
|
||||||
alert('Failed to flush database. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial render with current date
|
|
||||||
renderAdminCalendar(currentAdminMonth, currentAdminYear);
|
|
||||||
if (currentAdminDate.getDate() <= new Date(currentAdminYear, currentAdminMonth, 0).getDate()) {
|
|
||||||
const hasAvailability = await checkAvailabilityForDate(currentAdminDate);
|
|
||||||
if (hasAvailability) {
|
|
||||||
selectAdminDate(currentAdminDate, new Date(currentAdminYear, currentAdminMonth, 1).getDay());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
Binary file not shown.
Before ![]() (image error) Size: 33 KiB |
425
public/public.js
425
public/public.js
@ -1,425 +0,0 @@
|
|||||||
// 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 () => {
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Create blank cells for days before the first day of the month
|
|
||||||
for (let i = 0; i < firstDay; i++) {
|
|
||||||
const blank = document.createElement('div');
|
|
||||||
blank.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center', 'text-muted');
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
|
||||||
day.classList.add('current');
|
|
||||||
}
|
|
||||||
if (availability[dateStr]) {
|
|
||||||
day.classList.add('available');
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
245
public/script.js
245
public/script.js
@ -1,245 +0,0 @@
|
|||||||
// public/public.js (for index.html)
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
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');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const availability = await response.json();
|
|
||||||
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) {
|
|
||||||
// Fetch availability first to ensure data is ready
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Start of today (midnight)
|
|
||||||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
||||||
|
|
||||||
// Create blank cells for days before the first day of the month
|
|
||||||
for (let i = 0; i < firstDay; i++) {
|
|
||||||
const blank = document.createElement('div');
|
|
||||||
blank.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center', 'text-muted');
|
|
||||||
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];
|
|
||||||
|
|
||||||
// Check if date is in the past
|
|
||||||
const isPastDate = date < startOfToday;
|
|
||||||
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
|
||||||
day.classList.add('current');
|
|
||||||
}
|
|
||||||
if (availability[dateStr]) {
|
|
||||||
day.classList.add('available');
|
|
||||||
}
|
|
||||||
if (isPastDate) {
|
|
||||||
day.classList.add('text-muted');
|
|
||||||
day.style.cursor = 'not-allowed';
|
|
||||||
} else {
|
|
||||||
day.addEventListener('click', () => selectDate(date, firstDay));
|
|
||||||
day.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
calendarDates.appendChild(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only try to select today if we're in the current month and year
|
|
||||||
if (today.getMonth() === month && today.getFullYear() === year) {
|
|
||||||
await selectDate(today, firstDay); // Use await to ensure selection and display complete
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to convert time string to minutes for sorting
|
|
||||||
function timeToMinutes(timeStr) {
|
|
||||||
console.log('Converting time:', timeStr);
|
|
||||||
const match = timeStr.match(/([0-9]+)(?::([0-9]+))?(am|pm)/i);
|
|
||||||
if (!match) {
|
|
||||||
console.warn('No match for time:', timeStr);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [_, hours, minutes, period] = match;
|
|
||||||
hours = parseInt(hours);
|
|
||||||
minutes = minutes ? parseInt(minutes) : 0;
|
|
||||||
period = period.toLowerCase();
|
|
||||||
|
|
||||||
if (period === 'pm' && hours !== 12) hours += 12;
|
|
||||||
if (period === 'am' && hours === 12) hours = 0;
|
|
||||||
|
|
||||||
const totalMinutes = hours * 60 + minutes;
|
|
||||||
console.log(`${timeStr} -> ${totalMinutes} minutes`);
|
|
||||||
return totalMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time slots based on selected date
|
|
||||||
async function updateTimeSlots(date) {
|
|
||||||
const availability = await fetchAvailability();
|
|
||||||
const dateStr = date.toISOString().split('T')[0];
|
|
||||||
let times = [];
|
|
||||||
if (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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing time slots or message
|
|
||||||
timeSlotsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Get current time in minutes for comparison
|
|
||||||
const now = new Date();
|
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
// Filter out past times if it's today
|
|
||||||
if (isToday) {
|
|
||||||
times = times.filter(time => timeToMinutes(time) > currentMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort remaining times chronologically
|
|
||||||
times.sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
||||||
|
|
||||||
if (times.length === 0) {
|
|
||||||
// Display appropriate message based on context
|
|
||||||
const noAvailabilityMsg = document.createElement('p');
|
|
||||||
noAvailabilityMsg.classList.add('text-muted', 'text-center', 'fw-bold', 'fs-5', 'py-3');
|
|
||||||
|
|
||||||
let message;
|
|
||||||
if (isToday) {
|
|
||||||
if (currentMinutes > timeToMinutes('5:00pm')) {
|
|
||||||
message = 'No more availability today.';
|
|
||||||
} else {
|
|
||||||
message = 'No available time slots for today.';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message = 'No availability on selected date.';
|
|
||||||
}
|
|
||||||
|
|
||||||
noAvailabilityMsg.textContent = message;
|
|
||||||
timeSlotsContainer.appendChild(noAvailabilityMsg);
|
|
||||||
} else {
|
|
||||||
// Populate time slots dynamically
|
|
||||||
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', () => {
|
|
||||||
if (button.classList.contains('available') && selectedDate) {
|
|
||||||
const allTimeSlots = timeSlotsContainer.querySelectorAll('.time-slot');
|
|
||||||
allTimeSlots.forEach(s => s.classList.remove('selected'));
|
|
||||||
button.classList.add('selected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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
|
|
||||||
|
|
||||||
// Initial render (set to current date, automatically selecting and displaying it)
|
|
||||||
await renderCalendar(currentMonth, currentYear); // Use await to ensure the initial render completes
|
|
||||||
});
|
|
@ -1,234 +0,0 @@
|
|||||||
/* Custom styles to modernize and align with Calendly-inspired design */
|
|
||||||
:root {
|
|
||||||
--primary-color: #007bff; /* Blue from Calendly */
|
|
||||||
--secondary-color: #6c757d; /* Gray for muted elements */
|
|
||||||
--light-bg: #f8f9fa; /* Light background for modern look */
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Arial', sans-serif; /* Matches Calendly font */
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-fluid {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 20px auto;
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 2px 4px var(--shadow-color);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 30%;
|
|
||||||
padding: 20px;
|
|
||||||
border-right: 1px solid #e1e8ed;
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-pic {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
box-shadow: 0 2px 4px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
width: 70%;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
border-bottom: 1px solid #e1e8ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-nav button {
|
|
||||||
color: var(--secondary-color);
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-nav button:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.days-of-week, .dates {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 5px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.days-of-week span {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #666;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item {
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: var(--secondary-color);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item:hover {
|
|
||||||
background-color: #f0f8ff;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item.available {
|
|
||||||
background-color: #e9f2ff;
|
|
||||||
border: 2px solid var(--primary-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item.selected {
|
|
||||||
background-color: #0d6efd !important;
|
|
||||||
color: #fff !important;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates .date-item.current {
|
|
||||||
background-color: #e9f2ff;
|
|
||||||
border: 2px solid var(--primary-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
transition: background-color 0.3s ease, border-color 0.3s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot.available {
|
|
||||||
background-color: #e9f2ff;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slots .text-muted {
|
|
||||||
color: #6c757d; /* Muted text color, matching Calendly */
|
|
||||||
font-size: 1rem; /* Slightly larger font, e.g., 20px instead of 16px */
|
|
||||||
font-weight: bold; /* Bold text */
|
|
||||||
padding: 1rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot.available:hover, .time-slot:hover {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: #fff;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timezone {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.powered-by {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #666;
|
|
||||||
text-align: right;
|
|
||||||
position: relative;
|
|
||||||
margin-top: 2rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.powered-by a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.powered-by:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin-specific styles */
|
|
||||||
#admin .calendar {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#admin .form-control, #admin .form-select {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#admin .btn-primary, #admin .btn-danger {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#availabilityList .list-group-item {
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container-fluid {
|
|
||||||
margin: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar, .main-content {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid #e1e8ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
#admin .row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#admin .col-md-6 {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
419
server.js
419
server.js
@ -1,419 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const path = require('path');
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = 3000;
|
|
||||||
|
|
||||||
// Serve static files from the 'public' directory
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
// Serve HTML files from the 'views' directory
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, 'views', 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/admin', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, 'views', 'admin.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Middleware to parse JSON bodies
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Helper function to create database tables
|
|
||||||
const createDatabaseTables = (callback) => {
|
|
||||||
db.serialize(() => {
|
|
||||||
// Enable foreign key support
|
|
||||||
db.run('PRAGMA foreign_keys = ON', (err) => {
|
|
||||||
if (err) {
|
|
||||||
callback(new Error(`Failed to enable foreign keys: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create uids table
|
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS uids (
|
|
||||||
uid TEXT PRIMARY KEY,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
callback(new Error(`Failed to create uids table: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create availability table
|
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS availability (
|
|
||||||
uid TEXT,
|
|
||||||
date TEXT,
|
|
||||||
times TEXT,
|
|
||||||
PRIMARY KEY (uid, date),
|
|
||||||
FOREIGN KEY (uid) REFERENCES uids(uid) ON DELETE CASCADE
|
|
||||||
)`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
callback(new Error(`Failed to create availability table: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to initialize database
|
|
||||||
const initializeDatabase = () => {
|
|
||||||
// Ensure db directory exists
|
|
||||||
const fs = require('fs');
|
|
||||||
const dbDir = 'db';
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
|
||||||
fs.mkdirSync(dbDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to SQLite database
|
|
||||||
db = new sqlite3.Database('db/freesched.db', (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Could not connect to database:', err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Connected to SQLite database');
|
|
||||||
|
|
||||||
// Create tables
|
|
||||||
createDatabaseTables((err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating database tables:', err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Database tables created successfully');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
let db;
|
|
||||||
initializeDatabase();
|
|
||||||
|
|
||||||
// Admin view - must be defined before the catch-all UID route
|
|
||||||
app.get('/admin', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, 'views', 'admin.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Public view - handle both root and UID-based routes
|
|
||||||
app.get(['/', '/:uid'], (req, res) => {
|
|
||||||
// Only validate UID if it's provided (not root path)
|
|
||||||
if (req.params.uid) {
|
|
||||||
// Validate UID format
|
|
||||||
if (!/^[a-z0-9-]+$/.test(req.params.uid)) {
|
|
||||||
res.sendFile(path.join(__dirname, 'views', 'index.html'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.sendFile(path.join(__dirname, 'views', 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to convert time string to minutes for sorting
|
|
||||||
function timeToMinutes(timeStr) {
|
|
||||||
const match = timeStr.match(/([0-9]+)(?::([0-9]+))?(am|pm)/i);
|
|
||||||
if (!match) return 0;
|
|
||||||
|
|
||||||
let [_, hours, minutes, period] = match;
|
|
||||||
hours = parseInt(hours);
|
|
||||||
minutes = minutes ? parseInt(minutes) : 0;
|
|
||||||
period = period.toLowerCase();
|
|
||||||
|
|
||||||
if (period === 'pm' && hours !== 12) hours += 12;
|
|
||||||
if (period === 'am' && hours === 12) hours = 0;
|
|
||||||
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API endpoint to get available dates and times for a specific UID
|
|
||||||
app.get('/api/availability/:uid', (req, res) => {
|
|
||||||
const { uid } = req.params;
|
|
||||||
|
|
||||||
// Verify UID exists
|
|
||||||
db.get('SELECT uid FROM uids WHERE uid = ?', [uid], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!row) {
|
|
||||||
res.status(404).json({ error: 'UID not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get availability for this UID
|
|
||||||
db.all('SELECT date, times FROM availability WHERE uid = ?', [uid], (err, rows) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const availability = {};
|
|
||||||
rows.forEach(row => {
|
|
||||||
// Handle case where times might be null or undefined
|
|
||||||
if (row.times) {
|
|
||||||
// Split, trim, and sort times chronologically
|
|
||||||
const times = row.times.split(',').map(t => t.trim());
|
|
||||||
times.sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
||||||
availability[row.date] = times;
|
|
||||||
} else {
|
|
||||||
availability[row.date] = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(availability);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoint to add/update availability for a specific UID
|
|
||||||
app.post('/api/availability/:uid', (req, res) => {
|
|
||||||
const { uid } = req.params;
|
|
||||||
const { date, times } = req.body;
|
|
||||||
|
|
||||||
if (!date) {
|
|
||||||
res.status(400).json({ error: 'Date is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify UID exists
|
|
||||||
db.get('SELECT uid FROM uids WHERE uid = ?', [uid], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!row) {
|
|
||||||
res.status(404).json({ error: 'UID not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (times.length === 0) {
|
|
||||||
// Delete the entry if no times remain
|
|
||||||
db.run('DELETE FROM availability WHERE uid = ? AND date = ?', [uid, date], (err) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({ message: 'Availability removed successfully' });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Sort times before storing
|
|
||||||
const sortedTimes = [...times].sort((a, b) => timeToMinutes(a) - timeToMinutes(b));
|
|
||||||
|
|
||||||
// Insert or replace with the sorted times
|
|
||||||
db.run('INSERT OR REPLACE INTO availability (uid, date, times) VALUES (?, ?, ?)',
|
|
||||||
[uid, date, sortedTimes.join(',')],
|
|
||||||
(err) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({ message: 'Availability updated successfully' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// API endpoint to get all UIDs
|
|
||||||
app.get('/api/uids', (req, res) => {
|
|
||||||
createDatabaseTables((err) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all UIDs
|
|
||||||
db.all('SELECT uid, created_at FROM uids ORDER BY created_at DESC', (err, rows) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: `Failed to fetch UIDs: ${err.message}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(rows || []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoint to create a new UID
|
|
||||||
app.post('/api/uids', (req, res) => {
|
|
||||||
const { uid } = req.body;
|
|
||||||
|
|
||||||
// Validate UID format (lowercase letters, numbers, and hyphens only)
|
|
||||||
if (!uid || !/^[a-z0-9-]+$/.test(uid)) {
|
|
||||||
res.status(400).json({ error: 'Invalid UID format. Use only lowercase letters, numbers, and hyphens.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createDatabaseTables((err) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run('INSERT INTO uids (uid) VALUES (?)', [uid], function(err) {
|
|
||||||
if (err) {
|
|
||||||
if (err.code === 'SQLITE_CONSTRAINT') {
|
|
||||||
res.status(409).json({ error: 'UID already exists' });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ error: `Failed to insert UID: ${err.message}` });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(201).json({ uid, created_at: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoint to delete a UID and its availability
|
|
||||||
app.delete('/api/uids/:uid', (req, res) => {
|
|
||||||
const { uid } = req.params;
|
|
||||||
|
|
||||||
// Begin transaction
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run('BEGIN TRANSACTION');
|
|
||||||
|
|
||||||
// Delete from availability first due to foreign key constraint
|
|
||||||
db.run('DELETE FROM availability WHERE uid = ?', [uid], (err) => {
|
|
||||||
if (err) {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then delete from uids
|
|
||||||
db.run('DELETE FROM uids WHERE uid = ?', [uid], function(err) {
|
|
||||||
if (err) {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.changes === 0) {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
res.status(404).json({ error: 'UID not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit transaction
|
|
||||||
db.run('COMMIT', (err) => {
|
|
||||||
if (err) {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({ message: 'UID deleted successfully' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoint to flush (delete) all availability entries for a specific UID
|
|
||||||
app.delete('/api/availability/:uid/flush', (req, res) => {
|
|
||||||
const { uid } = req.params;
|
|
||||||
|
|
||||||
// Verify UID exists
|
|
||||||
db.get('SELECT uid FROM uids WHERE uid = ?', [uid], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!row) {
|
|
||||||
res.status(404).json({ error: 'UID not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all availability for this UID
|
|
||||||
db.run('DELETE FROM availability WHERE uid = ?', [uid], (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error deleting availability:', err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`All availability entries for UID ${uid} deleted successfully`);
|
|
||||||
res.json({ message: `All availability entries for UID ${uid} deleted successfully` });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoint to reset the entire database
|
|
||||||
app.post('/api/reset', async (req, res) => {
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const dbDir = 'db';
|
|
||||||
const dbPath = path.join(dbDir, 'freesched.db');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Close current database connection
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
if (!db) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure db directory exists
|
|
||||||
try {
|
|
||||||
await fs.access(dbDir);
|
|
||||||
} catch {
|
|
||||||
await fs.mkdir(dbDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete existing database if it exists
|
|
||||||
try {
|
|
||||||
await fs.unlink(dbPath);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== 'ENOENT') throw err; // Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize fresh database
|
|
||||||
initializeDatabase();
|
|
||||||
|
|
||||||
// Wait for database to be ready
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const checkDb = () => {
|
|
||||||
if (!db) {
|
|
||||||
setTimeout(checkDb, 100); // Check every 100ms
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test database connection
|
|
||||||
db.get('SELECT 1', (err) => {
|
|
||||||
if (err) {
|
|
||||||
setTimeout(checkDb, 100);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
checkDb();
|
|
||||||
|
|
||||||
// Set a timeout of 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error('Database initialization timeout'));
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Database completely wiped and recreated successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in flush-global:', error);
|
|
||||||
|
|
||||||
// Try to reinitialize database
|
|
||||||
try {
|
|
||||||
initializeDatabase();
|
|
||||||
} catch (reinitError) {
|
|
||||||
console.error('Failed to reinitialize database:', reinitError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we haven't already sent a response
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`FreeSched app listening at http://localhost:${port}`);
|
|
||||||
});
|
|
110
views/admin.html
110
views/admin.html
@ -1,110 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FreeSched Admin - Manage Availability</title>
|
|
||||||
<!-- Bootstrap CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/styles.css">
|
|
||||||
<!-- Bootstrap Icons -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container-fluid p-0">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-md-4 sidebar bg-light border-end border-gray-300">
|
|
||||||
<div class="d-flex flex-column align-items-center p-4">
|
|
||||||
<img src="/avatar.jpg" alt="Doug Masiero" class="profile-pic rounded-circle mb-3 shadow-sm">
|
|
||||||
<h2 class="fs-5 text-dark fw-bold mb-2">Doug Masiero</h2>
|
|
||||||
<h3 class="fs-6 text-muted mb-2">30 Minute Meeting</h3>
|
|
||||||
<p class="text-muted fs-6">30 min</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8 main-content p-4">
|
|
||||||
<div class="calendar-header d-flex justify-content-between align-items-center text-dark fs-5 mb-3">
|
|
||||||
<button id="adminPrevMonth" class="btn btn-link p-0 text-decoration-none"><i class="bi bi-chevron-left"></i></button>
|
|
||||||
<span id="adminMonthYear" class="fw-bold"></span>
|
|
||||||
<button id="adminNextMonth" class="btn btn-link p-0 text-decoration-none"><i class="bi bi-chevron-right"></i></button>
|
|
||||||
</div>
|
|
||||||
<div class="calendar mb-4">
|
|
||||||
<div class="days-of-week d-grid gap-2 mb-2" style="grid-template-columns: repeat(7, 1fr);">
|
|
||||||
<span class="text-muted fw-bold text-center p-2">SUN</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">MON</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">TUE</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">WED</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">THU</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">FRI</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">SAT</span>
|
|
||||||
</div>
|
|
||||||
<div id="adminCalendarDates" class="dates d-grid gap-2" style="grid-template-columns: repeat(7, 1fr);">
|
|
||||||
<!-- Dates will be dynamically generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uid-section mb-4">
|
|
||||||
<h2 class="fs-4 text-dark fw-bold mb-3">Manage Schedule UIDs</h2>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<select class="form-select form-select-lg" id="uidSelect">
|
|
||||||
<option value="">Select a Schedule UID...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<button class="btn btn-warning btn-lg w-100" id="flushDatabase" disabled>Clear UID</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<button class="btn btn-danger btn-lg w-100" id="deleteUid" disabled>Delete UID</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-9">
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="input-group input-group-lg gap-2">
|
|
||||||
<input type="text" class="form-control" id="newUid" placeholder="Enter new UID (e.g., john-1on1)">
|
|
||||||
<button class="btn btn-success" id="createUid" style="min-width: 120px;">Create UID</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">UIDs should be lowercase letters, numbers, and hyphens only</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-control-lg bg-light p-3">
|
|
||||||
<small class="d-block text-muted mb-1">Selected UID URL:</small>
|
|
||||||
<a id="uidUrl" href="#" target="_blank" class="text-decoration-none text-break">Select a UID first</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="availability-section">
|
|
||||||
<div id="selectedDateDisplay" class="mb-3 text-muted fs-6"></div>
|
|
||||||
<div class="time-grid mb-3">
|
|
||||||
<div class="time-slots" id="timeSlots">
|
|
||||||
<!-- Time slots will be dynamically populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="powered-by">
|
|
||||||
<a href="https://github.com/moeny/freesched" target="_blank" class="text-decoration-none text-muted">Powered by FreeSched</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container-fluid p-0">
|
|
||||||
<div class="danger-zone border border-danger rounded p-4">
|
|
||||||
<h2 class="fs-4 text-danger fw-bold mb-3">⚠️ Danger Zone</h2>
|
|
||||||
<p class="text-muted mb-4">The actions in this section can cause irreversible changes to the database. Please proceed with caution.</p>
|
|
||||||
<button class="btn btn-outline-danger w-100 mb-2" id="resetDatabase">Reset Global Database</button>
|
|
||||||
<div class="text-muted">This will permanently delete all UIDs and their associated availability data.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bootstrap JS and Popper.js -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
|
|
||||||
<script src="/admin.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
111
views/index.html
111
views/index.html
@ -1,111 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FreeSched Meeting Scheduler</title>
|
|
||||||
<!-- Bootstrap CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/styles.css">
|
|
||||||
<!-- Bootstrap Icons -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="uidError" class="container-fluid p-5 text-center" style="display: none;">
|
|
||||||
<div class="alert alert-warning p-5 shadow-sm" role="alert">
|
|
||||||
<h4 class="alert-heading mb-4"><i class="bi bi-exclamation-triangle me-2"></i>Invalid Schedule URL</h4>
|
|
||||||
<p class="mb-0">Please make sure you have the correct scheduling link.<br>The URL should include a valid schedule identifier.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="mainContent" class="container-fluid p-0">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-md-4 sidebar bg-light border-end border-gray-300">
|
|
||||||
<div class="d-flex flex-column align-items-center p-4">
|
|
||||||
<img src="/avatar.jpg" alt="Doug Masiero" class="profile-pic rounded-circle mb-3 shadow-sm">
|
|
||||||
<h2 class="fs-5 text-dark fw-bold mb-2">Doug Masiero</h2>
|
|
||||||
<h3 class="fs-6 text-muted mb-2">30 Minute Meeting</h3>
|
|
||||||
<p class="text-muted fs-6">30 min</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8 main-content p-4">
|
|
||||||
<div class="calendar-header">
|
|
||||||
<h1 class="fs-3 text-dark fw-bold mb-3">Select a Date & Time</h1>
|
|
||||||
<div class="month-nav d-flex justify-content-between align-items-center text-dark fs-5">
|
|
||||||
<button id="prevMonth" class="btn btn-link p-0 text-decoration-none"><i class="bi bi-chevron-left"></i></button>
|
|
||||||
<span id="monthYear">April 2021</span>
|
|
||||||
<button id="nextMonth" class="btn btn-link p-0 text-decoration-none"><i class="bi bi-chevron-right"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="calendar mb-4">
|
|
||||||
<div class="days-of-week d-grid gap-2 mb-2" style="grid-template-columns: repeat(7, 1fr);">
|
|
||||||
<span class="text-muted fw-bold text-center p-2">SUN</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">MON</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">TUE</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">WED</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">THU</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">FRI</span>
|
|
||||||
<span class="text-muted fw-bold text-center p-2">SAT</span>
|
|
||||||
</div>
|
|
||||||
<div id="calendarDates" class="dates d-grid gap-2" style="grid-template-columns: repeat(7, 1fr);">
|
|
||||||
<!-- Dates will be dynamically generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="timeSlots" class="time-slots mb-4">
|
|
||||||
<!-- Time slots will be dynamically populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
<p id="timezone" class="text-muted fs-6 mb-3">Eastern Time - US & Canada (<span id="currentTime"></span>)</p>
|
|
||||||
<div class="powered-by text-muted fs-7 text-end">
|
|
||||||
<a href="https://github.com/moeny/freesched" target="_blank" class="text-decoration-none text-muted">Powered by FreeSched</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Booking Modal -->
|
|
||||||
<div class="modal fade" id="bookingModal" tabindex="-1" aria-labelledby="bookingModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
||||||
<div class="modal-content shadow">
|
|
||||||
<div class="modal-header border-bottom-0 pb-0">
|
|
||||||
<h5 class="modal-title fw-bold" id="bookingModalLabel">Confirm Appointment</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body pt-2 px-4">
|
|
||||||
<form id="bookingForm">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="name" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control form-control-lg" id="name" required>
|
|
||||||
<div class="invalid-feedback">Please enter your name.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="email" class="form-label fw-semibold">Email <span class="text-danger">*</span></label>
|
|
||||||
<input type="email" class="form-control form-control-lg" id="email" required>
|
|
||||||
<div class="invalid-feedback">Please enter a valid email address.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="phone" class="form-label fw-semibold">Phone Number <small class="text-muted">(optional)</small></label>
|
|
||||||
<input type="tel" class="form-control form-control-lg" id="phone" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}">
|
|
||||||
<small class="text-muted">Format: XXX-XXX-XXXX</small>
|
|
||||||
<div class="invalid-feedback">Please use format: XXX-XXX-XXXX</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="notes" class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
|
|
||||||
<textarea class="form-control" id="notes" rows="3" placeholder="Add any additional information..."></textarea>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="selectedTime">
|
|
||||||
<input type="hidden" id="selectedDate">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-top-0 px-4">
|
|
||||||
<button type="button" class="btn btn-light btn-lg px-4" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary btn-lg px-4" id="submitBooking">Confirm</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bootstrap JS and Popper.js -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
|
|
||||||
<script src="/public.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Loading…
Reference in New Issue
Block a user