From e95bc8883e427d0eb63b3503c59f69668d0b24f7 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Sun, 1 Mar 2026 15:22:27 -0700 Subject: [PATCH] scheduling features --- .env.example | 8 +- admin/index.html | 28 + admin/src/App.tsx | 19 + admin/src/components/AppLayout.tsx | 21 +- admin/src/components/ErrorBoundary.tsx | 113 +++ admin/src/components/FeatureGate.tsx | 3 +- admin/src/components/GrapesJSEditor.tsx | 22 + .../components/calendar/UnifiedCalendar.tsx | 32 +- .../scheduling/SchedulingPollWidget.tsx | 586 +++++++++++++++ admin/src/main.tsx | 39 +- admin/src/pages/DocsPage.tsx | 51 +- admin/src/pages/MeetingPlannerPage.tsx | 709 ++++++++++++++++++ admin/src/pages/SettingsPage.tsx | 5 +- admin/src/pages/public/LandingPage.tsx | 39 + admin/src/pages/public/PollsListPage.tsx | 139 ++++ admin/src/pages/public/SchedulingPollPage.tsx | 501 +++++++++++++ admin/src/pages/sms/SmsCampaignsPage.tsx | 73 +- admin/src/pages/sms/SmsContactsPage.tsx | 349 ++++++--- admin/src/pages/sms/SmsSetupPage.tsx | 8 +- admin/src/pages/sms/SmsTemplatesPage.tsx | 130 ++-- admin/src/types/api.ts | 115 ++- admin/tsconfig.tsbuildinfo | 2 +- .../migration.sql | 127 ++++ api/prisma/schema.prisma | 109 +++ api/prisma/seed.ts | 17 + .../events/unified-calendar.service.ts | 61 +- .../meeting-planner.rate-limits.ts | 37 + .../meeting-planner/meeting-planner.routes.ts | 192 +++++ .../meeting-planner.schemas.ts | 77 ++ .../meeting-planner.service.ts | 491 ++++++++++++ api/src/modules/settings/settings.schemas.ts | 1 + .../sms/contacts/sms-contacts.routes.ts | 10 +- .../sms/contacts/sms-contacts.schemas.ts | 9 + .../sms/contacts/sms-contacts.service.ts | 26 + api/src/server.ts | 3 + campaign_connector | 2 +- mkdocs/docs/assets/js/scheduling-poll.js | 189 +++++ mkdocs/docs/docs/admin/broadcast/sms.md | 62 +- .../getting-started/environment-variables.md | 81 ++ mkdocs/mkdocs.yml | 1 + 40 files changed, 4267 insertions(+), 220 deletions(-) create mode 100644 admin/src/components/ErrorBoundary.tsx create mode 100644 admin/src/components/scheduling/SchedulingPollWidget.tsx create mode 100644 admin/src/pages/MeetingPlannerPage.tsx create mode 100644 admin/src/pages/public/PollsListPage.tsx create mode 100644 admin/src/pages/public/SchedulingPollPage.tsx create mode 100644 api/prisma/migrations/20260301173914_add_meeting_planner/migration.sql create mode 100644 api/src/modules/meeting-planner/meeting-planner.rate-limits.ts create mode 100644 api/src/modules/meeting-planner/meeting-planner.routes.ts create mode 100644 api/src/modules/meeting-planner/meeting-planner.schemas.ts create mode 100644 api/src/modules/meeting-planner/meeting-planner.service.ts create mode 100644 mkdocs/docs/assets/js/scheduling-poll.js diff --git a/.env.example b/.env.example index 3de5e4a3..f5565e26 100644 --- a/.env.example +++ b/.env.example @@ -329,13 +329,15 @@ ALERTMANAGER_EMBED_PORT=8895 # --- SMS Campaigns (Termux Android Bridge) --- # ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative +# URL + API key are typically managed via admin Settings page (DB overrides env) +# Use Tailscale IP (100.x.x.x) for stable addressing across networks ENABLE_SMS=false -TERMUX_API_URL=http://10.0.0.193:5001 +TERMUX_API_URL=http://100.x.x.x:5001 TERMUX_API_KEY= SMS_DELAY_BETWEEN_MS=3000 SMS_MAX_RETRIES=3 -SMS_RESPONSE_SYNC_INTERVAL_MS=30000 -SMS_DEVICE_MONITOR_INTERVAL_MS=30000 +SMS_RESPONSE_SYNC_INTERVAL_MS=120000 +SMS_DEVICE_MONITOR_INTERVAL_MS=300000 # --- Monitoring (only used with --profile monitoring) --- PROMETHEUS_PORT=9090 diff --git a/admin/index.html b/admin/index.html index 69d1f3b0..358243e0 100644 --- a/admin/index.html +++ b/admin/index.html @@ -14,6 +14,34 @@
+ + diff --git a/admin/src/App.tsx b/admin/src/App.tsx index f64d85e3..82cf6c96 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -115,6 +115,9 @@ import SocialDashboardPage from '@/pages/social/SocialDashboardPage'; import SocialGraphPage from '@/pages/social/SocialGraphPage'; import SocialModerationPage from '@/pages/social/SocialModerationPage'; import MeetingJoinPage from '@/pages/public/MeetingJoinPage'; +import MeetingPlannerPage from '@/pages/MeetingPlannerPage'; +import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; +import PollsListPage from '@/pages/public/PollsListPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage'; import NotFoundPage from '@/pages/NotFoundPage'; import CommandPalette from '@/components/command-palette/CommandPalette'; @@ -225,6 +228,14 @@ export default function App() { }> } /> + {/* Scheduling polls — feature-gated */} + }> + } /> + + }> + } /> + + {/* Public meeting join page — feature-gated */} }> } /> @@ -674,6 +685,14 @@ export default function App() { } /> + + + + } + /> , label: 'Locations' }, { key: '/app/map/data-quality', icon: , label: 'Data Quality' }, - { key: '/app/map/shifts', icon: , label: 'Shifts' }, { key: '/app/map/cuts', icon: , label: 'Areas' }, { key: '/app/map/canvass', icon: , label: 'Canvassing' }, { key: '/app/map/settings', icon: , label: 'Settings' }, @@ -257,6 +257,25 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS }); } + // Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled + if (settings?.enableMap !== false || settings?.enableMeetingPlanner) { + const schedulingChildren: any[] = []; + if (settings?.enableMap !== false) { + schedulingChildren.push({ key: '/app/map/shifts', icon: , label: 'Shifts' }); + } + if (settings?.enableMeetingPlanner) { + schedulingChildren.push({ key: '/app/meeting-planner', icon: , label: 'Meeting Planner' }); + } + if (schedulingChildren.length > 0) { + items.push({ + key: 'scheduling-submenu', + icon: , + label: 'Scheduling', + children: schedulingChildren, + }); + } + } + if (settings?.enableMediaFeatures !== false) { items.push({ key: 'media-submenu', diff --git a/admin/src/components/ErrorBoundary.tsx b/admin/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..1ae345a8 --- /dev/null +++ b/admin/src/components/ErrorBoundary.tsx @@ -0,0 +1,113 @@ +import { Component, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + isChunkError: boolean; +} + +/** + * Global error boundary that catches React rendering crashes. + * Detects stale chunk errors (after redeployment) and offers auto-reload. + */ +export default class ErrorBoundary extends Component { + state: State = { hasError: false, isChunkError: false }; + + static getDerivedStateFromError(error: Error): State { + const isChunkError = isChunkLoadError(error); + return { hasError: true, isChunkError }; + } + + componentDidCatch(error: Error) { + // If it's a stale chunk error, auto-reload once (avoid infinite loop via sessionStorage flag) + if (isChunkLoadError(error)) { + const reloadKey = 'cm_chunk_reload'; + const lastReload = sessionStorage.getItem(reloadKey); + const now = Date.now(); + // Only auto-reload if we haven't done so in the last 10 seconds + if (!lastReload || now - parseInt(lastReload, 10) > 10000) { + sessionStorage.setItem(reloadKey, String(now)); + window.location.reload(); + return; + } + } + } + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ {this.state.isChunkError ? '\u21BB' : '\u26A0'} +
+

