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 Width: | Height: | 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