New public shifts system

This commit is contained in:
2025-08-22 14:45:40 -06:00
parent d96d76d104
commit a8edcf8592
24 changed files with 2236 additions and 65 deletions

View File

@@ -195,3 +195,67 @@
grid-column: span 1;
}
}
/* Public Shift Functionality Styles */
.public-shift {
color: var(--success-color);
font-weight: 600;
}
.private-shift {
color: var(--text-secondary);
font-weight: 600;
}
.public-link-section {
margin-top: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: var(--border-radius);
border-left: 4px solid var(--info-color);
}
.public-link-section label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-secondary);
}
.public-link-input {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.public-link-input:focus {
outline: none;
border-color: var(--primary-color);
}
/* Status badges for public/private */
.status-badge.status-public {
background: var(--success-light);
color: var(--success-color);
}
.status-badge.status-private {
background: var(--text-light);
color: var(--text-secondary);
}
/* Input group for public links */
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group input {
flex: 1;
}
.input-group .btn {
white-space: nowrap;
}

View File

@@ -176,32 +176,6 @@
padding: 15px;
}
/* Admin container mobile layout */
.admin-container {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
padding: 15px;
}
.admin-nav {
flex-direction: row;
overflow-x: auto;
gap: 10px;
padding-bottom: 10px;
}
.admin-nav a {
white-space: nowrap;
min-width: auto;
padding: 8px 12px;
font-size: var(--font-size-base);
}
.header .header-actions {
display: flex !important;
gap: 10px;

View File

@@ -114,7 +114,7 @@
top: 50%;
transform: translateY(-50%);
width: auto;
max-width: 60px;
max-width: 80px;
flex-direction: column;
box-sizing: border-box;
}

View File