+ {this.state.isChunkError + ? 'Application Updated' + : 'Something went wrong'} +

+

+ {this.state.isChunkError + ? 'A new version has been deployed. Please refresh to load the latest version.' + : 'An unexpected error occurred. A page refresh usually fixes this.'} +

+ +
+ ); + } + + return this.props.children; + } +} + +/** Detect chunk/module load errors (stale deployments) */ +function isChunkLoadError(error: Error): boolean { + const msg = error.message || ''; + return ( + msg.includes('Failed to fetch dynamically imported module') || + msg.includes('Loading chunk') || + msg.includes('Loading CSS chunk') || + msg.includes('error loading dynamically imported module') || + msg.includes('Importing a module script failed') || + // Vite-specific: chunk not found + msg.includes('is not a valid JavaScript MIME type') || + // Generic syntax errors from loading wrong content (e.g., HTML 404 page as JS) + (error.name === 'SyntaxError' && msg.includes('Unexpected token')) + ); +} diff --git a/admin/src/components/FeatureGate.tsx b/admin/src/components/FeatureGate.tsx index e49d16b6..f6ea09c0 100644 --- a/admin/src/components/FeatureGate.tsx +++ b/admin/src/components/FeatureGate.tsx @@ -19,10 +19,11 @@ const FEATURE_LABELS: Record = { enableEvents: 'Events', enableSocial: 'Social Connections', enableMeet: 'Video Meetings', + enableMeetingPlanner: 'Meeting Planner', }; interface FeatureGateProps { - feature: keyof Pick; + feature: keyof Pick; children: ReactNode; } diff --git a/admin/src/components/GrapesJSEditor.tsx b/admin/src/components/GrapesJSEditor.tsx index 1ee42dbb..7e16a16c 100644 --- a/admin/src/components/GrapesJSEditor.tsx +++ b/admin/src/components/GrapesJSEditor.tsx @@ -549,6 +549,28 @@ function generateBlockHtml(type: string, defaults: Record): str `; } + case 'scheduling-poll': { + const pollSlug = (defaults.pollSlug as string) || ''; + const showComments = defaults.showComments !== false; + const title = (defaults.title as string) || 'Vote on a Meeting Time'; + return ` +
+
+
+ + + +

${title}

+

${pollSlug || 'Set poll slug in block properties'}

+

Poll will render on published page

+
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/calendar/UnifiedCalendar.tsx b/admin/src/components/calendar/UnifiedCalendar.tsx index 70dac2d5..6784f8ff 100644 --- a/admin/src/components/calendar/UnifiedCalendar.tsx +++ b/admin/src/components/calendar/UnifiedCalendar.tsx @@ -153,10 +153,11 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi return (
{visible.map(item => { + const isPoll = item.type === 'poll'; const isShift = item.type === 'shift'; - const bg = isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)'; - const border = isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)'; - const accent = isShift ? '#1890ff' : '#52c41a'; + const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)'; + const border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)'; + const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a'; return (
{ const isShift = item.type === 'shift'; + const isPoll = item.type === 'poll'; const spotsLeft = isShift && item.maxVolunteers ? item.maxVolunteers - (item.currentVolunteers || 0) : null; @@ -202,13 +204,16 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0 ? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100) : 0; + const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a'; + const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green'; + const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event'; return ( @@ -221,8 +226,8 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi )} - - {isShift ? 'Shift' : 'Event'} + + {tagLabel}
@@ -286,7 +291,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi )} - {!isShift && item.gancioUrl && ( + {!isShift && !isPoll && item.gancioUrl && ( + )} ); diff --git a/admin/src/components/scheduling/SchedulingPollWidget.tsx b/admin/src/components/scheduling/SchedulingPollWidget.tsx new file mode 100644 index 00000000..62f9fd78 --- /dev/null +++ b/admin/src/components/scheduling/SchedulingPollWidget.tsx @@ -0,0 +1,586 @@ +/** + * Self-contained scheduling poll widget for GrapesJS landing pages. + * Rendered via createRoot() outside the App's ConfigProvider — uses inline styles only, no Ant Design. + * Follows the same pattern as CampaignFormWidget, DonationWidget, etc. + */ +import { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +const apiBase = '/api'; + +// Theme colors matching the dark public pages +const COLORS = { + bg: '#0d1b2a', + card: '#1b2838', + cardAlt: '#243447', + primary: '#fa8c16', + primaryHover: '#d46b08', + text: '#fff', + textMuted: 'rgba(255,255,255,0.65)', + border: 'rgba(255,255,255,0.15)', + success: '#52c41a', + error: '#ff4d4f', + yes: '#52c41a', + ifNeedBe: '#faad14', + no: '#d9d9d9', +}; + +const STATUS_COLORS: Record = { + OPEN: '#52c41a', + CLOSED: '#fa8c16', + FINALIZED: '#1890ff', + CANCELLED: '#ff4d4f', +}; + +const STATUS_LABELS: Record = { + OPEN: 'Open', + CLOSED: 'Closed', + FINALIZED: 'Finalized', + CANCELLED: 'Cancelled', +}; + +const VOTE_LABELS: Record = { + YES: 'Yes', + IF_NEED_BE: 'If Need Be', + NO: 'No', +}; + +const VOTE_COLORS: Record = { + YES: COLORS.yes, + IF_NEED_BE: COLORS.ifNeedBe, + NO: COLORS.no, +}; + +interface PollOption { + id: string; + date: string; + startTime: string; + endTime: string; + yesCount?: number; + ifNeedBeCount?: number; + noCount?: number; + score?: number; +} + +interface PollVoter { + name: string; + votes: Record; +} + +interface PollComment { + id: string; + authorName: string; + content: string; + createdAt: string; +} + +interface PollData { + id: string; + slug: string; + title: string; + description: string | null; + location: string | null; + status: string; + timezone: string; + votingDeadline: string | null; + finalizedOptionId: string | null; + finalizedOption: PollOption | null; + allowAnonymous: boolean; + createdBy?: { name: string | null; email: string }; + options: PollOption[]; + voters: PollVoter[]; + comments: PollComment[]; +} + +interface SchedulingPollWidgetProps { + pollSlug: string; + showComments?: boolean; + title?: string; +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 14px', + border: `1px solid ${COLORS.border}`, + borderRadius: 6, + background: 'rgba(255,255,255,0.05)', + color: COLORS.text, + fontSize: 14, + outline: 'none', + boxSizing: 'border-box', +}; + +const btnStyle: React.CSSProperties = { + padding: '10px 24px', + background: COLORS.primary, + color: '#fff', + border: 'none', + borderRadius: 6, + fontWeight: 600, + fontSize: 14, + cursor: 'pointer', + width: '100%', +}; + +function formatDate(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); +} + +function formatDateTime(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +const VOTER_TOKEN_KEY = 'poll_voter_token_'; + +export function SchedulingPollWidget({ pollSlug, showComments = true, title }: SchedulingPollWidgetProps) { + const [poll, setPoll] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Vote form + const [voterName, setVoterName] = useState(''); + const [voterEmail, setVoterEmail] = useState(''); + const [votes, setVotes] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [hasVoted, setHasVoted] = useState(false); + const [submitMsg, setSubmitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Comment form + const [commentName, setCommentName] = useState(''); + const [commentContent, setCommentContent] = useState(''); + const [commentSubmitting, setCommentSubmitting] = useState(false); + + const fetchPoll = useCallback(async () => { + try { + const { data } = await axios.get(`${apiBase}/meeting-planner/public/${pollSlug}`); + setPoll(data); + + // Check for stored voter token + const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug); + if (storedToken) { + setHasVoted(true); + } + } catch { + setError('Poll not found or unavailable'); + } finally { + setLoading(false); + } + }, [pollSlug]); + + useEffect(() => { fetchPoll(); }, [fetchPoll]); + + const handleVoteChange = (optionId: string, value: string) => { + setVotes((prev) => ({ ...prev, [optionId]: value })); + }; + + const handleSubmitVotes = async () => { + if (!poll || !voterName.trim()) { + setSubmitMsg({ type: 'error', text: 'Please enter your name' }); + return; + } + if (!Object.keys(votes).length) { + setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' }); + return; + } + + setSubmitting(true); + setSubmitMsg(null); + try { + const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug); + const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, { + voterName: voterName.trim(), + voterEmail: voterEmail.trim() || undefined, + voterToken: storedToken || undefined, + votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })), + }); + + if (data.voterToken) { + localStorage.setItem(VOTER_TOKEN_KEY + pollSlug, data.voterToken); + } + + setSubmitMsg({ type: 'success', text: hasVoted ? 'Votes updated!' : 'Votes submitted!' }); + setHasVoted(true); + fetchPoll(); + } catch (err: any) { + setSubmitMsg({ type: 'error', text: err.response?.data?.error?.message || 'Failed to submit votes' }); + } finally { + setSubmitting(false); + } + }; + + const handleSubmitComment = async () => { + if (!commentName.trim() || !commentContent.trim()) return; + setCommentSubmitting(true); + try { + await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/comment`, { + authorName: commentName.trim(), + content: commentContent.trim(), + }); + setCommentContent(''); + fetchPoll(); + } catch { + // Silent fail + } finally { + setCommentSubmitting(false); + } + }; + + if (loading) { + return ( +
+ Loading poll... +
+ ); + } + + if (error || !poll) { + return ( +
+ {error || 'Poll not found'} +
+ ); + } + + const isOpen = poll.status === 'OPEN'; + const isFinalized = poll.status === 'FINALIZED'; + const bestScore = poll.options.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0; + + return ( +
+ {/* Title */} + {title && ( +

+ {title} +

+ )} + + {/* Poll header */} +
+

+ {poll.title} +

+ {poll.description && ( +

+ {poll.description} +

+ )} +
+ + {STATUS_LABELS[poll.status] || poll.status} + + {poll.location && ( + + {poll.location} + + )} + + {poll.timezone} + +
+
+ + {/* Deadline */} + {poll.votingDeadline && isOpen && ( +
+ Voting deadline: {formatDateTime(poll.votingDeadline)} +
+ )} + + {/* Finalized banner */} + {isFinalized && poll.finalizedOption && ( +
+ Date Confirmed:{' '} + {formatDate(poll.finalizedOption.date)} — {poll.finalizedOption.startTime}–{poll.finalizedOption.endTime} +
+ )} + + {/* Options table */} +
+
+ + + + + {poll.options.map((opt) => ( + + ))} + + + + {poll.voters.map((voter, i) => ( + + + {poll.options.map((opt) => { + const value = voter.votes[opt.id]; + return ( + + ); + })} + + ))} + {/* Score row */} + + + {poll.options.map((opt) => ( + + ))} + + +
+ Participant + 0 + ? 'rgba(82,196,26,0.06)' + : undefined, + }} + > +
{formatDate(opt.date)}
+
{opt.startTime}–{opt.endTime}
+ {isFinalized && poll.finalizedOptionId === opt.id && ( + + Confirmed + + )} +
+ {voter.name} + + {value && ( + + {VOTE_LABELS[value] || value} + + )} +
Score 0 ? 'rgba(82,196,26,0.1)' : undefined, + }} + > +
{opt.score ?? 0}
+
+ {opt.yesCount ?? 0}Y / {opt.ifNeedBeCount ?? 0}M / {opt.noCount ?? 0}N +
+
+
+
+ + {/* Vote form */} + {isOpen && ( +
+

+ {hasVoted ? 'Update Your Votes' : 'Cast Your Votes'} +

+ +
+ setVoterName(e.target.value)} + style={{ ...inputStyle, flex: '1 1 200px' }} + /> + setVoterEmail(e.target.value)} + style={{ ...inputStyle, flex: '1 1 200px' }} + /> +
+ + {poll.options.map((opt) => ( +
+ + {formatDate(opt.date)} {opt.startTime}–{opt.endTime} + +
+ {(['YES', 'IF_NEED_BE', 'NO'] as const).map((val) => ( + + ))} +
+
+ ))} + + {submitMsg && ( +
+ {submitMsg.text} +
+ )} + + +
+ )} + + {/* Comments */} + {showComments && ( +
+

+ Comments ({poll.comments.length}) +

+ + {poll.comments.map((comment) => ( +
+
+ {comment.authorName} + + {formatDateTime(comment.createdAt)} + +
+

+ {comment.content} +

+
+ ))} + + {poll.status !== 'CANCELLED' && ( +
+
+ setCommentName(e.target.value)} + style={{ ...inputStyle, flex: '0 0 140px' }} + /> + setCommentContent(e.target.value)} + style={{ ...inputStyle, flex: '1 1 200px' }} + onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitComment(); }} + /> + +
+
+ )} +
+ )} +
+ ); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index faf826cb..9c20c0ee 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,10 +1,47 @@ import '@ant-design/v5-patch-for-react-19'; import React from 'react'; import ReactDOM from 'react-dom/client'; +import ErrorBoundary from './components/ErrorBoundary'; import App from './App'; +// Catch unhandled chunk/module load errors globally (e.g., stale deployment) +// These fire as window errors when a