New public shifts system
This commit is contained in:
parent
960bd39e21
commit
a026af5b48
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
- <20> Calendar integration (Google, Outlook, Apple) for shift export
|
||||
- <20>👥 Admin shift creation and management with volunteer assignment
|
||||
- <20> **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
|
||||
- <20>👨💼 User management panel for admin users (create, delete users)
|
||||
- <20> **Admin broadcast emailing** - Rich HTML email composer with live preview
|
||||
- <20>🔐 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)
|
||||
|
||||
271
map/app/controllers/publicShiftsController.js
Normal file
271
map/app/controllers/publicShiftsController.js
Normal file
@ -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)
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: auto;
|
||||
max-width: 60px;
|
||||
max-width: 80px;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
524
map/app/public/css/public-shifts.css
Normal file
524
map/app/public/css/public-shifts.css
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -959,16 +959,6 @@ async function loadWalkSheetConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
console.error('Failed to load config:', data.error);
|
||||
@ -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, "'")}'>Manage Volunteers</button>
|
||||
@ -1226,6 +1228,14 @@ function setupShiftActionListeners() {
|
||||
const shiftData = JSON.parse(e.target.getAttribute('data-shift').replace(/'/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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -163,8 +163,14 @@ 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>
|
||||
<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>
|
||||
`;
|
||||
|
||||
557
map/app/public/js/public-shifts.js
Normal file
557
map/app/public/js/public-shifts.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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);
|
||||
}
|
||||
99
map/app/public/public-shifts.html
Normal file
99
map/app/public/public-shifts.html
Normal 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">×</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">×</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>
|
||||
@ -12,6 +12,7 @@ const qrRoutes = require('./qr');
|
||||
const debugRoutes = require('./debug');
|
||||
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
|
||||
const shiftsRoutes = require('./shifts');
|
||||
const publicRoutes = require('./public');
|
||||
const externalDataRoutes = require('./external');
|
||||
const cutsRoutes = require('./cuts');
|
||||
const listmonkRoutes = require('./listmonk');
|
||||
@ -34,6 +35,9 @@ module.exports = (app) => {
|
||||
// Auth routes (no auth required)
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Public routes (no auth required)
|
||||
app.use('/api/public', publicRoutes);
|
||||
|
||||
// Public config endpoint
|
||||
app.get('/api/config/start-location', require('../controllers/settingsController').getPublicStartLocation);
|
||||
|
||||
|
||||
11
map/app/routes/public.js
Normal file
11
map/app/routes/public.js
Normal file
@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const publicShiftsController = require('../controllers/publicShiftsController');
|
||||
const { apiLimiter, publicSignupLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// Public routes - no authentication required
|
||||
router.get('/shifts', apiLimiter, publicShiftsController.getPublicShifts);
|
||||
router.get('/shifts/:id', apiLimiter, publicShiftsController.getShiftById);
|
||||
router.post('/shifts/:id/signup', publicSignupLimiter, publicShiftsController.publicSignup);
|
||||
|
||||
module.exports = router;
|
||||
11
map/app/routes/publicShifts.js
Normal file
11
map/app/routes/publicShifts.js
Normal file
@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const publicShiftsController = require('../controllers/publicShiftsController');
|
||||
const { rateLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// Public routes - no auth required
|
||||
router.get('/shifts', rateLimiter.apiLimiter, publicShiftsController.getPublicShifts);
|
||||
router.get('/shifts/:id', rateLimiter.apiLimiter, publicShiftsController.getShiftById);
|
||||
router.post('/shifts/:id/signup', rateLimiter.strictLimiter, publicShiftsController.publicSignup);
|
||||
|
||||
module.exports = router;
|
||||
@ -30,16 +30,30 @@ class EmailTemplateService {
|
||||
}
|
||||
}
|
||||
|
||||
renderTemplate(template, variables) {
|
||||
let rendered = template;
|
||||
processTemplate(template, variables) {
|
||||
if (!template) return '';
|
||||
|
||||
// Replace all {{VARIABLE}} with actual values
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
rendered = rendered.replace(regex, value || '');
|
||||
let processed = template;
|
||||
|
||||
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
|
||||
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
|
||||
const value = variables[varName];
|
||||
// Check if value exists and is not empty string
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
// Recursively process the content inside the conditional block
|
||||
return this.processTemplate(content, variables);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
return rendered;
|
||||
// Replace variables {{VARIABLE}}
|
||||
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
// Return the value or empty string if undefined
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async render(templateName, variables) {
|
||||
@ -50,9 +64,10 @@ class EmailTemplateService {
|
||||
this.loadTemplate(templateName, 'txt')
|
||||
]);
|
||||
|
||||
// Use processTemplate which handles conditionals properly
|
||||
return {
|
||||
html: this.renderTemplate(htmlTemplate, variables),
|
||||
text: this.renderTemplate(textTemplate, variables)
|
||||
html: this.processTemplate(htmlTemplate, variables),
|
||||
text: this.processTemplate(textTemplate, variables)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to render email template:', error);
|
||||
|
||||
@ -102,10 +102,44 @@ class NocoDBService {
|
||||
}
|
||||
|
||||
// Create record
|
||||
async create(tableId, data) {
|
||||
const url = this.getTableUrl(tableId);
|
||||
const response = await this.client.post(url, data);
|
||||
async create(sheetId, data) {
|
||||
try {
|
||||
// Explicitly remove any ID field to prevent NocoDB conflicts
|
||||
const cleanData = { ...data };
|
||||
|
||||
// Remove all possible ID field variations
|
||||
delete cleanData.ID;
|
||||
delete cleanData.id;
|
||||
delete cleanData.Id;
|
||||
delete cleanData.iD;
|
||||
|
||||
// Remove any undefined values to prevent issues
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === undefined) {
|
||||
delete cleanData[key];
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Creating record in sheet ${sheetId}`);
|
||||
logger.info(`Data being sent to NocoDB:`, JSON.stringify(cleanData, null, 2));
|
||||
|
||||
const url = this.getTableUrl(sheetId);
|
||||
const response = await this.client.post(url, cleanData);
|
||||
|
||||
logger.info(`Create response status: ${response.status}`);
|
||||
logger.info(`Create response:`, JSON.stringify(response.data, null, 2));
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Error creating record:', error);
|
||||
logger.error('Error response data:', JSON.stringify(error.response?.data, null, 2));
|
||||
logger.error('Error response status:', error.response?.status);
|
||||
logger.error('Error response headers:', JSON.stringify(error.response?.headers, null, 2));
|
||||
logger.error('Request URL:', error.config?.url);
|
||||
logger.error('Request method:', error.config?.method);
|
||||
logger.error('Request data:', JSON.stringify(error.config?.data, null, 2));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update record
|
||||
|
||||
182
map/app/templates/email/public-shift-signup-existing.html
Normal file
182
map/app/templates/email/public-shift-signup-existing.html
Normal file
@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shift Signup Confirmation</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #28a745;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #28a745;
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
background: #d4edda;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #28a745;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.shift-details {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.shift-details h2 {
|
||||
color: #28a745;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
min-width: 100px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.secondary-link {
|
||||
color: #28a745;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.secondary-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.important {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.important strong {
|
||||
color: #0c5460;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Shift Signup Confirmed!</h1>
|
||||
</div>
|
||||
|
||||
<div class="welcome-message">
|
||||
<p><strong>Hi {{USER_NAME}},</strong></p>
|
||||
<p>Great news! You've been successfully signed up for another volunteer shift with {{APP_NAME}}.</p>
|
||||
</div>
|
||||
|
||||
<div class="shift-details">
|
||||
<h2>📅 {{SHIFT_TITLE}}</h2>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">📅 Date:</span>
|
||||
<span>{{SHIFT_DATE}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">⏰ Time:</span>
|
||||
<span>{{SHIFT_TIME}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">📍 Location:</span>
|
||||
<span>{{SHIFT_LOCATION}}</span>
|
||||
</div>
|
||||
{{#if SHIFT_DESCRIPTION}}
|
||||
<div style="margin-top: 15px;">
|
||||
<strong>Details:</strong><br>
|
||||
{{SHIFT_DESCRIPTION}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="important">
|
||||
<strong>What's Next?</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>Mark your calendar for {{SHIFT_DATE}} at {{SHIFT_TIME}}</li>
|
||||
<li>Login to your existing account using your current credentials</li>
|
||||
<li>You can view or cancel your signup anytime from your account</li>
|
||||
<li>We'll send you reminder details closer to the shift date</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{LOGIN_URL}}" class="button">🔐 Login to Your Account</a>
|
||||
<br>
|
||||
<p style="margin-top: 15px;">
|
||||
Once logged in, visit <a href="{{SHIFTS_URL}}" class="secondary-link">My Shifts</a> to manage all your volunteer signups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Thank you for continuing to volunteer with {{APP_NAME}}!</p>
|
||||
<p>Questions? Reply to this email and we'll help you out.</p>
|
||||
<p><small>Sent on {{TIMESTAMP}}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
map/app/templates/email/public-shift-signup-existing.txt
Normal file
29
map/app/templates/email/public-shift-signup-existing.txt
Normal file
@ -0,0 +1,29 @@
|
||||
Hi {{USER_NAME}},
|
||||
|
||||
✅ SHIFT SIGNUP CONFIRMED!
|
||||
|
||||
Great news! You've been successfully signed up for another volunteer shift with {{APP_NAME}}.
|
||||
|
||||
📅 SHIFT DETAILS
|
||||
▸ Event: {{SHIFT_TITLE}}
|
||||
▸ Date: {{SHIFT_DATE}}
|
||||
▸ Time: {{SHIFT_TIME}}
|
||||
▸ Location: {{SHIFT_LOCATION}}
|
||||
{{#if SHIFT_DESCRIPTION}}
|
||||
▸ Details: {{SHIFT_DESCRIPTION}}
|
||||
{{/if}}
|
||||
|
||||
ℹ️ You can use your existing account credentials to:
|
||||
• Login at: {{LOGIN_URL}}
|
||||
• View all your shift signups at: {{SHIFTS_URL}}
|
||||
• Cancel or modify your signups anytime from your account
|
||||
|
||||
Need help? Reply to this email or contact our volunteer coordinator.
|
||||
|
||||
Thank you for your continued support!
|
||||
|
||||
---
|
||||
{{APP_NAME}} Volunteer Team
|
||||
Sent: {{TIMESTAMP}}
|
||||
|
||||
This is an automated message. Please do not reply directly to this email.
|
||||
36
map/app/templates/email/public-shift-signup-new.html
Normal file
36
map/app/templates/email/public-shift-signup-new.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
/* ...email styles... */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Welcome & Shift Confirmation</h1>
|
||||
|
||||
<p>Hi {{USER_NAME}},</p>
|
||||
|
||||
<p>Thank you for signing up to volunteer! Your account has been created and you're confirmed for:</p>
|
||||
|
||||
<div class="shift-details">
|
||||
<h2>{{SHIFT_TITLE}}</h2>
|
||||
<p><strong>Date:</strong> {{SHIFT_DATE}}</p>
|
||||
<p><strong>Time:</strong> {{SHIFT_TIME}}</p>
|
||||
<p><strong>Location:</strong> {{SHIFT_LOCATION}}</p>
|
||||
</div>
|
||||
|
||||
<div class="credentials">
|
||||
<h3>Your Login Details</h3>
|
||||
<p><strong>Email:</strong> {{USER_EMAIL}}</p>
|
||||
<p><strong>Temporary Password:</strong> <code>{{TEMP_PASSWORD}}</code></p>
|
||||
<p class="note">This is a temporary account that expires after your shift.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{LOGIN_URL}}" class="button">Login to Your Account</a>
|
||||
<p>Once logged in, visit <a href="{{SHIFTS_URL}}">My Shifts</a> to view or cancel your signup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
map/app/templates/email/public-shift-signup-new.txt
Normal file
33
map/app/templates/email/public-shift-signup-new.txt
Normal file
@ -0,0 +1,33 @@
|
||||
Hi {{USER_NAME}},
|
||||
|
||||
✅ SHIFT SIGNUP CONFIRMATION
|
||||
|
||||
Thank you for signing up to volunteer with {{APP_NAME}}! Your account has been created and you're confirmed for:
|
||||
|
||||
📅 SHIFT DETAILS
|
||||
▸ Event: {{SHIFT_TITLE}}
|
||||
▸ Date: {{SHIFT_DATE}}
|
||||
▸ Time: {{SHIFT_TIME}}
|
||||
▸ Location: {{SHIFT_LOCATION}}
|
||||
{{#if SHIFT_DESCRIPTION}}
|
||||
▸ Details: {{SHIFT_DESCRIPTION}}
|
||||
{{/if}}
|
||||
|
||||
🔑 YOUR LOGIN CREDENTIALS
|
||||
▸ Email: {{USER_EMAIL}}
|
||||
▸ Temporary Password: {{TEMP_PASSWORD}}
|
||||
|
||||
ℹ️ This is a temporary account that expires after your shift. Keep these credentials safe!
|
||||
|
||||
WHAT'S NEXT:
|
||||
• Mark your calendar for {{SHIFT_DATE}} at {{SHIFT_TIME}}
|
||||
• Login at: {{LOGIN_URL}}
|
||||
• Manage your signups at: {{SHIFTS_URL}}
|
||||
• We'll send reminder details closer to the shift date
|
||||
|
||||
Questions? Reply to this email and we'll help you out.
|
||||
|
||||
Thank you for volunteering with {{APP_NAME}}!
|
||||
|
||||
---
|
||||
Sent on {{TIMESTAMP}}
|
||||
@ -6,13 +6,13 @@
|
||||
#
|
||||
# Creates six tables:
|
||||
# 1. locations - Main table with GeoData, proper field types per README.md
|
||||
# 2. login - Simple authentication table with Email, Name, Admin fields
|
||||
# 2. login - Simple authentication table with Email, Name, Admin fields and temp user support
|
||||
# 3. settings - Configuration table with text fields only (no QR image storage)
|
||||
# 4. shifts - Table for volunteer shift scheduling
|
||||
# 5. shift_signups - Table for tracking signups to shifts
|
||||
# 4. shifts - Table for volunteer shift scheduling with public visibility support
|
||||
# 5. shift_signups - Table for tracking signups to shifts with source tracking and phone numbers
|
||||
# 6. cuts - Table for storing polygon overlays for the map
|
||||
#
|
||||
# Updated: July 2025 - Always creates a new base, does not touch existing data
|
||||
# Updated: August 2025 - Added public shift support, signup source tracking, phone numbers
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
@ -429,6 +429,19 @@ create_login_table() {
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"column_name": "Created Via",
|
||||
"title": "Created Via",
|
||||
"uidt": "SingleSelect",
|
||||
"rqd": false,
|
||||
"colOptions": {
|
||||
"options": [
|
||||
{"title": "admin", "color": "#2196F3"},
|
||||
{"title": "public_shift_signup", "color": "#FF9800"},
|
||||
{"title": "standard", "color": "#4CAF50"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"column_name": "ExpiresAt",
|
||||
"title": "Expires At",
|
||||
@ -672,6 +685,12 @@ create_shifts_table() {
|
||||
"title": "Updated At",
|
||||
"uidt": "DateTime",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "is_public",
|
||||
"title": "Is Public",
|
||||
"uidt": "Checkbox",
|
||||
"rqd": false
|
||||
}
|
||||
]
|
||||
}'
|
||||
@ -718,6 +737,12 @@ create_shift_signups_table() {
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "user_phone",
|
||||
"title": "User Phone",
|
||||
"uidt": "PhoneNumber",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "signup_date",
|
||||
"title": "Signup Date",
|
||||
@ -735,6 +760,19 @@ create_shift_signups_table() {
|
||||
{"title": "Cancelled", "color": "#F44336"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"column_name": "signup_source",
|
||||
"title": "Signup Source",
|
||||
"uidt": "SingleSelect",
|
||||
"rqd": false,
|
||||
"colOptions": {
|
||||
"options": [
|
||||
{"title": "authenticated", "color": "#2196F3"},
|
||||
{"title": "public", "color": "#FF9800"},
|
||||
{"title": "admin", "color": "#9C27B0"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
@ -68,10 +68,12 @@ Controller for application settings, start location, and walk sheet config.
|
||||
|
||||
# app/controllers/shiftsController.js
|
||||
|
||||
# app/controllers/shiftsController.js
|
||||
|
||||
Controller for handling shift management, signup/cancellation, and admin operations on volunteer shifts. Includes comprehensive email functionality for sending shift details to all volunteers with professional HTML templates, user management within shifts, and calendar integration features.
|
||||
|
||||
# app/controllers/publicShiftsController.js
|
||||
|
||||
Controller for handling public-facing shift signup functionality. Manages public shift viewing, temporary user account creation during signup, email confirmation for public signups, and integration with the existing shift system. Includes rate limiting, validation, and automated email notifications for new volunteers.
|
||||
|
||||
# app/controllers/dataConvertController.js
|
||||
|
||||
Controller for handling CSV upload and batch geocoding of addresses. Parses CSV files, validates address data, uses the geocoding service to get coordinates, and provides real-time progress updates via Server-Sent Events (SSE). Enhanced with comprehensive error logging and downloadable processing reports that include both successful and failed geocoding attempts for review and debugging.
|
||||
@ -172,6 +174,22 @@ HTML email template for admin broadcasting messages to all users. Features rich
|
||||
|
||||
Plain text email template for admin broadcast messages to all users. Provides clean, accessible format for mass communication with proper formatting and sender information.
|
||||
|
||||
# app/templates/email/public-shift-signup-new.html
|
||||
|
||||
HTML email template for public shift signup confirmation sent to new users. Features responsive design with shift details, temporary account credentials, login instructions, and professional styling for volunteer onboarding experience.
|
||||
|
||||
# app/templates/email/public-shift-signup-new.txt
|
||||
|
||||
Plain text email template for public shift signup confirmation sent to new users. Contains shift details, temporary account credentials, and login instructions in accessible format.
|
||||
|
||||
# app/templates/email/public-shift-signup-existing.html
|
||||
|
||||
HTML email template for public shift signup confirmation sent to existing users. Features responsive design with shift details and account access instructions for returning volunteers.
|
||||
|
||||
# app/templates/email/public-shift-signup-existing.txt
|
||||
|
||||
Plain text email template for public shift signup confirmation sent to existing users. Contains shift details and account access instructions in accessible format.
|
||||
|
||||
# app/utils/helpers.js
|
||||
|
||||
Utility functions for geographic data, validation, and helpers used across the backend.
|
||||
@ -328,6 +346,10 @@ Styles for temporary user restrictions and visual indicators. Includes temp user
|
||||
|
||||
CSS styles for the volunteer shifts page, including grid view, calendar view, and view toggle functionality.
|
||||
|
||||
# app/public/css/public-shifts.css
|
||||
|
||||
CSS styles for the public volunteer opportunity signup page. Includes responsive design, shift card layouts, signup modal styling, success confirmations, loading states, and mobile-optimized interfaces for public users.
|
||||
|
||||
# app/public/css/style.css
|
||||
|
||||
Main stylesheet that imports all modular CSS files from the `public/css/modules/` directory. Acts as the central entry point for all application styles, organizing them into logical modules for better maintainability. Referenced in all HTML files to load the complete application styling system.
|
||||
@ -348,6 +370,10 @@ Login page HTML for user authentication.
|
||||
|
||||
Volunteer shifts management and signup page HTML with both grid and calendar view options.
|
||||
|
||||
# app/public/public-shifts.html
|
||||
|
||||
Public-facing volunteer opportunity signup page that doesn't require authentication. Features shift browsing, detailed information display, signup modal with account creation, and success confirmation flow for new volunteers.
|
||||
|
||||
# app/public/user.html
|
||||
|
||||
User profile page HTML for displaying user information and account management.
|
||||
@ -408,6 +434,10 @@ Backup or legacy version of the main map JavaScript logic.
|
||||
|
||||
JavaScript for volunteer shift signup, management, and UI logic with both grid and calendar view functionality. Features include view toggling, calendar navigation, shift color-coding, interactive shift popups, calendar integration (Google, Outlook, Apple), and enhanced user experience with dropdown actions. Updated to use shift titles directly from signup records with improved event handling.
|
||||
|
||||
# app/public/js/public-shifts.js
|
||||
|
||||
JavaScript for the public volunteer opportunity signup page. Handles shift loading, filtering, signup modal management, form validation, API communication for public signups, success confirmations, and direct shift linking. Includes responsive design support and error handling for public users.
|
||||
|
||||
# app/public/js/ui-controls.js
|
||||
|
||||
JavaScript for UI controls, event handlers, and user interaction logic. Includes address confirmation functionality that manages state for ensuring users confirm geocoded addresses before saving locations.
|
||||
@ -524,6 +554,10 @@ Express router for application settings endpoints (start location, walk sheet).
|
||||
|
||||
Express router for volunteer shift management endpoints (public and admin).
|
||||
|
||||
# app/routes/public.js
|
||||
|
||||
Express router for public-facing endpoints that don't require authentication. Handles public shift viewing and signup functionality with appropriate rate limiting and validation.
|
||||
|
||||
# app/routes/users.js
|
||||
|
||||
Express router for user management endpoints (list, create, delete users).
|
||||
|
||||
73
map/test-nocodb.js
Normal file
73
map/test-nocodb.js
Normal file
@ -0,0 +1,73 @@
|
||||
// Test NocoDB API directly to debug the issue
|
||||
const axios = require('axios');
|
||||
require('dotenv').config();
|
||||
|
||||
async function testNocoDB() {
|
||||
const apiUrl = process.env.NOCODB_API_URL;
|
||||
const apiToken = process.env.NOCODB_API_TOKEN;
|
||||
const projectId = process.env.NOCODB_PROJECT_ID || 'pp1ijipzj121aqq';
|
||||
const shiftSignupsSheetId = process.env.NOCODB_SHIFT_SIGNUPS_SHEET || 'mocxv7kzcvyo4aa';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: apiUrl,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'xc-token': apiToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Test 1: Try to get table info
|
||||
console.log('Test 1: Getting table info...');
|
||||
try {
|
||||
const url = `/db/data/v1/${projectId}/${shiftSignupsSheetId}?limit=1`;
|
||||
console.log('Request URL:', apiUrl + url);
|
||||
const response = await client.get(url);
|
||||
console.log('Table info response:', JSON.stringify(response.data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error getting table info:', error.response?.data || error.message);
|
||||
}
|
||||
|
||||
// Test 2: Try to create a minimal record
|
||||
console.log('\nTest 2: Creating minimal record...');
|
||||
try {
|
||||
const testData = {
|
||||
'Shift ID': 1,
|
||||
'User Email': 'test@example.com',
|
||||
'User Name': 'Test User',
|
||||
'Status': 'Confirmed'
|
||||
};
|
||||
|
||||
console.log('Test data:', JSON.stringify(testData, null, 2));
|
||||
|
||||
const url = `/db/data/v1/${projectId}/${shiftSignupsSheetId}`;
|
||||
console.log('Create URL:', apiUrl + url);
|
||||
|
||||
const response = await client.post(url, testData);
|
||||
console.log('Create response:', JSON.stringify(response.data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error creating record:');
|
||||
console.error('Status:', error.response?.status);
|
||||
console.error('Data:', JSON.stringify(error.response?.data, null, 2));
|
||||
console.error('Headers:', JSON.stringify(error.response?.headers, null, 2));
|
||||
}
|
||||
|
||||
// Test 3: Check what table fields exist
|
||||
console.log('\nTest 3: Getting table schema...');
|
||||
try {
|
||||
const schemaUrl = `/db/meta/projects/${projectId}/tables`;
|
||||
const response = await client.get(schemaUrl);
|
||||
const tables = response.data.list || [];
|
||||
const signupsTable = tables.find(t => t.id === shiftSignupsSheetId);
|
||||
if (signupsTable) {
|
||||
console.log('Signups table columns:');
|
||||
signupsTable.columns?.forEach(col => {
|
||||
console.log(` - ${col.title} (${col.column_name}) - ${col.uidt} - PK: ${col.pk}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting schema:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
testNocoDB().catch(console.error);
|
||||
Loading…
x
Reference in New Issue
Block a user