@@ -0,0 +1,524 @@
/* Public Shifts Page CSS */
/* Import base styles */
@import url("modules/base.css");
@import url("modules/buttons.css");
@import url("modules/forms.css");
@import url("modules/modal.css");
@import url("modules/notifications.css");
@import url("modules/layout.css");
/* Public header styles */
.public-header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.public-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.header-actions .btn {
min-width: auto;
padding: 0.5rem 1rem;
}
/* Main container */
.public-shifts-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
padding-bottom: 120px; /* Extra padding for mobile navigation bars */
}
/* Intro section */
.shifts-intro {
text-align: center;
margin-bottom: 2rem;
}
.shifts-intro h2 {
color: var(--primary-color);
margin-bottom: 0.5rem;
font-size: 2rem;
}
.shifts-intro p {
color: var(--text-secondary);
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto;
}
/* Filters section */
.shifts-filters {
background: var(--bg-secondary);
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.filter-group {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group label {
font-weight: 600;
color: var(--text-primary);
}
.filter-group input[type="date"] {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 0.9rem;
}
/* Loading state */
.shifts-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Shifts grid */
.shifts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
/* Individual shift cards */
.shift-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
gap: 12px;
}
.shift-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.shift-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.shift-header h3 {
margin: 0;
color: #333;
font-size: 1.25rem;
line-height: 1.3;
flex: 1;
}
.shift-status {
padding: 4px 12px;
border-radius: 16px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-open {
background: #e8f5e9;
color: #2e7d32;
}
.status-full {
background: #ffebee;
color: #c62828;
}
.shift-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.shift-date,
.shift-time,
.shift-location {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 0.9rem;
}
/* Make icons smaller and consistent */
.shift-details .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: #9e3fb5;
}
.shift-description {
color: #666;
font-size: 0.9rem;
line-height: 1.5;
margin: 8px 0;
}
.shift-volunteers {
margin-top: 8px;
}
.volunteer-bar {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.volunteer-fill {
height: 100%;
background: linear-gradient(to right, #9e3fb5, #d946ef);
transition: width 0.3s ease;
}
.volunteer-count {
font-size: 0.85rem;
color: #666;
}
.signup-btn {
margin-top: 8px;
padding: 10px 20px;
font-size: 0.95rem;
}
/* No shifts message */
.no-shifts {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.no-shifts h3 {
color: var(--text-primary);
margin-bottom: 1rem;
}
/* Modal enhancements for public shifts */
.shift-details-modal {
background: var(--bg-secondary);
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
border-left: 4px solid var(--primary-color);
}
.shift-details-modal h4 {
margin: 0 0 0.5rem 0;
color: var(--primary-color);
}
.shift-details-modal .detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
}
.shift-details-modal .detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shift-details-modal .detail-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 60px;
}
/* Form help text */
.form-help {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* Signup info box */
.signup-info {
background: var(--bg-secondary);
padding: 1rem;
border-radius: var(--border-radius);
margin: 1rem 0;
border-left: 4px solid var(--info-color);
}
.signup-info p {
margin: 0 0 0.5rem 0;
font-weight: 600;
color: var(--text-primary);
}
.signup-info ul {
margin: 0;
padding-left: 1.2rem;
}
.signup-info li {
margin: 0.25rem 0;
color: var(--text-secondary);
}
/* Button loading state */
.btn-loading {
display: none;
}
.btn-text {
display: inline;
}
#submit-signup[disabled] .btn-text {
display: none !important;
}
#submit-signup[disabled] .btn-loading {
display: inline !important;
}
/* Ensure hidden class works */
.hidden {
display: none !important;
}
/* Success modal styling */
.success-content {
text-align: center;
}
.success-content p {
margin: 1rem 0;
}
.success-content ul {
text-align: left;
max-width: 300px;
margin: 1rem auto;
}
.success-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
/* Status badges for shifts */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.status-open {
background: var(--success-light);
color: var(--success-color);
}
.status-badge.status-full {
background: var(--warning-light);
color: var(--warning-color);
}
.status-badge.status-cancelled {
background: var(--error-light);
color: var(--error-color);
}
/* Modal positioning - center on screen */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
overflow: auto;
}
.modal:not(.hidden) {
display: flex;
}
.modal-content {
background: white;
border-radius: var(--border-radius);
max-width: 500px;
width: 90%;
max-height: 85vh; /* Reduced from 90vh to ensure visibility on mobile */
overflow-y: auto;
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
margin: auto;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.public-shifts-container {
padding: 1rem;
padding-bottom: 100px; /* Extra padding for mobile nav bars */
}
.shifts-intro h2 {
font-size: 1.6rem;
}
.shifts-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding-bottom: 80px; /* Additional padding for last card visibility */
}
.shift-card {
padding: 1rem;
}
/* Ensure last card is visible above mobile navigation */
.shift-card:last-child {
margin-bottom: 20px;
}
.filter-group {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.success-actions {
flex-direction: column;
}
/* Center modal on mobile with safe spacing */
.modal-content {
max-height: 75vh; /* Reduce height on mobile for better visibility */
margin: 10vh auto; /* Center vertically with safe margins */
width: 95%;
max-width: calc(100vw - 20px);
}
}
@media (max-width: 480px) {
.public-header h1 {
font-size: 1.4rem;
}
.header-actions {
width: 100%;
justify-content: center;
}
.shift-card {
margin: 0;
}
/* Even more space for smaller screens with bottom navigation */
.public-shifts-container {
padding-bottom: 150px;
}
.shifts-grid {
padding-bottom: 100px;
}
.modal-content {
max-height: 70vh; /* Further reduce on small screens */
margin: 15vh auto; /* More vertical centering space */
width: calc(100vw - 20px);
}
}
/* Account for iOS Safari bottom bar */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.public-shifts-container {
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
@media (max-width: 768px) {
.public-shifts-container {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
.shifts-grid {
padding-bottom: calc(80px + env(safe-area-inset-bottom));
}
}
@media (max-width: 480px) {
.public-shifts-container {
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
.shifts-grid {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}
}

View File

@@ -958,16 +958,6 @@ async function loadWalkSheetConfig() {
console.log(`Set QR ${i} label to:`, labelField.value);
}
}
console.log('Walk sheet config loaded successfully');
// Show status message about data source
if (data.source) {
const sourceText = data.source === 'database' ? 'Walk sheet config loaded from database' :
data.source === 'defaults' ? 'Using walk sheet defaults' :
'Walk sheet config loaded';
showStatus(sourceText, 'info');
}
return true;
} else {
@@ -1175,6 +1165,7 @@ function displayAdminShifts(shifts) {
list.innerHTML = shifts.map(shift => {
const shiftDate = createLocalDate(shift.Date);
const signupCount = shift.signups ? shift.signups.length : 0;
const isPublic = shift['Is Public'] !== false;
console.log(`Shift "${shift.Title}" (ID: ${shift.ID}) has ${signupCount} volunteers:`, shift.signups?.map(s => s['User Email']) || []);
@@ -1186,6 +1177,17 @@ function displayAdminShifts(shifts) {
<p>📍 ${escapeHtml(shift.Location || 'TBD')}</p>
<p>👥 ${signupCount}/${shift['Max Volunteers']} volunteers</p>
<p class="status-${(shift.Status || 'open').toLowerCase()}">${shift.Status || 'Open'}</p>
<p class="${isPublic ? 'public-shift' : 'private-shift'}">${isPublic ? '🌐 Public' : '🔒 Private'}</p>
${isPublic ? `
<div class="public-link-section">
<label>Public Link:</label>
<div class="input-group">
<input type="text" class="public-link-input" value="${generateShiftPublicLink(shift.ID)}" readonly />
<button type="button" class="btn btn-secondary btn-sm copy-shift-link-btn" data-shift-id="${shift.ID}">📋</button>
<button type="button" class="btn btn-info btn-sm open-shift-link-btn" data-shift-id="${shift.ID}">🔗</button>
</div>
</div>
` : ''}
</div>
<div class="shift-actions">
<button class="btn btn-primary btn-sm manage-volunteers-btn" data-shift-id="${shift.ID}" data-shift='${JSON.stringify(shift).replace(/'/g, "&#39;")}'>Manage Volunteers</button>
@@ -1226,6 +1228,14 @@ function setupShiftActionListeners() {
const shiftData = JSON.parse(e.target.getAttribute('data-shift').replace(/&#39;/g, "'"));
console.log('Manage volunteers clicked for shift:', shiftId);
showShiftUserModal(shiftId, shiftData);
} else if (e.target.classList.contains('copy-shift-link-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
console.log('Copy link button clicked for shift:', shiftId);
copyShiftLink(shiftId);
} else if (e.target.classList.contains('open-shift-link-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
console.log('Open link button clicked for shift:', shiftId);
openShiftLink(shiftId);
}
});
}
@@ -1272,7 +1282,8 @@ async function createShift(e) {
startTime: document.getElementById('shift-start').value,
endTime: document.getElementById('shift-end').value,
location: document.getElementById('shift-location').value,
maxVolunteers: document.getElementById('shift-max-volunteers').value
maxVolunteers: document.getElementById('shift-max-volunteers').value,
isPublic: document.getElementById('shift-is-public')?.checked !== false
};
try {
@@ -2587,3 +2598,69 @@ document.addEventListener('DOMContentLoaded', function() {
// Setup rich text editor functionality
setupRichTextEditor();
});
// Public Shifts Functions
function generateShiftPublicLink(shiftId) {
const baseUrl = window.location.origin;
return `${baseUrl}/public-shifts.html#shift-${shiftId}`;
}
function copyShiftLink(shiftId) {
const link = generateShiftPublicLink(shiftId);
navigator.clipboard.writeText(link).then(() => {
showStatus('Public shift link copied to clipboard!', 'success');
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = link;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showStatus('Public shift link copied to clipboard!', 'success');
});
}
function openShiftLink(shiftId) {
const link = generateShiftPublicLink(shiftId);
window.open(link, '_blank');
}
// Update the shift form to include Is Public checkbox
function updateShiftFormWithPublicOption() {
const form = document.getElementById('shift-form');
if (!form) return;
// Check if public checkbox already exists
if (document.getElementById('shift-is-public')) return;
const maxVolunteersGroup = form.querySelector('.form-group:has(#shift-max-volunteers)');
if (maxVolunteersGroup) {
const publicGroup = document.createElement('div');
publicGroup.className = 'form-group';
publicGroup.innerHTML = `
<label class="checkbox-label">
<input type="checkbox" id="shift-is-public" checked />
<span>Show on public signup page</span>
</label>
`;
maxVolunteersGroup.insertAdjacentElement('afterend', publicGroup);
}
}
// Call this when the shifts section is shown
function enhanceShiftsSection() {
updateShiftFormWithPublicOption();
}
// Update the showSection function to call enhanceShiftsSection when shifts section is shown
const originalShowSection = window.showSection || showSection;
window.showSection = function(sectionId) {
if (originalShowSection) {
originalShowSection(sectionId);
}
if (sectionId === 'shifts') {
enhanceShiftsSection();
}
};

View File

@@ -163,9 +163,15 @@ class ClientCacheManager {
notification.className = 'update-notification';
notification.innerHTML = `
<div class="update-notification-content">
<span class="update-message">🔄 A new version is available!</span>
<button class="update-button" onclick="cacheManager.forceReload()">Reload Now</button>
<button class="update-dismiss" onclick="this.closest('.update-notification').remove()">×</button>
<span class="update-message">
🔄 A new version is available!<br>
Please clear your browser cache and cookies, then refresh this page.<br>
<small>
<b>Instructions:</b> On most browsers, press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd> (Windows) or <kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd> (Mac) to force refresh.<br>
For best results, consider deleting browsing data via your browser settings.
</small>
</span>
<button class="update-dismiss" onclick="this.closest('.update-notification').remove()">×</button>
</div>
`;

View File

@@ -0,0 +1,557 @@
// Public shifts JavaScript
let allShifts = [];
let filteredShifts = [];
// Utility function to create a local date from YYYY-MM-DD string
function createLocalDate(dateString) {
if (!dateString) return new Date();
const parts = dateString.split('-');
if (parts.length !== 3) return new Date(dateString);
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
loadPublicShifts();
setupEventListeners();
// Check if there's a specific shift in the URL hash
const hash = window.location.hash;
if (hash.startsWith('#shift-')) {
const shiftId = hash.replace('#shift-', '');
setTimeout(() => highlightShift(shiftId), 1000); // Wait for shifts to load
}
});
// Load public shifts from API
async function loadPublicShifts() {
const loadingEl = document.getElementById('shifts-loading');
const gridEl = document.getElementById('shifts-grid');
const noShiftsEl = document.getElementById('no-shifts');
try {
showLoadingState(true);
const response = await fetch('/api/public/shifts');
const data = await response.json();
if (data.success) {
allShifts = data.shifts || [];
filteredShifts = [...allShifts];
displayShifts(filteredShifts);
} else {
throw new Error(data.error || 'Failed to load shifts');
}
} catch (error) {
console.error('Error loading shifts:', error);
showStatus('Failed to load volunteer opportunities. Please try again later.', 'error');
if (noShiftsEl) {
noShiftsEl.innerHTML = `
<h3>Unable to load opportunities</h3>
<p>There was a problem loading volunteer opportunities. Please refresh the page or try again later.</p>
`;
noShiftsEl.style.display = 'block';
}
} finally {
showLoadingState(false);
}
}
// Show/hide loading state
function showLoadingState(show) {
const loadingEl = document.getElementById('shifts-loading');
const gridEl = document.getElementById('shifts-grid');
const noShiftsEl = document.getElementById('no-shifts');
if (loadingEl) loadingEl.style.display = show ? 'flex' : 'none';
if (gridEl) gridEl.style.display = show ? 'none' : 'grid';
if (noShiftsEl && show) noShiftsEl.style.display = 'none';
}
// Utility function to escape HTML
function escapeHtml(text) {
// Handle null/undefined values
if (text === null || text === undefined) {
return '';
}
// Convert to string if not already
text = String(text);
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Display shifts in the grid
function displayShifts(shifts) {
const grid = document.getElementById('shifts-grid');
if (!shifts || shifts.length === 0) {
grid.innerHTML = '<div class="no-shifts">No volunteer opportunities available at this time.</div>';
return;
}
grid.innerHTML = shifts.map(shift => {
// NocoDB may use 'Id', 'ID', or 'id' - check all variations
const shiftId = shift.id || shift.Id || shift.ID || shift.ncRecordId;
// Debug log to see what fields are available
console.log('Shift object keys:', Object.keys(shift));
console.log('Shift ID found:', shiftId);
const shiftDate = createLocalDate(shift.Date);
const currentVolunteers = shift['Current Volunteers'] || 0;
const maxVolunteers = shift['Max Volunteers'] || 0;
const spotsLeft = Math.max(0, maxVolunteers - currentVolunteers);
const isFull = spotsLeft === 0;
// Use empty string as fallback for any missing fields
const title = shift.Title || 'Untitled Shift';
const location = shift.Location || 'Location TBD';
const startTime = shift['Start Time'] || '';
const endTime = shift['End Time'] || '';
const description = shift.Description || '';
return `
<div class="shift-card ${isFull ? 'shift-full' : ''}" data-shift-id="${shiftId}">
<div class="shift-header">
<h3>${escapeHtml(title)}</h3>
<span class="shift-status ${isFull ? 'status-full' : 'status-open'}">
${isFull ? 'FULL' : `${spotsLeft} spots left`}
</span>
</div>
<div class="shift-details">
<div class="shift-date">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
${shiftDate.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div class="shift-time">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
${escapeHtml(startTime)}${endTime ? ' - ' + escapeHtml(endTime) : ''}
</div>
<div class="shift-location">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
${escapeHtml(location)}
</div>
</div>
${description ? `<p class="shift-description">${escapeHtml(description)}</p>` : ''}
<div class="shift-volunteers">
<div class="volunteer-bar">
<div class="volunteer-fill" style="width: ${(currentVolunteers / maxVolunteers) * 100}%"></div>
</div>
<span class="volunteer-count">${currentVolunteers} / ${maxVolunteers} volunteers</span>
</div>
<button class="btn btn-primary signup-btn" data-shift-id="${shiftId}" ${isFull ? 'disabled' : ''}>
${isFull ? 'Shift Full' : 'Sign Up'}
</button>
</div>
`;
}).join('');
// Attach event listeners to all signup buttons after rendering
setTimeout(() => {
const signupButtons = grid.querySelectorAll('.signup-btn:not([disabled])');
signupButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const shiftId = e.currentTarget.getAttribute('data-shift-id');
console.log('Signup button clicked for shift:', shiftId);
showSignupModal(shiftId);
});
});
console.log(`Attached listeners to ${signupButtons.length} signup buttons`);
}, 0);
}
// Setup event listeners
function setupEventListeners() {
// Date filter
const dateFilter = document.getElementById('date-filter');
if (dateFilter) {
dateFilter.addEventListener('change', applyFilters);
}
// Clear filters button
const clearFiltersBtn = document.getElementById('clear-filters-btn');
if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', clearFilters);
}
// Modal close buttons - Fixed to properly close modals
const closeModalBtn = document.getElementById('close-modal');
const cancelSignupBtn = document.getElementById('cancel-signup');
if (closeModalBtn) {
closeModalBtn.addEventListener('click', (e) => {
e.preventDefault();
closeSignupModal();
});
}
if (cancelSignupBtn) {
cancelSignupBtn.addEventListener('click', (e) => {
e.preventDefault();
closeSignupModal();
});
}
// Success modal close
const closeSuccessBtn = document.getElementById('close-success-btn');
const closeSuccessModal = document.getElementById('close-success-modal');
if (closeSuccessBtn) {
closeSuccessBtn.addEventListener('click', (e) => {
e.preventDefault();
closeSuccessModals();
});
}
if (closeSuccessModal) {
closeSuccessModal.addEventListener('click', (e) => {
e.preventDefault();
closeSuccessModals();
});
}
// Signup form submission
const signupForm = document.getElementById('signup-form');
if (signupForm) {
signupForm.addEventListener('submit', handleSignupSubmit);
}
// Close modals when clicking outside
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
closeAllModals();
}
});
}
// Close signup modal
function closeSignupModal() {
const modal = document.getElementById('signup-modal');
if (modal) {
modal.classList.add('hidden');
modal.style.display = 'none';
}
}
// Close success modal
function closeSuccessModals() {
const modal = document.getElementById('success-modal');
if (modal) {
modal.classList.add('hidden');
modal.style.display = 'none';
}
}
// Close all modals
function closeAllModals() {
closeSignupModal();
closeSuccessModals();
}
// Apply filters
function applyFilters() {
const dateFilter = document.getElementById('date-filter');
const filterDate = dateFilter ? dateFilter.value : null;
filteredShifts = allShifts.filter(shift => {
if (filterDate) {
const shiftDate = shift.date;
if (shiftDate !== filterDate) {
return false;
}
}
return true;
});
displayShifts(filteredShifts);
}
// Clear all filters
function clearFilters() {
const dateFilter = document.getElementById('date-filter');
if (dateFilter) dateFilter.value = '';
filteredShifts = [...allShifts];
displayShifts(filteredShifts);
}
// Show signup modal for specific shift
function showSignupModal(shiftId) {
console.log('showSignupModal called with shiftId:', shiftId);
// Find shift using the same ID field variations
const shift = allShifts.find(s => {
const sId = s.id || s.Id || s.ID || s.ncRecordId;
return sId == shiftId;
});
if (!shift) {
console.error('Shift not found:', shiftId);
console.log('Available shifts:', allShifts.map(s => ({
id: s.id,
Id: s.Id,
ID: s.ID,
ncRecordId: s.ncRecordId,
Title: s.Title
})));
return;
}
const modal = document.getElementById('signup-modal');
const shiftDetails = document.getElementById('shift-details');
if (!modal || !shiftDetails) {
console.error('Modal elements not found');
return;
}
const shiftDate = createLocalDate(shift.Date);
// Use the actual field names from the shift object
const title = shift.Title || 'Untitled Shift';
const startTime = shift['Start Time'] || '';
const endTime = shift['End Time'] || '';
const location = shift.Location || 'Location TBD';
const description = shift.Description || '';
const maxVolunteers = shift['Max Volunteers'] || 0;
const currentVolunteers = shift['Current Volunteers'] || 0;
const spotsLeft = Math.max(0, maxVolunteers - currentVolunteers);
shiftDetails.innerHTML = `
<h4>${escapeHtml(title)}</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">📅 Date:</span>
<span>${shiftDate.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
<div class="detail-item">
<span class="detail-label">⏰ Time:</span>
<span>${escapeHtml(startTime)}${endTime ? ' - ' + escapeHtml(endTime) : ''}</span>
</div>
<div class="detail-item">
<span class="detail-label">📍 Location:</span>
<span>${escapeHtml(location)}</span>
</div>
<div class="detail-item">
<span class="detail-label">👥 Spots Available:</span>
<span>${spotsLeft} of ${maxVolunteers}</span>
</div>
</div>
${description ? `<p><strong>Description:</strong> ${escapeHtml(description)}</p>` : ''}
`;
// Store shift ID in form for submission
const form = document.getElementById('signup-form');
if (form) {
form.setAttribute('data-shift-id', shiftId);
}
// Clear previous form data
const emailField = document.getElementById('signup-email');
const nameField = document.getElementById('signup-name');
const phoneField = document.getElementById('signup-phone');
if (emailField) emailField.value = '';
if (nameField) nameField.value = '';
if (phoneField) phoneField.value = '';
// Show the modal with proper display
modal.classList.remove('hidden');
modal.style.display = 'flex'; // Changed from 'block' to 'flex' for centering
// Ensure modal content is scrolled to top
const modalContent = modal.querySelector('.modal-content');
if (modalContent) {
modalContent.scrollTop = 0;
}
console.log('Modal should now be visible and centered');
}
// Handle signup form submission
async function handleSignupSubmit(e) {
e.preventDefault();
const form = e.target;
const shiftId = form.getAttribute('data-shift-id');
const submitBtn = document.getElementById('submit-signup');
if (!submitBtn) {
console.error('Submit button not found');
return;
}
// Get or create the button text elements
let btnText = submitBtn.querySelector('.btn-text');
let btnLoading = submitBtn.querySelector('.btn-loading');
// If elements don't exist, the button might have plain text - restructure it
if (!btnText && !btnLoading) {
const currentText = submitBtn.textContent || 'Sign Up';
submitBtn.innerHTML = `
<span class="btn-text">${currentText}</span>
<span class="btn-loading" style="display: none;">Signing up...</span>
`;
btnText = submitBtn.querySelector('.btn-text');
btnLoading = submitBtn.querySelector('.btn-loading');
}
if (!shiftId) {
showStatus('No shift selected', 'error');
return;
}
const formData = {
email: document.getElementById('signup-email')?.value?.trim() || '',
name: document.getElementById('signup-name')?.value?.trim() || '',
phone: document.getElementById('signup-phone')?.value?.trim() || ''
};
// Basic validation
if (!formData.email || !formData.name) {
showStatus('Please fill in all required fields', 'error');
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
showStatus('Please enter a valid email address', 'error');
return;
}
try {
// Show loading state
submitBtn.disabled = true;
if (btnText) btnText.style.display = 'none';
if (btnLoading) btnLoading.style.display = 'inline';
const response = await fetch(`/api/public/shifts/${shiftId}/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
// Close signup modal
closeSignupModal();
// Show success modal
showSuccessModal(data.isNewUser);
// Refresh shifts to update counts
loadPublicShifts();
} else {
showStatus(data.error || 'Signup failed. Please try again.', 'error');
}
} catch (error) {
console.error('Signup error:', error);
showStatus('Network error. Please try again.', 'error');
} finally {
// Reset button state
submitBtn.disabled = false;
if (btnText) btnText.style.display = 'inline';
if (btnLoading) btnLoading.style.display = 'none';
}
}
// Show success modal
function showSuccessModal(isNewUser) {
const modal = document.getElementById('success-modal');
const messageDiv = document.getElementById('success-message');
if (!modal || !messageDiv) return;
const message = isNewUser
? `<p><strong>Welcome to our volunteer team!</strong></p>
<p>Thank you for signing up! We've created a temporary account for you and sent login details to your email.</p>
<p>You'll also receive a confirmation email with all the shift details.</p>`
: `<p>Thank you for signing up! You'll receive an email confirmation shortly with all the details.</p>`;
messageDiv.innerHTML = `
${message}
<div class="success-actions">
<button class="btn btn-primary" id="close-success-btn">Got it!</button>
</div>
`;
// Re-attach event listener for the new button
const closeBtn = messageDiv.querySelector('#close-success-btn');
if (closeBtn) {
closeBtn.addEventListener('click', closeModals);
}
modal.classList.remove('hidden');
// Auto-close after 10 seconds
setTimeout(() => {
if (!modal.classList.contains('hidden')) {
closeModals();
}
}, 10000);
}
// Close all modals
function closeModals() {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
modal.classList.add('hidden');
});
}
// Highlight specific shift (for direct links)
function highlightShift(shiftId) {
const shiftCard = document.querySelector(`[data-shift-id="${shiftId}"]`);
if (shiftCard) {
shiftCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
shiftCard.style.border = '3px solid var(--primary-color)';
setTimeout(() => {
shiftCard.style.border = '1px solid var(--border-color)';
}, 3000);
}
}
// Show status message
function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
if (!container) return;
const statusEl = document.createElement('div');
statusEl.className = `status-message status-${type}`;
statusEl.textContent = message;
container.appendChild(statusEl);
// Auto-remove after 5 seconds
setTimeout(() => {
if (statusEl.parentNode) {
statusEl.parentNode.removeChild(statusEl);
}
}, 5000);
}

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Volunteer Opportunities - BNKops</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/public-shifts.css">
</head>
<body>
<div id="app">
<header class="public-header">
<h1>Volunteer Opportunities</h1>
<div class="header-actions">
<a href="/" class="btn btn-secondary">View Map</a>
<a href="/login.html" class="btn btn-primary">Login</a>
</div>
</header>
<div class="public-shifts-container">
<div class="shifts-intro">
<h2>Join Our Campaign!</h2>
<p>Sign up for volunteer shifts to help make a difference in our community. If you already have a account, you can sign up for shifts by logging in and navigating to Shifts.</p>
</div>
<div class="shifts-filters">
<div class="filter-group">
<label for="date-filter">Filter by Date:</label>
<input type="date" id="date-filter" />
<button class="btn btn-secondary btn-sm" id="clear-filters-btn">Clear</button>
</div>
</div>
<div class="shifts-grid" id="shifts-grid">
<!-- Shifts will be loaded here -->
</div>
</div>
<!-- Signup Modal -->
<div id="signup-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Sign Up for Shift</h3>
<button class="modal-close" id="close-modal">&times;</button>
</div>
<div class="modal-body">
<div id="shift-details">
<!-- Shift details will be populated here -->
</div>
<form id="signup-form">
<div class="form-group">
<label for="signup-email">Email *</label>
<input type="email" id="signup-email" required />
</div>
<div class="form-group">
<label for="signup-name">Name *</label>
<input type="text" id="signup-name" required />
</div>
<div class="form-group">
<label for="signup-phone">Phone</label>
<input type="tel" id="signup-phone" />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-signup">Cancel</button>
<button type="submit" class="btn btn-primary" id="submit-signup">
<span class="btn-text">Sign Up</span>
<span class="btn-loading" style="display: none;">Signing up...</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Success Modal -->
<div id="success-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>✅ Signup Successful!</h3>
<button class="modal-close" id="close-success-modal">&times;</button>
</div>
<div class="modal-body">
<div id="success-message">
<p>Thank you for signing up! You'll receive an email confirmation shortly with all the details.</p>
<div class="success-actions">
<button class="btn btn-primary" id="close-success-btn">Got it!</button>
</div>
</div>
</div>
</div>
</div>
<div id="status-container" class="status-container"></div>
</div>
<script src="js/public-shifts.js"></script>
</body>
</html>