New public shifts system
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -958,16 +958,6 @@ async function loadWalkSheetConfig() {
|
||||
console.log(`Set QR ${i} label to:`, labelField.value);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Walk sheet config loaded successfully');
|
||||
|
||||
// Show status message about data source
|
||||
if (data.source) {
|
||||
const sourceText = data.source === 'database' ? 'Walk sheet config loaded from database' :
|
||||
data.source === 'defaults' ? 'Using walk sheet defaults' :
|
||||
'Walk sheet config loaded';
|
||||
showStatus(sourceText, 'info');
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
@@ -1175,6 +1165,7 @@ function displayAdminShifts(shifts) {
|
||||
list.innerHTML = shifts.map(shift => {
|
||||
const shiftDate = createLocalDate(shift.Date);
|
||||
const signupCount = shift.signups ? shift.signups.length : 0;
|
||||
const isPublic = shift['Is Public'] !== false;
|
||||
|
||||
console.log(`Shift "${shift.Title}" (ID: ${shift.ID}) has ${signupCount} volunteers:`, shift.signups?.map(s => s['User Email']) || []);
|
||||
|
||||
@@ -1186,6 +1177,17 @@ function displayAdminShifts(shifts) {
|
||||
<p>📍 ${escapeHtml(shift.Location || 'TBD')}</p>
|
||||
<p>👥 ${signupCount}/${shift['Max Volunteers']} volunteers</p>
|
||||
<p class="status-${(shift.Status || 'open').toLowerCase()}">${shift.Status || 'Open'}</p>
|
||||
<p class="${isPublic ? 'public-shift' : 'private-shift'}">${isPublic ? '🌐 Public' : '🔒 Private'}</p>
|
||||
${isPublic ? `
|
||||
<div class="public-link-section">
|
||||
<label>Public Link:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="public-link-input" value="${generateShiftPublicLink(shift.ID)}" readonly />
|
||||
<button type="button" class="btn btn-secondary btn-sm copy-shift-link-btn" data-shift-id="${shift.ID}">📋</button>
|
||||
<button type="button" class="btn btn-info btn-sm open-shift-link-btn" data-shift-id="${shift.ID}">🔗</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="shift-actions">
|
||||
<button class="btn btn-primary btn-sm manage-volunteers-btn" data-shift-id="${shift.ID}" data-shift='${JSON.stringify(shift).replace(/'/g, "'")}'>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,9 +163,15 @@ class ClientCacheManager {
|
||||
notification.className = 'update-notification';
|
||||
notification.innerHTML = `
|
||||
<div class="update-notification-content">
|
||||
<span class="update-message">🔄 A new version is available!</span>
|
||||
<button class="update-button" onclick="cacheManager.forceReload()">Reload Now</button>
|
||||
<button class="update-dismiss" onclick="this.closest('.update-notification').remove()">×</button>
|
||||
<span class="update-message">
|
||||
🔄 A new version is available!<br>
|
||||
Please clear your browser cache and cookies, then refresh this page.<br>
|
||||
<small>
|
||||
<b>Instructions:</b> On most browsers, press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd> (Windows) or <kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd> (Mac) to force refresh.<br>
|
||||
For best results, consider deleting browsing data via your browser settings.
|
||||
</small>
|
||||
</span>
|
||||
<button class="update-dismiss" onclick="this.closest('.update-notification').remove()">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
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);
|
||||
return response.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}}
|
||||
Reference in New Issue
Block a user