diff --git a/map/Instuctions.md b/map/Instuctions.md index 886ec80..36dd0e0 100644 --- a/map/Instuctions.md +++ b/map/Instuctions.md @@ -58,6 +58,16 @@ When adding a new feature, follow these steps: - **Change map behavior:** Update `/public/js/map-manager.js` and related modules. - **Add a new cut property:** Update the cuts table schema, cutsController.js, and admin-cuts.js form. - **Modify cut drawing behavior:** Update `/public/js/cut-drawing.js` and related cut modules. +- **Add a new shift feature:** Update shiftsController.js for backend logic, shifts.js for authenticated users, and use publicShiftsController.js for public-facing functionality. +- **Modify public signup flow:** Update `/public/js/public-shifts.js`, `publicShiftsController.js`, and related email templates in `/templates/email/`. + +## Key Features + +- **Public Shift Signup:** Visitors can view and sign up for volunteer shifts without requiring an account. Temporary accounts are automatically created during signup with shift-based expiration. +- **Admin Shift Management:** Admins can toggle shift visibility, generate shareable public links, and manage volunteer signups through the admin panel. +- **Email Confirmation System:** Automated email confirmations sent to volunteers with account credentials, shift details, and login instructions. +- **Rate Limiting:** Built-in spam protection for public signup endpoints to prevent abuse. +- **Responsive Design:** All pages optimized for mobile and desktop viewing with accessible interfaces. ## Contact diff --git a/map/README.md b/map/README.md index 57c1c93..6917d9f 100644 --- a/map/README.md +++ b/map/README.md @@ -32,9 +32,12 @@ - đ QR code integration for digital resources - đ Volunteer shift management system with calendar and grid views - â User shift signup and cancellation with color-coded calendar -- īŋŊ Calendar integration (Google, Outlook, Apple) for shift export -- īŋŊđĨ Admin shift creation and management with volunteer assignment -- īŋŊ **Automated shift email notifications** - Send shift details to volunteers +- đ **Public shift signup** - Share volunteer opportunities with visitors (no account required) +- đ **Direct shift links** - Generate shareable URLs for specific volunteer opportunities +- đ¤ **Auto account creation** - Temporary accounts created automatically during public signup +- đ Calendar integration (Google, Outlook, Apple) for shift export +- đĨ Admin shift creation and management with volunteer assignment +- đ§ **Automated shift email notifications** - Send shift details to volunteers - īŋŊđ¨âđŧ User management panel for admin users (create, delete users) - īŋŊ **Admin broadcast emailing** - Rich HTML email composer with live preview - īŋŊđ Role-based access control (Admin vs User permissions) @@ -534,9 +537,83 @@ Common issues and solutions: - **Slow Sync**: Monitor network connectivity and Listmonk performance - **Duplicate Subscribers**: The system automatically handles duplicates using email addresses +## Public Volunteer Signup + +The application includes a public-facing volunteer signup system that allows visitors to view and sign up for volunteer opportunities without requiring an account. This feature is perfect for sharing volunteer opportunities via social media, QR codes, or direct links. + +### đ Public Shift Viewing + +- **No Authentication Required**: Visitors can browse all public volunteer opportunities at `/public-shifts.html` +- **Responsive Design**: Mobile-optimized interface for easy viewing on all devices +- **Shift Filtering**: Filter opportunities by date to find relevant events +- **Direct Linking**: Share specific shifts using unique URLs (e.g., `#shift-123`) + +### đ¤ Automatic Account Creation + +- **Temporary Accounts**: New volunteers automatically receive temporary accounts during signup +- **Account Credentials**: Login details sent via professional HTML email +- **Shift-Based Expiration**: Accounts expire automatically after the volunteer shift +- **Seamless Transition**: Temp users can access the main application to manage their signups + +### đ§ Professional Email Confirmations + +- **Instant Notifications**: Confirmation emails sent immediately after signup +- **Rich HTML Templates**: Professional, responsive email design +- **Shift Details**: Complete shift information including date, time, location +- **Login Instructions**: Clear instructions for accessing the volunteer account +- **Branded Experience**: Consistent branding with organization identity + +### đ Admin Share Controls + +- **Public/Private Toggle**: Admins control which shifts appear on the public page +- **Shareable Links**: Generate and copy direct links to volunteer opportunities +- **QR Code Ready**: Links work perfectly in QR codes for physical materials +- **Real-time Updates**: Changes to shift visibility reflect immediately + +### đĄī¸ Security & Abuse Prevention + +- **Rate Limiting**: Built-in protection against spam signups (5 signups per 15 minutes per IP) +- **Email Validation**: Automatic validation of email addresses during signup +- **Duplicate Prevention**: System prevents multiple signups for the same shift +- **Admin Oversight**: Admins can view and manage all public signups + +### đą Mobile-First Design + +- **Touch-Friendly**: Optimized for mobile signup on phones and tablets +- **Fast Loading**: Lightweight design for quick loading on all connections +- **Accessible**: Screen reader compatible and keyboard navigable +- **Progressive Enhancement**: Works without JavaScript for maximum compatibility + +### đ Integration Features + +- **Existing User Support**: Returning volunteers use their existing accounts +- **Signup Tracking**: Public signups marked separately for reporting +- **Volunteer Management**: Public signups integrate seamlessly with admin tools +- **Email Templates**: Separate templates for new vs existing users + +Access the public volunteer signup page at: **`/public-shifts.html`** + ## API Endpoints -### Public Endpoints +### Public Shift Endpoints (No Authentication Required) + +| Endpoint | Method | Description | Rate Limit | +|----------|--------|-------------|------------| +| `/public/shifts` | GET | Get all public shifts with locations | None | +| `/public/shifts/signup` | POST | Sign up for a public shift | 5 per 15 min per IP | + +**Public Shift Signup Request Body:** +```json +{ + "shift_id": "integer", + "first_name": "string", + "last_name": "string", + "email": "string", + "phone": "string (optional)" +} +``` + +### Public Endpoints (Location & Health) - `GET /api/locations` - Fetch all locations (requires auth) - `POST /api/locations` - Create new location (requires auth) diff --git a/map/app/controllers/publicShiftsController.js b/map/app/controllers/publicShiftsController.js new file mode 100644 index 0000000..7a428a9 --- /dev/null +++ b/map/app/controllers/publicShiftsController.js @@ -0,0 +1,271 @@ +const nocodbService = require('../services/nocodb'); +const config = require('../config'); +const logger = require('../utils/logger'); +const { sendEmail } = require('../services/email'); +const emailTemplates = require('../services/emailTemplates'); +const crypto = require('crypto'); + +class PublicShiftsController { + // Get all public shifts (without volunteer counts) + async getPublicShifts(req, res) { + try { + if (!config.nocodb.shiftsSheetId) { + return res.status(500).json({ + success: false, + error: 'Shifts not configured' + }); + } + + const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { + sort: 'Date,Start Time' + }); + + // More flexible filtering - check for Public field being truthy or not explicitly false + const shifts = (response.list || []).filter(shift => { + // Skip cancelled shifts + if (shift.Status === 'Cancelled') { + return false; + } + + // If Public field doesn't exist, include the shift (backwards compatibility) + if (shift.Public === undefined || shift.Public === null) { + logger.info(`Shift ${shift.Title} has no Public field, including it`); + return true; + } + + // Check for various truthy values (true, "true", 1, "1", "yes", etc.) + const publicValue = String(shift.Public).toLowerCase(); + return publicValue === 'true' || publicValue === '1' || publicValue === 'yes'; + }); + + logger.info(`Found ${shifts.length} public shifts out of ${response.list?.length || 0} total`); + + res.json({ + success: true, + shifts: shifts + }); + } catch (error) { + logger.error('Error fetching public shifts:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch shifts' + }); + } + } + + // Get single shift details for direct linking + async getShiftById(req, res) { + try { + const { id } = req.params; + const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, id); + + if (!shift || shift.Status === 'Cancelled') { + return res.status(404).json({ + success: false, + error: 'Shift not found' + }); + } + + // Similar flexible check for single shift + if (shift.Public !== undefined && shift.Public !== null) { + const publicValue = String(shift.Public).toLowerCase(); + const isPublic = publicValue === 'true' || publicValue === '1' || publicValue === 'yes'; + if (!isPublic) { + return res.status(404).json({ + success: false, + error: 'Shift not found' + }); + } + } + + res.json({ + success: true, + shift + }); + } catch (error) { + logger.error('Error fetching shift:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch shift' + }); + } + } + + // Public signup - creates temp user and signs them up + async publicSignup(req, res) { + try { + const { id } = req.params; + const { email, name, phone } = req.body; + + if (!email || !name) { + return res.status(400).json({ + success: false, + error: 'Email and name are required' + }); + } + + // Get shift details + const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, id); + logger.info('Raw shift data retrieved:', JSON.stringify(shift, null, 2)); + + if (!shift || shift.Status === 'Cancelled') { + return res.status(404).json({ + success: false, + error: 'Shift not found or cancelled' + }); + } + + // Check if shift is full + if (shift['Current Volunteers'] >= shift['Max Volunteers']) { + return res.status(400).json({ + success: false, + error: 'This shift is full' + }); + } + + // Check if user exists + let user = await nocodbService.getUserByEmail(email); + let isNewUser = false; + let tempPassword = null; + + if (!user) { + // Generate temp password using instance method + const controller = new PublicShiftsController(); + tempPassword = controller.generateTempPassword(); + + const shiftDate = new Date(shift.Date); + const expiresAt = new Date(shiftDate); + expiresAt.setDate(expiresAt.getDate() + 1); // Expires day after shift + + const userData = { + Email: email, + Password: tempPassword, + Name: name, + UserType: 'temp', + 'User Type': 'temp', + ExpiresAt: expiresAt.toISOString() + }; + + logger.info('Creating temp user with data:', JSON.stringify(userData, null, 2)); + user = await nocodbService.create(config.nocodb.loginSheetId, userData); + isNewUser = true; + logger.info(`Created temp user ${email} for shift ${id}`); + } + + // Check if already signed up + const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); + const existingSignup = (allSignups.list || []).find(s => + s['Shift ID'] === parseInt(id) && + s['User Email'] === email && + s.Status === 'Confirmed' + ); + + if (existingSignup) { + return res.status(400).json({ + success: false, + error: 'You are already signed up for this shift' + }); + } + + // Create signup + const signupData = { + 'Shift ID': parseInt(id), + 'Shift Title': shift.Title, + 'User Email': email, + 'User Name': name, + 'Signup Date': new Date().toISOString(), + 'Status': 'Confirmed', + 'Source': 'public' + }; + + // Add phone if provided + if (phone && phone.trim()) { + signupData['Phone'] = phone.trim(); + } + + logger.info('Creating shift signup with data:', JSON.stringify(signupData, null, 2)); + const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, signupData); + + // Update shift volunteer count + const newCount = (shift['Current Volunteers'] || 0) + 1; + await nocodbService.update(config.nocodb.shiftsSheetId, id, { + 'Current Volunteers': newCount, + 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' + }); + + // Send confirmation email + const controller = new PublicShiftsController(); + await controller.sendSignupConfirmation(email, name, shift, isNewUser, tempPassword); + + res.json({ + success: true, + message: 'Successfully signed up! Check your email for confirmation and login details.', + isNewUser + }); + + } catch (error) { + logger.error('Error in public signup:', error); + logger.error('Error details:', error.response?.data || error.message); + res.status(500).json({ + success: false, + error: 'Failed to complete signup. Please try again.' + }); + } + } + + generateTempPassword() { + // Generate readable temporary password + const adjectives = ['Blue', 'Green', 'Happy', 'Swift', 'Bright']; + const nouns = ['Tiger', 'Eagle', 'River', 'Mountain', 'Star']; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const num = Math.floor(Math.random() * 100); + return `${adj}${noun}${num}`; + } + + async sendSignupConfirmation(email, name, shift, isNewUser, tempPassword) { + const baseUrl = config.isProduction ? + `https://map.${config.domain}` : + `http://localhost:${config.port}`; + + const shiftDate = new Date(shift.Date); + + // Prepare all variables including optional ones + const variables = { + APP_NAME: process.env.APP_NAME || 'CMlite Map', + USER_NAME: name, + USER_EMAIL: email, + SHIFT_TITLE: shift.Title || 'Untitled Shift', + SHIFT_DATE: shiftDate.toLocaleDateString(), + SHIFT_TIME: `${shift['Start Time'] || ''} - ${shift['End Time'] || ''}`, + SHIFT_LOCATION: shift.Location || 'Location TBD', + SHIFT_DESCRIPTION: shift.Description || '', // Include even if empty + LOGIN_URL: `${baseUrl}/login.html`, + SHIFTS_URL: `${baseUrl}/shifts.html`, + IS_NEW_USER: isNewUser, + TEMP_PASSWORD: tempPassword || '', + TIMESTAMP: new Date().toLocaleString() + }; + + // Log the variables for debugging + logger.info('Email template variables:', JSON.stringify(variables, null, 2)); + + const templateName = isNewUser ? 'public-shift-signup-new' : 'public-shift-signup-existing'; + const { html, text } = await emailTemplates.render(templateName, variables); + + return await sendEmail({ + to: email, + subject: `Shift Signup Confirmation - ${shift.Title}`, + text, + html + }); + } +} + +// Create singleton instance and bind methods +const controller = new PublicShiftsController(); +module.exports = { + getPublicShifts: controller.getPublicShifts.bind(controller), + getShiftById: controller.getShiftById.bind(controller), + publicSignup: controller.publicSignup.bind(controller) +}; \ No newline at end of file diff --git a/map/app/middleware/rateLimiter.js b/map/app/middleware/rateLimiter.js index e0f182f..e9fb3a7 100644 --- a/map/app/middleware/rateLimiter.js +++ b/map/app/middleware/rateLimiter.js @@ -78,11 +78,23 @@ const adminLimiter = rateLimit({ message: 'Rate limit exceeded for admin operations. Please try again later.' }); +// Public signup rate limiter - prevent spam signups +const publicSignupLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Max 5 signups per IP per 15 minutes + keyGenerator, + message: 'Too many signup attempts. Please try again later.', + standardHeaders: true, + legacyHeaders: false, + trustProxy: true +}); + module.exports = { apiLimiter, strictLimiter, authLimiter, tempUserLimiter, conditionalTempLimiter, - adminLimiter + adminLimiter, + publicSignupLimiter }; \ No newline at end of file diff --git a/map/app/public/css/admin/cuts-shifts.css b/map/app/public/css/admin/cuts-shifts.css index 2ee9a5e..43a8d6f 100644 --- a/map/app/public/css/admin/cuts-shifts.css +++ b/map/app/public/css/admin/cuts-shifts.css @@ -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; +} diff --git a/map/app/public/css/admin/responsive.css b/map/app/public/css/admin/responsive.css index 340fe30..d748ab9 100644 --- a/map/app/public/css/admin/responsive.css +++ b/map/app/public/css/admin/responsive.css @@ -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; diff --git a/map/app/public/css/modules/responsive.css b/map/app/public/css/modules/responsive.css index 8398274..af8a021 100644 --- a/map/app/public/css/modules/responsive.css +++ b/map/app/public/css/modules/responsive.css @@ -114,7 +114,7 @@ top: 50%; transform: translateY(-50%); width: auto; - max-width: 60px; + max-width: 80px; flex-direction: column; box-sizing: border-box; } diff --git a/map/app/public/css/public-shifts.css b/map/app/public/css/public-shifts.css new file mode 100644 index 0000000..20ca3df --- /dev/null +++ b/map/app/public/css/public-shifts.css @@ -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)); + } + } +} diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js index 1fdf0eb..9b8d562 100644 --- a/map/app/public/js/admin.js +++ b/map/app/public/js/admin.js @@ -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) {
đ ${escapeHtml(shift.Location || 'TBD')}
đĨ ${signupCount}/${shift['Max Volunteers']} volunteers
${shift.Status || 'Open'}
+${isPublic ? 'đ Public' : 'đ Private'}
+ ${isPublic ? ` +There was a problem loading volunteer opportunities. Please refresh the page or try again later.
+ `; + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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 = '${escapeHtml(description)}
` : ''} +Description: ${escapeHtml(description)}
` : ''} + `; + + // 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 = ` + ${currentText} + + `; + 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 + ? `Welcome to our volunteer team!
+Thank you for signing up! We've created a temporary account for you and sent login details to your email.
+You'll also receive a confirmation email with all the shift details.
` + : `Thank you for signing up! You'll receive an email confirmation shortly with all the details.
`; + + messageDiv.innerHTML = ` + ${message} +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.
++ Once logged in, visit My Shifts to manage all your volunteer signups. +
+Hi {{USER_NAME}},
+ +Thank you for signing up to volunteer! Your account has been created and you're confirmed for:
+ +Date: {{SHIFT_DATE}}
+Time: {{SHIFT_TIME}}
+Location: {{SHIFT_LOCATION}}
+Email: {{USER_EMAIL}}
+Temporary Password: {{TEMP_PASSWORD}}
This is a temporary account that expires after your shift.
+Once logged in, visit My Shifts to view or cancel your signup.
+