scheduling features
This commit is contained in:
parent
aaba7df97d
commit
e95bc8883e
@ -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
|
||||
|
||||
@ -14,6 +14,34 @@
|
||||
</head>
|
||||
<body style="margin:0;background:#1a1025">
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif">
|
||||
<p>JavaScript is required to run this application.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script>
|
||||
// Fallback: if the main module fails to load entirely (stale deployment),
|
||||
// show a reload prompt after a timeout. The main app replaces #root content on success.
|
||||
setTimeout(function() {
|
||||
var root = document.getElementById('root');
|
||||
if (root && root.children.length === 0) {
|
||||
var key = 'cm_chunk_reload';
|
||||
var last = sessionStorage.getItem(key);
|
||||
var now = Date.now();
|
||||
if (!last || now - parseInt(last, 10) > 10000) {
|
||||
sessionStorage.setItem(key, String(now));
|
||||
window.location.reload();
|
||||
} else {
|
||||
root.innerHTML = '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif;text-align:center;padding:0 24px">'
|
||||
+ '<div style="font-size:48px;margin-bottom:16px">↻</div>'
|
||||
+ '<h2 style="margin:0 0 8px;font-size:20px">Application Updated</h2>'
|
||||
+ '<p style="margin:0 0 24px;color:#999;max-width:400px;line-height:1.5">A new version has been deployed. Please refresh to load the latest version.</p>'
|
||||
+ '<button onclick="window.location.reload()" style="padding:10px 24px;font-size:14px;border:none;border-radius:6px;background:#9d4edd;color:#fff;cursor:pointer">Refresh Page</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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() {
|
||||
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<EventsPage />} />
|
||||
</Route>
|
||||
{/* Scheduling polls — feature-gated */}
|
||||
<Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<PollsListPage />} />
|
||||
</Route>
|
||||
<Route path="/poll/:slug" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<SchedulingPollPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Public meeting join page — feature-gated */}
|
||||
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<MeetingJoinPage />} />
|
||||
@ -674,6 +685,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="meeting-planner"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<MeetingPlannerPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/cuts"
|
||||
element={
|
||||
|
||||
@ -112,6 +112,7 @@ const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
|
||||
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
|
||||
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
|
||||
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
|
||||
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
|
||||
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
|
||||
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
|
||||
@ -249,7 +250,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
children: [
|
||||
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
||||
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
|
||||
{ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
|
||||
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
|
||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||
{ key: '/app/map/settings', icon: <SettingOutlined />, 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: <ScheduleOutlined />, label: 'Shifts' });
|
||||
}
|
||||
if (settings?.enableMeetingPlanner) {
|
||||
schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
|
||||
}
|
||||
if (schedulingChildren.length > 0) {
|
||||
items.push({
|
||||
key: 'scheduling-submenu',
|
||||
icon: <ScheduleOutlined />,
|
||||
label: 'Scheduling',
|
||||
children: schedulingChildren,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.enableMediaFeatures !== false) {
|
||||
items.push({
|
||||
key: 'media-submenu',
|
||||
|
||||
113
admin/src/components/ErrorBoundary.tsx
Normal file
113
admin/src/components/ErrorBoundary.tsx
Normal file
@ -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<Props, State> {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
background: '#1a1025',
|
||||
color: '#e0e0e0',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
textAlign: 'center',
|
||||
padding: '0 24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>
|
||||
{this.state.isChunkError ? '\u21BB' : '\u26A0'}
|
||||
</div>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 600 }}>
|
||||
{this.state.isChunkError
|
||||
? 'Application Updated'
|
||||
: 'Something went wrong'}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 24px', color: '#999', maxWidth: 400, lineHeight: 1.5 }}>
|
||||
{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.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
background: '#9d4edd',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.opacity = '0.85')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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'))
|
||||
);
|
||||
}
|
||||
@ -19,10 +19,11 @@ const FEATURE_LABELS: Record<string, string> = {
|
||||
enableEvents: 'Events',
|
||||
enableSocial: 'Social Connections',
|
||||
enableMeet: 'Video Meetings',
|
||||
enableMeetingPlanner: 'Meeting Planner',
|
||||
};
|
||||
|
||||
interface FeatureGateProps {
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet'>;
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner'>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@ -549,6 +549,28 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
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 `
|
||||
<section style="padding: 60px 40px;">
|
||||
<div class="scheduling-poll-block"
|
||||
data-poll-slug="${pollSlug}"
|
||||
data-show-comments="${showComments}"
|
||||
data-title="${title}"
|
||||
style="max-width: 700px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #fa8c16 0%, #d46b08 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
|
||||
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
|
||||
<path d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32z"/>
|
||||
</svg>
|
||||
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">${title}</p>
|
||||
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Poll will render on published page</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
default:
|
||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||
}
|
||||
|
||||
@ -153,10 +153,11 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
|
||||
{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 (
|
||||
<div
|
||||
key={item.id}
|
||||
@ -195,6 +196,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
|
||||
const renderItemCard = (item: UnifiedCalendarItem) => {
|
||||
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 (
|
||||
<Card
|
||||
key={item.id}
|
||||
size="small"
|
||||
style={{
|
||||
borderLeft: `4px solid ${isShift ? '#1890ff' : '#52c41a'}`,
|
||||
borderLeft: `4px solid ${borderColor}`,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
@ -221,8 +226,8 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
</Tooltip>
|
||||
)}
|
||||
</Text>
|
||||
<Tag color={isShift ? 'blue' : 'green'} style={{ margin: 0, fontSize: 11 }}>
|
||||
{isShift ? 'Shift' : 'Event'}
|
||||
<Tag color={tagColor} style={{ margin: 0, fontSize: 11 }}>
|
||||
{tagLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
@ -286,7 +291,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isShift && item.gancioUrl && (
|
||||
{!isShift && !isPoll && item.gancioUrl && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@ -298,6 +303,19 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isPoll && item.pollSlug && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CalendarOutlined />}
|
||||
href={`/poll/${item.pollSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vote ({item.pollVoteCount ?? 0})
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
|
||||
586
admin/src/components/scheduling/SchedulingPollWidget.tsx
Normal file
586
admin/src/components/scheduling/SchedulingPollWidget.tsx
Normal file
@ -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<string, string> = {
|
||||
OPEN: '#52c41a',
|
||||
CLOSED: '#fa8c16',
|
||||
FINALIZED: '#1890ff',
|
||||
CANCELLED: '#ff4d4f',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: 'Open',
|
||||
CLOSED: 'Closed',
|
||||
FINALIZED: 'Finalized',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const VOTE_LABELS: Record<string, string> = {
|
||||
YES: 'Yes',
|
||||
IF_NEED_BE: 'If Need Be',
|
||||
NO: 'No',
|
||||
};
|
||||
|
||||
const VOTE_COLORS: Record<string, string> = {
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
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<PollData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Vote form
|
||||
const [voterName, setVoterName] = useState('');
|
||||
const [voterEmail, setVoterEmail] = useState('');
|
||||
const [votes, setVotes] = useState<Record<string, string>>({});
|
||||
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<PollData>(`${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 (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
|
||||
Loading poll...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !poll) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
|
||||
{error || 'Poll not found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto', fontFamily: "'Inter', -apple-system, sans-serif", color: COLORS.text }}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h2 style={{ textAlign: 'center', marginBottom: 8, fontSize: '1.5rem', fontWeight: 700 }}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Poll header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>
|
||||
{poll.title}
|
||||
</h3>
|
||||
{poll.description && (
|
||||
<p style={{ color: COLORS.textMuted, margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||
{poll.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: `${STATUS_COLORS[poll.status] || '#666'}22`,
|
||||
color: STATUS_COLORS[poll.status] || '#666',
|
||||
border: `1px solid ${STATUS_COLORS[poll.status] || '#666'}44`,
|
||||
}}>
|
||||
{STATUS_LABELS[poll.status] || poll.status}
|
||||
</span>
|
||||
{poll.location && (
|
||||
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
|
||||
{poll.location}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
|
||||
{poll.timezone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
{poll.votingDeadline && isOpen && (
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(250,140,22,0.1)',
|
||||
border: '1px solid rgba(250,140,22,0.3)',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
color: COLORS.primary,
|
||||
}}>
|
||||
Voting deadline: {formatDateTime(poll.votingDeadline)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Finalized banner */}
|
||||
{isFinalized && poll.finalizedOption && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(82,196,26,0.1)',
|
||||
border: '1px solid rgba(82,196,26,0.3)',
|
||||
marginBottom: 16,
|
||||
color: COLORS.success,
|
||||
}}>
|
||||
<strong>Date Confirmed:</strong>{' '}
|
||||
{formatDate(poll.finalizedOption.date)} — {poll.finalizedOption.startTime}–{poll.finalizedOption.endTime}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options table */}
|
||||
<div style={{
|
||||
background: COLORS.card,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '10px 14px', borderBottom: `2px solid ${COLORS.border}`, textAlign: 'left', minWidth: 130 }}>
|
||||
Participant
|
||||
</th>
|
||||
{poll.options.map((opt) => (
|
||||
<th
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: `2px solid ${COLORS.border}`,
|
||||
textAlign: 'center',
|
||||
minWidth: 100,
|
||||
background: isFinalized && poll.finalizedOptionId === opt.id
|
||||
? 'rgba(82,196,26,0.12)'
|
||||
: opt.score === bestScore && bestScore > 0
|
||||
? 'rgba(82,196,26,0.06)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{formatDate(opt.date)}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||
{isFinalized && poll.finalizedOptionId === opt.id && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: 4,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: 'rgba(82,196,26,0.2)',
|
||||
color: COLORS.success,
|
||||
}}>
|
||||
Confirmed
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poll.voters.map((voter, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '8px 14px', borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
{voter.name}
|
||||
</td>
|
||||
{poll.options.map((opt) => {
|
||||
const value = voter.votes[opt.id];
|
||||
return (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderBottom: `1px solid ${COLORS.border}`,
|
||||
textAlign: 'center',
|
||||
background: value ? `${VOTE_COLORS[value] || '#666'}18` : undefined,
|
||||
}}
|
||||
>
|
||||
{value && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 3,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: `${VOTE_COLORS[value] || '#666'}22`,
|
||||
color: VOTE_COLORS[value] || '#666',
|
||||
}}>
|
||||
{VOTE_LABELS[value] || value}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Score row */}
|
||||
<tr style={{ fontWeight: 600 }}>
|
||||
<td style={{ padding: '10px 14px', borderTop: `2px solid ${COLORS.border}` }}>Score</td>
|
||||
{poll.options.map((opt) => (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderTop: `2px solid ${COLORS.border}`,
|
||||
textAlign: 'center',
|
||||
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82,196,26,0.1)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||
{opt.yesCount ?? 0}Y / {opt.ifNeedBeCount ?? 0}M / {opt.noCount ?? 0}N
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vote form */}
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
background: COLORS.card,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
|
||||
{hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
|
||||
</h4>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={voterName}
|
||||
onChange={(e) => setVoterName(e.target.value)}
|
||||
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email (optional)"
|
||||
value={voterEmail}
|
||||
onChange={(e) => setVoterEmail(e.target.value)}
|
||||
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{poll.options.map((opt) => (
|
||||
<div key={opt.id} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ minWidth: 150, fontSize: 13 }}>
|
||||
{formatDate(opt.date)} {opt.startTime}–{opt.endTime}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['YES', 'IF_NEED_BE', 'NO'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => handleVoteChange(opt.id, val)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
border: `1px solid ${VOTE_COLORS[val]}66`,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
background: votes[opt.id] === val ? `${VOTE_COLORS[val]}33` : 'transparent',
|
||||
color: votes[opt.id] === val ? VOTE_COLORS[val] : COLORS.textMuted,
|
||||
}}
|
||||
>
|
||||
{VOTE_LABELS[val]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{submitMsg && (
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
marginTop: 12,
|
||||
fontSize: 13,
|
||||
background: submitMsg.type === 'success' ? 'rgba(82,196,26,0.1)' : 'rgba(255,77,79,0.1)',
|
||||
color: submitMsg.type === 'success' ? COLORS.success : COLORS.error,
|
||||
border: `1px solid ${submitMsg.type === 'success' ? 'rgba(82,196,26,0.3)' : 'rgba(255,77,79,0.3)'}`,
|
||||
}}>
|
||||
{submitMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitVotes}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
...btnStyle,
|
||||
marginTop: 14,
|
||||
opacity: submitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Submitting...' : hasVoted ? 'Update Votes' : 'Submit Votes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{showComments && (
|
||||
<div style={{
|
||||
background: COLORS.card,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
padding: 20,
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
|
||||
Comments ({poll.comments.length})
|
||||
</h4>
|
||||
|
||||
{poll.comments.map((comment) => (
|
||||
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<strong style={{ fontSize: 13 }}>{comment.authorName}</strong>
|
||||
<span style={{ fontSize: 11, color: COLORS.textMuted }}>
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, color: COLORS.textMuted, lineHeight: 1.5 }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{poll.status !== 'CANCELLED' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={commentName}
|
||||
onChange={(e) => setCommentName(e.target.value)}
|
||||
style={{ ...inputStyle, flex: '0 0 140px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a comment..."
|
||||
value={commentContent}
|
||||
onChange={(e) => setCommentContent(e.target.value)}
|
||||
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitComment(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitComment}
|
||||
disabled={commentSubmitting}
|
||||
style={{
|
||||
...btnStyle,
|
||||
width: 'auto',
|
||||
flex: '0 0 auto',
|
||||
opacity: commentSubmitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 <script> tag fails to load
|
||||
window.addEventListener('error', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.tagName === 'SCRIPT' || target?.tagName === 'LINK') {
|
||||
const reloadKey = 'cm_chunk_reload';
|
||||
const lastReload = sessionStorage.getItem(reloadKey);
|
||||
const now = Date.now();
|
||||
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
|
||||
sessionStorage.setItem(reloadKey, String(now));
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, true); // capture phase to catch resource load errors
|
||||
|
||||
// Catch unhandled promise rejections (dynamic import failures)
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const msg = String(event.reason?.message || event.reason || '');
|
||||
if (
|
||||
msg.includes('Failed to fetch dynamically imported module') ||
|
||||
msg.includes('error loading dynamically imported module') ||
|
||||
msg.includes('Loading chunk') ||
|
||||
msg.includes('Importing a module script failed')
|
||||
) {
|
||||
const reloadKey = 'cm_chunk_reload';
|
||||
const lastReload = sessionStorage.getItem(reloadKey);
|
||||
const now = Date.now();
|
||||
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
|
||||
sessionStorage.setItem(reloadKey, String(now));
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -58,6 +58,7 @@ import {
|
||||
ShoppingCartOutlined,
|
||||
MobileOutlined,
|
||||
DesktopOutlined,
|
||||
CalendarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { OnMount } from '@monaco-editor/react';
|
||||
@ -359,6 +360,7 @@ const SNIPPETS: MkDocsSnippet[] = [
|
||||
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
|
||||
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
|
||||
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
|
||||
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
|
||||
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
||||
];
|
||||
|
||||
@ -590,6 +592,8 @@ export default function DocsPage() {
|
||||
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
||||
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||
const [pollSlugInput, setPollSlugInput] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -816,6 +820,13 @@ export default function DocsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scheduling poll — opens slug input modal
|
||||
if (snippetId === 'scheduling-poll') {
|
||||
setPollSlugInput('');
|
||||
setPollInsertOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pricing table — static CTA (plans are dynamic, so link out)
|
||||
if (snippetId === 'pricing-table') {
|
||||
const appUrl = config
|
||||
@ -1058,6 +1069,23 @@ export default function DocsPage() {
|
||||
setAdPickerOpen(false);
|
||||
}, []);
|
||||
|
||||
const handlePollInsert = useCallback(() => {
|
||||
const slug = pollSlugInput.trim();
|
||||
if (!slug) return;
|
||||
|
||||
const html = `<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">\n Loading poll...\n</div>`;
|
||||
|
||||
const ed = monacoEditorRef.current;
|
||||
if (ed) {
|
||||
const sel = ed.getSelection();
|
||||
if (sel) {
|
||||
ed.executeEdits('poll-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
|
||||
}
|
||||
}
|
||||
setPollInsertOpen(false);
|
||||
setPollSlugInput('');
|
||||
}, [pollSlugInput]);
|
||||
|
||||
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
||||
setCtxMenu(null);
|
||||
handleToolbarSnippet(snippetId);
|
||||
@ -1976,7 +2004,7 @@ export default function DocsPage() {
|
||||
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
||||
key: s.id,
|
||||
label: s.label,
|
||||
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
||||
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'scheduling-poll' ? <CalendarOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
||||
onClick: () => handleToolbarSnippet(s.id),
|
||||
})) }} trigger={['click']}>
|
||||
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
||||
@ -2190,6 +2218,27 @@ export default function DocsPage() {
|
||||
onInsert={handleAdInsert}
|
||||
/>
|
||||
|
||||
{/* Scheduling Poll Insert Modal */}
|
||||
<Modal
|
||||
title="Insert Scheduling Poll"
|
||||
open={pollInsertOpen}
|
||||
onOk={handlePollInsert}
|
||||
onCancel={() => { setPollInsertOpen(false); setPollSlugInput(''); }}
|
||||
okText="Insert"
|
||||
okButtonProps={{ disabled: !pollSlugInput.trim() }}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Enter the slug of the scheduling poll to embed. You can find poll slugs in the Meeting Planner admin page.
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="e.g. team-meeting-march"
|
||||
value={pollSlugInput}
|
||||
onChange={(e) => setPollSlugInput(e.target.value)}
|
||||
onPressEnter={handlePollInsert}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Custom right-click context menu with submenus */}
|
||||
{ctxMenu && (
|
||||
<div
|
||||
|
||||
709
admin/src/pages/MeetingPlannerPage.tsx
Normal file
709
admin/src/pages/MeetingPlannerPage.tsx
Normal file
@ -0,0 +1,709 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Form,
|
||||
Switch,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Drawer,
|
||||
Card,
|
||||
Tooltip,
|
||||
Grid,
|
||||
Divider,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
CalendarOutlined,
|
||||
CopyOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
SchedulingPoll,
|
||||
PollsListResponse,
|
||||
PollDetailResponse,
|
||||
SchedulingPollStatus,
|
||||
PollVoteValue,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
POLL_STATUS_COLORS,
|
||||
POLL_STATUS_LABELS,
|
||||
VOTE_VALUE_COLORS,
|
||||
VOTE_VALUE_LABELS,
|
||||
} from '@/types/api';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const TIMEZONE_OPTIONS = [
|
||||
'America/Vancouver',
|
||||
'America/Edmonton',
|
||||
'America/Regina',
|
||||
'America/Winnipeg',
|
||||
'America/Toronto',
|
||||
'America/Halifax',
|
||||
'America/St_Johns',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
];
|
||||
|
||||
export default function MeetingPlannerPage() {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<SchedulingPollStatus | undefined>();
|
||||
|
||||
// Create drawer
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Detail drawer
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selectedPoll, setSelectedPoll] = useState<PollDetailResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Finalize modal
|
||||
const [finalizeOpen, setFinalizeOpen] = useState(false);
|
||||
const [selectedOptionId, setSelectedOptionId] = useState<string>('');
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
|
||||
// Convert modal
|
||||
const [convertOpen, setConvertOpen] = useState(false);
|
||||
const [convertForm] = Form.useForm();
|
||||
const [converting, setConverting] = useState(false);
|
||||
|
||||
const fetchPolls = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = { page: pagination.page, limit: pagination.limit };
|
||||
if (search) params.search = search;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const { data } = await api.get<PollsListResponse>('/meeting-planner', { params });
|
||||
setPolls(data.polls);
|
||||
setPagination((p) => ({ ...p, total: data.pagination.total }));
|
||||
} catch {
|
||||
message.error('Failed to load polls');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, search, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchPolls(); }, [fetchPolls]);
|
||||
|
||||
const fetchPollDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
|
||||
setSelectedPoll(data);
|
||||
setDetailOpen(true);
|
||||
} catch {
|
||||
message.error('Failed to load poll details');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const options = values.options.map((opt: any) => ({
|
||||
date: opt.date.format('YYYY-MM-DD'),
|
||||
startTime: opt.startTime.format('HH:mm'),
|
||||
endTime: opt.endTime.format('HH:mm'),
|
||||
}));
|
||||
await api.post('/meeting-planner', {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
location: values.location,
|
||||
timezone: values.timezone,
|
||||
allowAnonymous: values.allowAnonymous ?? true,
|
||||
notifyOnVote: values.notifyOnVote ?? true,
|
||||
votingDeadline: values.votingDeadline?.toISOString(),
|
||||
options,
|
||||
});
|
||||
message.success('Poll created');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchPolls();
|
||||
} catch {
|
||||
message.error('Failed to create poll');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/meeting-planner/${id}`);
|
||||
message.success('Poll deleted');
|
||||
fetchPolls();
|
||||
} catch {
|
||||
message.error('Failed to delete poll');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!selectedPoll || !selectedOptionId) return;
|
||||
setFinalizing(true);
|
||||
try {
|
||||
await api.post(`/meeting-planner/${selectedPoll.id}/finalize`, {
|
||||
optionId: selectedOptionId,
|
||||
});
|
||||
message.success('Poll finalized');
|
||||
setFinalizeOpen(false);
|
||||
fetchPollDetail(selectedPoll.id);
|
||||
fetchPolls();
|
||||
} catch {
|
||||
message.error('Failed to finalize poll');
|
||||
} finally {
|
||||
setFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertToShift = async (values: any) => {
|
||||
if (!selectedPoll) return;
|
||||
setConverting(true);
|
||||
try {
|
||||
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-shift`, {
|
||||
maxVolunteers: values.maxVolunteers,
|
||||
isPublic: values.isPublic ?? true,
|
||||
});
|
||||
message.success('Converted to shift');
|
||||
setConvertOpen(false);
|
||||
convertForm.resetFields();
|
||||
fetchPollDetail(selectedPoll.id);
|
||||
} catch {
|
||||
message.error('Failed to convert to shift');
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertToEvent = async () => {
|
||||
if (!selectedPoll) return;
|
||||
try {
|
||||
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-event`);
|
||||
message.success('Converted to Gancio event');
|
||||
fetchPollDetail(selectedPoll.id);
|
||||
} catch {
|
||||
message.error('Failed to convert to event');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
if (!selectedPoll) return;
|
||||
try {
|
||||
await api.delete(`/meeting-planner/${selectedPoll.id}/comments/${commentId}`);
|
||||
message.success('Comment deleted');
|
||||
fetchPollDetail(selectedPoll.id);
|
||||
} catch {
|
||||
message.error('Failed to delete comment');
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = (slug: string) => {
|
||||
const url = `${window.location.origin}/poll/${slug}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('Share link copied');
|
||||
};
|
||||
|
||||
const columns: ColumnsType<SchedulingPoll> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string, record) => (
|
||||
<Button type="link" onClick={() => fetchPollDetail(record.id)} style={{ padding: 0 }}>
|
||||
{title}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (status: SchedulingPollStatus) => (
|
||||
<Tag color={POLL_STATUS_COLORS[status]}>{POLL_STATUS_LABELS[status]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Options',
|
||||
key: 'options',
|
||||
width: 80,
|
||||
render: (_, record) => record._count?.options ?? 0,
|
||||
},
|
||||
{
|
||||
title: 'Votes',
|
||||
key: 'votes',
|
||||
width: 80,
|
||||
render: (_, record) => record._count?.votes ?? 0,
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 120,
|
||||
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="Copy share link">
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="Delete this poll?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Voting Matrix Component ---
|
||||
const VotingMatrix = ({ poll }: { poll: PollDetailResponse }) => {
|
||||
if (!poll.options?.length) return <Text type="secondary">No options yet</Text>;
|
||||
|
||||
const bestScore = Math.max(...poll.options.map((o) => o.score ?? 0));
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '8px 12px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 120 }}>
|
||||
Voter
|
||||
</th>
|
||||
{poll.options.map((opt) => (
|
||||
<th
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '2px solid #303030',
|
||||
textAlign: 'center',
|
||||
minWidth: 100,
|
||||
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.1)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div>{dayjs(opt.date).format('MMM D')}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||
{poll.finalizedOptionId === opt.id && (
|
||||
<Tag color="blue" style={{ marginTop: 4 }}>Selected</Tag>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poll.voters?.map((voter, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '6px 12px', borderBottom: '1px solid #303030' }}>
|
||||
{voter.name}
|
||||
</td>
|
||||
{poll.options.map((opt) => {
|
||||
const value = voter.votes[opt.id] as PollVoteValue | undefined;
|
||||
return (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderBottom: '1px solid #303030',
|
||||
textAlign: 'center',
|
||||
background: value ? `${VOTE_VALUE_COLORS[value]}20` : undefined,
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<Tag
|
||||
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{VOTE_VALUE_LABELS[value]}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Tally row */}
|
||||
<tr style={{ fontWeight: 600 }}>
|
||||
<td style={{ padding: '8px 12px', borderTop: '2px solid #303030' }}>Score</td>
|
||||
{poll.options.map((opt) => (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: '2px solid #303030',
|
||||
textAlign: 'center',
|
||||
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.15)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div>{opt.score ?? 0}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: screens.md ? 24 : 16 }}>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
Meeting Planner
|
||||
</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
{screens.md ? 'Create Poll' : 'New'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search polls..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={Object.entries(POLL_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
dataSource={polls}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setPagination((p) => ({ ...p, page })),
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Create Poll Drawer */}
|
||||
<Drawer
|
||||
title="Create Scheduling Poll"
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
width={screens.md ? 560 : '100%'}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreate}
|
||||
initialValues={{
|
||||
timezone: 'America/Edmonton',
|
||||
allowAnonymous: true,
|
||||
notifyOnVote: true,
|
||||
options: [
|
||||
{ date: null, startTime: null, endTime: null },
|
||||
{ date: null, startTime: null, endTime: null },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
|
||||
<Input placeholder="e.g., Team Planning Meeting" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={3} placeholder="What is this meeting about?" />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="Location">
|
||||
<Input placeholder="e.g., Community Centre Room 204" />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="Timezone">
|
||||
<Select options={TIMEZONE_OPTIONS.map((tz) => ({ value: tz, label: tz }))} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Date/Time Options</Divider>
|
||||
|
||||
<Form.List name="options">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name }) => (
|
||||
<Row key={key} gutter={8} align="middle" style={{ marginBottom: 8 }}>
|
||||
<Col flex="auto">
|
||||
<Space wrap>
|
||||
<Form.Item name={[name, 'date']} noStyle rules={[{ required: true, message: 'Date required' }]}>
|
||||
<DatePicker format="YYYY-MM-DD" placeholder="Date" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[name, 'startTime']} noStyle rules={[{ required: true, message: 'Start required' }]}>
|
||||
<TimePicker format="HH:mm" minuteStep={15} placeholder="Start" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[name, 'endTime']} noStyle rules={[{ required: true, message: 'End required' }]}>
|
||||
<TimePicker format="HH:mm" minuteStep={15} placeholder="End" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
{fields.length > 2 && (
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => remove(name)} />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add({ date: null, startTime: null, endTime: null })}
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
disabled={fields.length >= 20}
|
||||
>
|
||||
Add Option
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Divider>Settings</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
|
||||
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" loading={creating} block>
|
||||
Create Poll
|
||||
</Button>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Poll Detail Drawer */}
|
||||
<Drawer
|
||||
title={selectedPoll?.title || 'Poll Details'}
|
||||
open={detailOpen}
|
||||
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
|
||||
width={screens.md ? 720 : '100%'}
|
||||
loading={detailLoading}
|
||||
extra={
|
||||
selectedPoll && (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyShareLink(selectedPoll.slug)}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedPoll && (
|
||||
<>
|
||||
{/* Status + Meta */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Tag color={POLL_STATUS_COLORS[selectedPoll.status]}>
|
||||
{POLL_STATUS_LABELS[selectedPoll.status]}
|
||||
</Tag>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">
|
||||
{selectedPoll._count?.votes ?? 0} votes across {selectedPoll._count?.options ?? 0} options
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{selectedPoll.description && (
|
||||
<Text style={{ display: 'block', marginBottom: 16 }}>{selectedPoll.description}</Text>
|
||||
)}
|
||||
{selectedPoll.location && (
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
Location: {selectedPoll.location}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Voting Matrix */}
|
||||
<Card size="small" title="Voting Matrix" style={{ marginBottom: 16 }}>
|
||||
<VotingMatrix poll={selectedPoll} />
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card size="small" title="Actions" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
{selectedPoll.status === 'OPEN' && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedOptionId('');
|
||||
setFinalizeOpen(true);
|
||||
}}
|
||||
>
|
||||
Finalize
|
||||
</Button>
|
||||
)}
|
||||
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedShiftId && (
|
||||
<Button
|
||||
icon={<TeamOutlined />}
|
||||
onClick={() => {
|
||||
convertForm.resetFields();
|
||||
setConvertOpen(true);
|
||||
}}
|
||||
>
|
||||
Convert to Shift
|
||||
</Button>
|
||||
)}
|
||||
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedGancioEventId && (
|
||||
<Popconfirm title="Convert to Gancio event?" onConfirm={handleConvertToEvent}>
|
||||
<Button icon={<CalendarOutlined />}>Convert to Event</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{selectedPoll.convertedShiftId && (
|
||||
<Tag color="blue" icon={<CheckCircleOutlined />}>Converted to Shift</Tag>
|
||||
)}
|
||||
{selectedPoll.convertedGancioEventId && (
|
||||
<Tag color="green" icon={<CheckCircleOutlined />}>Converted to Event</Tag>
|
||||
)}
|
||||
{selectedPoll.status === 'OPEN' && (
|
||||
<Popconfirm
|
||||
title="Close this poll?"
|
||||
onConfirm={async () => {
|
||||
await api.put(`/meeting-planner/${selectedPoll.id}`, { status: 'CLOSED' });
|
||||
message.success('Poll closed');
|
||||
fetchPollDetail(selectedPoll.id);
|
||||
fetchPolls();
|
||||
}}
|
||||
>
|
||||
<Button icon={<ClockCircleOutlined />}>Close</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Comments */}
|
||||
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
|
||||
<Card size="small" title={`Comments (${selectedPoll.comments.length})`}>
|
||||
{selectedPoll.comments.map((comment) => (
|
||||
<div key={comment.id} style={{ marginBottom: 12, padding: '8px 0', borderBottom: '1px solid #303030' }}>
|
||||
<Row justify="space-between">
|
||||
<Col>
|
||||
<Text strong>{comment.authorName}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Popconfirm title="Delete comment?" onConfirm={() => handleDeleteComment(comment.id)}>
|
||||
<Button size="small" danger type="text" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Finalize Modal */}
|
||||
<Modal
|
||||
title="Finalize Poll"
|
||||
open={finalizeOpen}
|
||||
onCancel={() => setFinalizeOpen(false)}
|
||||
onOk={handleFinalize}
|
||||
confirmLoading={finalizing}
|
||||
okText="Finalize"
|
||||
okButtonProps={{ disabled: !selectedOptionId }}
|
||||
>
|
||||
<Text style={{ display: 'block', marginBottom: 16 }}>
|
||||
Select the winning date/time option:
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="Select option"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedOptionId || undefined}
|
||||
onChange={setSelectedOptionId}
|
||||
options={selectedPoll?.options?.map((opt) => ({
|
||||
value: opt.id,
|
||||
label: `${dayjs(opt.date).format('MMM D, YYYY')} ${opt.startTime}–${opt.endTime} (score: ${opt.score ?? 0})`,
|
||||
}))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Convert to Shift Modal */}
|
||||
<Modal
|
||||
title="Convert to Volunteer Shift"
|
||||
open={convertOpen}
|
||||
onCancel={() => setConvertOpen(false)}
|
||||
onOk={() => convertForm.submit()}
|
||||
confirmLoading={converting}
|
||||
okText="Create Shift"
|
||||
>
|
||||
<Form form={convertForm} layout="vertical" onFinish={handleConvertToShift} initialValues={{ maxVolunteers: 10, isPublic: true }}>
|
||||
<Form.Item name="maxVolunteers" label="Max Volunteers" rules={[{ required: true }]}>
|
||||
<Input type="number" min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isPublic" label="Public" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -505,7 +505,10 @@ export default function SettingsPage() {
|
||||
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 0 }}>
|
||||
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Meeting Planner" name="enableMeetingPlanner" valuePropName="checked" extra="Scheduling polls for finding the best meeting time" style={{ marginBottom: 0 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
@ -10,6 +10,7 @@ import { DonationWidget } from '@/components/payments/DonationWidget';
|
||||
import { PricingWidget } from '@/components/payments/PricingWidget';
|
||||
import { ProductWidget } from '@/components/payments/ProductWidget';
|
||||
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
||||
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
|
||||
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
||||
import type { GalleryAd } from '@/types/gallery-ads';
|
||||
|
||||
@ -23,6 +24,7 @@ export default function PublicLandingPage() {
|
||||
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||
const pollRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||
|
||||
// Track page view
|
||||
useEffect(() => {
|
||||
@ -322,12 +324,44 @@ export default function PublicLandingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Hydrate scheduling poll blocks
|
||||
const hydratePollBlocks = () => {
|
||||
const pollBlocks = contentRef.current?.querySelectorAll('.scheduling-poll-block');
|
||||
if (!pollBlocks) return;
|
||||
|
||||
pollRootsRef.current.forEach((root) => {
|
||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root:', err); }
|
||||
});
|
||||
pollRootsRef.current = [];
|
||||
|
||||
pollBlocks.forEach((blockEl) => {
|
||||
const pollSlug = blockEl.getAttribute('data-poll-slug');
|
||||
if (!pollSlug) return;
|
||||
|
||||
const showComments = blockEl.getAttribute('data-show-comments') !== 'false';
|
||||
const title = blockEl.getAttribute('data-title') || undefined;
|
||||
|
||||
const container = document.createElement('div');
|
||||
blockEl.innerHTML = '';
|
||||
blockEl.appendChild(container);
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
pollRootsRef.current.push(root);
|
||||
root.render(<SchedulingPollWidget pollSlug={pollSlug} showComments={showComments} title={title} />);
|
||||
} catch (err) {
|
||||
console.error('Failed to render scheduling poll widget:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Hydrate after DOM is ready
|
||||
setTimeout(hydrateVideoBlocks, 100);
|
||||
setTimeout(hydrateVideoCards, 200);
|
||||
setTimeout(hydratePaymentBlocks, 150);
|
||||
setTimeout(hydrateCampaignFormBlocks, 175);
|
||||
setTimeout(hydrateAdBlocks, 200);
|
||||
setTimeout(hydratePollBlocks, 180);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
@ -350,6 +384,11 @@ export default function PublicLandingPage() {
|
||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
|
||||
});
|
||||
adRootsRef.current = [];
|
||||
|
||||
pollRootsRef.current.forEach((root) => {
|
||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root on cleanup:', err); }
|
||||
});
|
||||
pollRootsRef.current = [];
|
||||
};
|
||||
}, [page]);
|
||||
|
||||
|
||||
139
admin/src/pages/public/PollsListPage.tsx
Normal file
139
admin/src/pages/public/PollsListPage.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Spin,
|
||||
Grid,
|
||||
Space,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
|
||||
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } from '@/types/api';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function PollsListPage() {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPolls = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<PollsListResponse>('/api/meeting-planner/public');
|
||||
setPolls(data.polls);
|
||||
} catch {
|
||||
// If unauthorized, try the public listing approach
|
||||
setPolls([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPolls();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 24 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
Scheduling Polls
|
||||
</Title>
|
||||
|
||||
{polls.length === 0 ? (
|
||||
<Empty description="No open polls at the moment" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{polls.map((poll) => {
|
||||
const optionCount = poll._count?.options ?? 0;
|
||||
const voteCount = poll._count?.votes ?? 0;
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} key={poll.id}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => navigate(`/poll/${poll.slug}`)}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="top">
|
||||
<Col flex="auto">
|
||||
<Text strong style={{ fontSize: 16 }}>{poll.title}</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Tag color={POLL_STATUS_COLORS[poll.status as SchedulingPollStatus]}>
|
||||
{POLL_STATUS_LABELS[poll.status as SchedulingPollStatus]}
|
||||
</Tag>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{poll.description && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{poll.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<Space split={<span style={{ opacity: 0.3 }}>|</span>} wrap>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||
{optionCount} options
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
<TeamOutlined style={{ marginRight: 4 }} />
|
||||
{voteCount} votes
|
||||
</Text>
|
||||
{poll.location && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||||
{poll.location}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{poll.votingDeadline && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
Deadline: {dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button type="primary" size="small" block style={{ marginTop: 8 }}>
|
||||
Vote Now
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
501
admin/src/pages/public/SchedulingPollPage.tsx
Normal file
501
admin/src/pages/public/SchedulingPollPage.tsx
Normal file
@ -0,0 +1,501 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Input,
|
||||
Radio,
|
||||
message,
|
||||
Spin,
|
||||
Result,
|
||||
Grid,
|
||||
Space,
|
||||
Divider,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type {
|
||||
PollDetailResponse,
|
||||
PollVoteValue,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
POLL_STATUS_COLORS,
|
||||
POLL_STATUS_LABELS,
|
||||
VOTE_VALUE_COLORS,
|
||||
VOTE_VALUE_LABELS,
|
||||
} from '@/types/api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const VOTER_TOKEN_KEY = 'poll_voter_token_';
|
||||
|
||||
export default function SchedulingPollPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Vote form state
|
||||
const [voterName, setVoterName] = useState(user?.name || '');
|
||||
const [voterEmail, setVoterEmail] = useState(user?.email || '');
|
||||
const [votes, setVotes] = useState<Record<string, PollVoteValue>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
|
||||
// Comment form state
|
||||
const [commentName, setCommentName] = useState(user?.name || '');
|
||||
const [commentContent, setCommentContent] = useState('');
|
||||
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||
|
||||
const fetchPoll = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.get<PollDetailResponse>(`/api/meeting-planner/public/${slug}`);
|
||||
setPoll(data);
|
||||
|
||||
// Check if user has already voted (by token or auth)
|
||||
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
|
||||
if (storedToken || user) {
|
||||
const existingVoter = data.voters?.find((v) => {
|
||||
if (user) return data.votes?.some((vote) => vote.userId === user.id && v.name === vote.voterName);
|
||||
return false;
|
||||
});
|
||||
if (existingVoter) {
|
||||
setVoterName(existingVoter.name);
|
||||
setVotes(existingVoter.votes);
|
||||
setHasVoted(true);
|
||||
} else if (storedToken) {
|
||||
// Find voter by checking if any vote has our token
|
||||
// We can't see tokens in the response, but if we stored one, we know we voted
|
||||
setHasVoted(true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, user]);
|
||||
|
||||
useEffect(() => { fetchPoll(); }, [fetchPoll]);
|
||||
|
||||
const handleVoteChange = (optionId: string, value: PollVoteValue) => {
|
||||
setVotes((prev) => ({ ...prev, [optionId]: value }));
|
||||
};
|
||||
|
||||
const handleSubmitVotes = async () => {
|
||||
if (!poll || !voterName.trim()) {
|
||||
message.warning('Please enter your name');
|
||||
return;
|
||||
}
|
||||
if (!Object.keys(votes).length) {
|
||||
message.warning('Please vote on at least one option');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
|
||||
const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, {
|
||||
voterName: voterName.trim(),
|
||||
voterEmail: voterEmail.trim() || undefined,
|
||||
voterToken: storedToken || undefined,
|
||||
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
|
||||
});
|
||||
|
||||
// Store voter token for anonymous edit access
|
||||
if (data.voterToken) {
|
||||
localStorage.setItem(VOTER_TOKEN_KEY + slug, data.voterToken);
|
||||
}
|
||||
|
||||
message.success(hasVoted ? 'Votes updated' : 'Votes submitted');
|
||||
setHasVoted(true);
|
||||
fetchPoll();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.error?.message || 'Failed to submit votes');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentName.trim() || !commentContent.trim()) {
|
||||
message.warning('Please enter your name and comment');
|
||||
return;
|
||||
}
|
||||
setCommentSubmitting(true);
|
||||
try {
|
||||
await axios.post(`/api/meeting-planner/public/${slug}/comment`, {
|
||||
authorName: commentName.trim(),
|
||||
content: commentContent.trim(),
|
||||
});
|
||||
message.success('Comment added');
|
||||
setCommentContent('');
|
||||
fetchPoll();
|
||||
} catch {
|
||||
message.error('Failed to add comment');
|
||||
} finally {
|
||||
setCommentSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !poll) {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="Poll Not Found"
|
||||
subTitle="This scheduling poll may have been removed or the link is invalid."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
{poll.title}
|
||||
</Title>
|
||||
{poll.description && (
|
||||
<Paragraph style={{ fontSize: 16, opacity: 0.85, marginBottom: 12 }}>
|
||||
{poll.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Space wrap>
|
||||
<Tag color={POLL_STATUS_COLORS[poll.status]}>{POLL_STATUS_LABELS[poll.status]}</Tag>
|
||||
{poll.location && (
|
||||
<Text type="secondary">
|
||||
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||||
{poll.location}
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{poll.timezone}
|
||||
</Text>
|
||||
{poll.createdBy && (
|
||||
<Text type="secondary">
|
||||
by {poll.createdBy.name || poll.createdBy.email}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Deadline banner */}
|
||||
{poll.votingDeadline && isOpen && (
|
||||
<Alert
|
||||
type={dayjs(poll.votingDeadline).isBefore(dayjs()) ? 'error' : 'info'}
|
||||
message={
|
||||
dayjs(poll.votingDeadline).isBefore(dayjs())
|
||||
? 'Voting deadline has passed'
|
||||
: `Voting deadline: ${dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}`
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finalized banner */}
|
||||
{isFinalized && poll.finalizedOption && (
|
||||
<Alert
|
||||
type="success"
|
||||
message="Date Confirmed"
|
||||
description={
|
||||
<Space>
|
||||
<CheckCircleOutlined />
|
||||
<Text strong>
|
||||
{dayjs(poll.finalizedOption.date).format('dddd, MMMM D, YYYY')} — {poll.finalizedOption.startTime}–{poll.finalizedOption.endTime}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Voting */}
|
||||
{isMobile ? (
|
||||
/* Mobile: vertical cards */
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{poll.options?.map((opt) => (
|
||||
<Card
|
||||
key={opt.id}
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
border: isFinalized && poll.finalizedOptionId === opt.id ? '2px solid #52c41a' : undefined,
|
||||
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.05)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Text strong>{dayjs(opt.date).format('ddd, MMM D')}</Text>
|
||||
<br />
|
||||
<Text type="secondary">{opt.startTime} – {opt.endTime}</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
{isOpen && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Radio.Group
|
||||
value={votes[opt.id]}
|
||||
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="YES" style={{ color: votes[opt.id] === 'YES' ? '#fff' : undefined }}>Yes</Radio.Button>
|
||||
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
|
||||
<Radio.Button value="NO">No</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: voting matrix */
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '10px 14px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 140 }}>
|
||||
Participant
|
||||
</th>
|
||||
{poll.options?.map((opt) => (
|
||||
<th
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '2px solid #303030',
|
||||
textAlign: 'center',
|
||||
minWidth: 110,
|
||||
background: isFinalized && poll.finalizedOptionId === opt.id
|
||||
? 'rgba(82, 196, 26, 0.15)'
|
||||
: opt.score === bestScore && bestScore > 0
|
||||
? 'rgba(82, 196, 26, 0.08)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{dayjs(opt.date).format('ddd, MMM D')}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||
{isFinalized && poll.finalizedOptionId === opt.id && (
|
||||
<Tag color="green" style={{ marginTop: 4, fontSize: 10 }}>Confirmed</Tag>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poll.voters?.map((voter, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '8px 14px', borderBottom: '1px solid #252525' }}>
|
||||
{voter.name}
|
||||
</td>
|
||||
{poll.options?.map((opt) => {
|
||||
const value = voter.votes[opt.id] as PollVoteValue | undefined;
|
||||
return (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderBottom: '1px solid #252525',
|
||||
textAlign: 'center',
|
||||
background: value ? `${VOTE_VALUE_COLORS[value]}18` : undefined,
|
||||
}}
|
||||
>
|
||||
{value && (
|
||||
<Tag
|
||||
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{VOTE_VALUE_LABELS[value]}
|
||||
</Tag>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Score row */}
|
||||
<tr style={{ fontWeight: 600 }}>
|
||||
<td style={{ padding: '10px 14px', borderTop: '2px solid #303030' }}>Score</td>
|
||||
{poll.options?.map((opt) => (
|
||||
<td
|
||||
key={opt.id}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderTop: '2px solid #303030',
|
||||
textAlign: 'center',
|
||||
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.12)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vote Form */}
|
||||
{isOpen && (
|
||||
<Card
|
||||
size="small"
|
||||
title={hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={voterName}
|
||||
onChange={(e) => setVoterName(e.target.value)}
|
||||
disabled={!!user}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
placeholder="Email (optional, for notifications)"
|
||||
value={voterEmail}
|
||||
onChange={(e) => setVoterEmail(e.target.value)}
|
||||
disabled={!!user}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Desktop: inline radio buttons per option */}
|
||||
{!isMobile && poll.options?.map((opt) => (
|
||||
<Row key={opt.id} gutter={12} align="middle" style={{ marginBottom: 8 }}>
|
||||
<Col flex="200px">
|
||||
<Text>{dayjs(opt.date).format('ddd, MMM D')} {opt.startTime}–{opt.endTime}</Text>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<Radio.Group
|
||||
value={votes[opt.id]}
|
||||
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="YES">Yes</Radio.Button>
|
||||
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
|
||||
<Radio.Button value="NO">No</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSubmitVotes}
|
||||
loading={submitting}
|
||||
block
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{hasVoted ? 'Update Votes' : 'Submit Votes'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Comments Section */}
|
||||
<Card
|
||||
size="small"
|
||||
title={`Comments (${poll.comments?.length ?? 0})`}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
{poll.comments?.map((comment) => (
|
||||
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #252525' }}>
|
||||
<Row justify="space-between">
|
||||
<Col>
|
||||
<Text strong>{comment.authorName}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{poll.status !== 'CANCELLED' && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={6}>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={commentName}
|
||||
onChange={(e) => setCommentName(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={14}>
|
||||
<Input.TextArea
|
||||
placeholder="Add a comment..."
|
||||
value={commentContent}
|
||||
onChange={(e) => setCommentContent(e.target.value)}
|
||||
rows={1}
|
||||
size="small"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={4}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={handleSubmitComment}
|
||||
loading={commentSubmitting}
|
||||
block
|
||||
>
|
||||
Post
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
|
||||
import { Table, Button, Drawer, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
|
||||
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
@ -26,9 +26,10 @@ export default function SmsCampaignsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
|
||||
const [createForm] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Preview & Test
|
||||
const [testPreview, setTestPreview] = useState('');
|
||||
@ -67,16 +68,21 @@ export default function SmsCampaignsPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [campaigns, fetchCampaigns]);
|
||||
|
||||
const closeDrawer = () => { setDrawerOpen(false); setTestPreview(''); };
|
||||
|
||||
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/sms/campaigns', values);
|
||||
message.success('Campaign created');
|
||||
setCreateOpen(false);
|
||||
closeDrawer();
|
||||
createForm.resetFields();
|
||||
fetchCampaigns();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Create failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -209,34 +215,47 @@ export default function SmsCampaignsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 480;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { setCreateOpen(true); fetchContactLists(); }}
|
||||
>
|
||||
New Campaign
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { setDrawerOpen(true); fetchContactLists(); }}
|
||||
>
|
||||
New Campaign
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={campaigns}
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
||||
size="middle"
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={campaigns}
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Campaign Modal */}
|
||||
<Modal
|
||||
<Drawer
|
||||
title="New SMS Campaign"
|
||||
open={createOpen}
|
||||
onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
|
||||
onOk={() => createForm.submit()}
|
||||
width={600}
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
destroyOnHidden
|
||||
mask={false}
|
||||
width={drawerWidth}
|
||||
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeDrawer}>Cancel</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => createForm.submit()}>
|
||||
Create
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ delayBetweenMs: 3000 }}>
|
||||
<Form.Item name="name" label="Campaign Name" rules={[{ required: true }]}>
|
||||
@ -306,7 +325,7 @@ export default function SmsCampaignsPage() {
|
||||
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag } from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Drawer, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag, Modal } from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
||||
@ -35,14 +35,24 @@ export default function SmsContactsPage() {
|
||||
const [listsPage, setListsPage] = useState(1);
|
||||
const [listsLoading, setListsLoading] = useState(true);
|
||||
|
||||
// --- Modals ---
|
||||
// --- Drawers ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [addContactOpen, setAddContactOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importListId, setImportListId] = useState<string | null>(null);
|
||||
const [csvText, setCsvText] = useState('');
|
||||
const [createForm] = Form.useForm();
|
||||
const [addContactForm] = Form.useForm();
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [addContactSaving, setAddContactSaving] = useState(false);
|
||||
|
||||
// Import modal state
|
||||
// --- Selection + bulk actions ---
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [addToListModalOpen, setAddToListModalOpen] = useState(false);
|
||||
const [addToListTargetId, setAddToListTargetId] = useState<string | undefined>();
|
||||
const [addToListLoading, setAddToListLoading] = useState(false);
|
||||
|
||||
// Import state
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
@ -102,7 +112,7 @@ export default function SmsContactsPage() {
|
||||
fetchEntries(1);
|
||||
}, [fetchEntries]);
|
||||
|
||||
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
|
||||
// Load shifts and SMS campaigns for filter dropdowns when import drawer opens
|
||||
const loadFilterOptions = useCallback(async () => {
|
||||
try {
|
||||
const [shiftsRes, campaignsRes] = await Promise.all([
|
||||
@ -116,17 +126,71 @@ export default function SmsContactsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeCreateDrawer = () => { setCreateOpen(false); };
|
||||
const closeAddContactDrawer = () => { setAddContactOpen(false); };
|
||||
const closeImportDrawer = () => { setImportOpen(false); resetImportState(); };
|
||||
|
||||
const handleCreate = async (values: { name: string }) => {
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
const { data } = await api.post('/sms/contacts', values);
|
||||
message.success('Contact list created');
|
||||
setCreateOpen(false);
|
||||
closeCreateDrawer();
|
||||
createForm.resetFields();
|
||||
fetchLists();
|
||||
// Auto-select the new list in the filter
|
||||
setFilterListId(data.id);
|
||||
} catch {
|
||||
message.error('Failed to create list');
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddContact = async (values: { listId: string; phone: string; name?: string; email?: string }) => {
|
||||
setAddContactSaving(true);
|
||||
try {
|
||||
const { listId, ...body } = values;
|
||||
await api.post(`/sms/contacts/${listId}/entries`, body);
|
||||
message.success('Contact added');
|
||||
closeAddContactDrawer();
|
||||
addContactForm.resetFields();
|
||||
fetchEntries(entriesPage);
|
||||
fetchLists();
|
||||
} catch {
|
||||
message.error('Failed to add contact');
|
||||
} finally {
|
||||
setAddContactSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddContactDrawer = () => {
|
||||
// Pre-fill the list if one is selected in the filter
|
||||
if (filterListId) {
|
||||
addContactForm.setFieldsValue({ listId: filterListId });
|
||||
} else if (lists.length === 1) {
|
||||
addContactForm.setFieldsValue({ listId: lists[0]!.id });
|
||||
}
|
||||
setAddContactOpen(true);
|
||||
};
|
||||
|
||||
const handleAddToList = async () => {
|
||||
if (!addToListTargetId || selectedRowKeys.length === 0) return;
|
||||
setAddToListLoading(true);
|
||||
try {
|
||||
const selected = entries.filter(e => selectedRowKeys.includes(e.id));
|
||||
const payload = selected.map(e => ({ phone: e.phone, name: e.name || undefined, email: e.email || undefined }));
|
||||
const { data } = await api.post(`/sms/contacts/${addToListTargetId}/entries/bulk`, { entries: payload });
|
||||
message.success(`Added ${data.imported} contact${data.imported !== 1 ? 's' : ''} to list${data.skipped ? ` (${data.skipped} skipped)` : ''}`);
|
||||
setAddToListModalOpen(false);
|
||||
setAddToListTargetId(undefined);
|
||||
setSelectedRowKeys([]);
|
||||
fetchEntries(entriesPage);
|
||||
fetchLists();
|
||||
} catch {
|
||||
message.error('Failed to add contacts to list');
|
||||
} finally {
|
||||
setAddToListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -149,8 +213,7 @@ export default function SmsContactsPage() {
|
||||
try {
|
||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
|
||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||
setImportOpen(false);
|
||||
resetImportState();
|
||||
closeImportDrawer();
|
||||
fetchLists();
|
||||
fetchEntries(entriesPage);
|
||||
} catch (err: unknown) {
|
||||
@ -167,8 +230,7 @@ export default function SmsContactsPage() {
|
||||
try {
|
||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
|
||||
message.success(`Imported ${data.imported} contacts from phone`);
|
||||
setImportOpen(false);
|
||||
resetImportState();
|
||||
closeImportDrawer();
|
||||
fetchLists();
|
||||
fetchEntries(entriesPage);
|
||||
} catch {
|
||||
@ -189,13 +251,13 @@ export default function SmsContactsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openImportModal = (listId?: string) => {
|
||||
const openImportDrawer = (listId?: string) => {
|
||||
if (listId) {
|
||||
setImportListId(listId);
|
||||
} else if (filterListId) {
|
||||
setImportListId(filterListId);
|
||||
} else if (lists.length === 1) {
|
||||
setImportListId(lists[0].id);
|
||||
setImportListId(lists[0]!.id);
|
||||
} else {
|
||||
message.info('Select a list first, or use the filter dropdown to choose one');
|
||||
return;
|
||||
@ -277,8 +339,7 @@ export default function SmsContactsPage() {
|
||||
}
|
||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
|
||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||
setImportOpen(false);
|
||||
resetImportState();
|
||||
closeImportDrawer();
|
||||
fetchLists();
|
||||
fetchEntries(entriesPage);
|
||||
} catch {
|
||||
@ -392,7 +453,7 @@ export default function SmsContactsPage() {
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportModal(record.id)}>Import</Button>
|
||||
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportDrawer(record.id)}>Import</Button>
|
||||
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
@ -425,95 +486,209 @@ export default function SmsContactsPage() {
|
||||
|
||||
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
|
||||
|
||||
// Either drawer being open shifts content
|
||||
const anyDrawerOpen = createOpen || addContactOpen || importOpen;
|
||||
const drawerWidth = 480;
|
||||
const importDrawerWidth = 560;
|
||||
const activeDrawerWidth = importOpen ? importDrawerWidth : drawerWidth;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top toolbar */}
|
||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||
<Input.Search
|
||||
placeholder="Search phone, name, or email..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Lists"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 200 }}
|
||||
value={filterListId}
|
||||
onChange={(v) => setFilterListId(v)}
|
||||
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={() => openImportModal()}>Import</Button>
|
||||
</Space>
|
||||
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
{/* Top toolbar */}
|
||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||
<Input.Search
|
||||
placeholder="Search phone, name, or email..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Lists"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 200 }}
|
||||
value={filterListId}
|
||||
onChange={(v) => setFilterListId(v)}
|
||||
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
|
||||
<Button icon={<UserAddOutlined />} onClick={openAddContactDrawer}>Add Contact</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={() => openImportDrawer()}>Import</Button>
|
||||
</Space>
|
||||
|
||||
{/* Main contacts table */}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={contactColumns}
|
||||
dataSource={entries}
|
||||
loading={entriesLoading}
|
||||
pagination={{
|
||||
current: entriesPage,
|
||||
total: entriesTotal,
|
||||
pageSize: 50,
|
||||
onChange: (p) => fetchEntries(p),
|
||||
showTotal: (t) => `${t} contacts`,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
{/* Lists management panel */}
|
||||
<Collapse
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[{
|
||||
key: 'lists',
|
||||
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={listColumns}
|
||||
dataSource={lists}
|
||||
loading={listsLoading}
|
||||
pagination={{
|
||||
current: listsPage,
|
||||
total: listsTotal,
|
||||
pageSize: 50,
|
||||
onChange: (p) => fetchLists(p),
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
|
||||
{/* Lists management panel */}
|
||||
<Collapse
|
||||
style={{ marginTop: 16 }}
|
||||
items={[{
|
||||
key: 'lists',
|
||||
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={listColumns}
|
||||
dataSource={lists}
|
||||
loading={listsLoading}
|
||||
pagination={{
|
||||
current: listsPage,
|
||||
total: listsTotal,
|
||||
pageSize: 50,
|
||||
onChange: (p) => fetchLists(p),
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
{/* Selection action bar */}
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: 12,
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(22, 119, 255, 0.08)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Text strong>{selectedRowKeys.length} selected</Text>
|
||||
<Button
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setAddToListModalOpen(true)}
|
||||
>
|
||||
Add to List
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create List Modal */}
|
||||
<Modal
|
||||
{/* Main contacts table */}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={contactColumns}
|
||||
dataSource={entries}
|
||||
loading={entriesLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
pagination={{
|
||||
current: entriesPage,
|
||||
total: entriesTotal,
|
||||
pageSize: 50,
|
||||
onChange: (p) => fetchEntries(p),
|
||||
showTotal: (t) => `${t} contacts`,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create List Drawer */}
|
||||
<Drawer
|
||||
title="New Contact List"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
onClose={closeCreateDrawer}
|
||||
destroyOnHidden
|
||||
mask={false}
|
||||
width={drawerWidth}
|
||||
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeCreateDrawer}>Cancel</Button>
|
||||
<Button type="primary" loading={createSaving} onClick={() => createForm.submit()}>
|
||||
Create
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="e.g. Ward 6 Supporters" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Add Single Contact Drawer */}
|
||||
<Drawer
|
||||
title="Add Contact"
|
||||
open={addContactOpen}
|
||||
onClose={closeAddContactDrawer}
|
||||
destroyOnHidden
|
||||
mask={false}
|
||||
width={drawerWidth}
|
||||
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeAddContactDrawer}>Cancel</Button>
|
||||
<Button type="primary" loading={addContactSaving} onClick={() => addContactForm.submit()}>
|
||||
Add
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={addContactForm} layout="vertical" onFinish={handleAddContact}>
|
||||
<Form.Item name="listId" label="List" rules={[{ required: true, message: 'Select a contact list' }]}>
|
||||
<Select
|
||||
placeholder="Select a list"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={lists.map(l => ({ value: l.id, label: l.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Phone Number" rules={[{ required: true, message: 'Phone number is required' }]}>
|
||||
<Input placeholder="e.g. 5551234567" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input placeholder="e.g. Jane Doe" />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="Email">
|
||||
<Input placeholder="e.g. jane@example.com" type="email" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Add to List Modal */}
|
||||
<Modal
|
||||
title={`Add ${selectedRowKeys.length} contact${selectedRowKeys.length !== 1 ? 's' : ''} to list`}
|
||||
open={addToListModalOpen}
|
||||
onCancel={() => { setAddToListModalOpen(false); setAddToListTargetId(undefined); }}
|
||||
onOk={handleAddToList}
|
||||
okText="Add to List"
|
||||
confirmLoading={addToListLoading}
|
||||
okButtonProps={{ disabled: !addToListTargetId }}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select target list"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
value={addToListTargetId}
|
||||
onChange={setAddToListTargetId}
|
||||
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Unified Import Modal */}
|
||||
<Modal
|
||||
{/* Import Contacts Drawer */}
|
||||
<Drawer
|
||||
title={`Import Contacts${importListName ? ` → ${importListName}` : ''}`}
|
||||
open={importOpen}
|
||||
onCancel={() => { setImportOpen(false); resetImportState(); }}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
onClose={closeImportDrawer}
|
||||
destroyOnHidden
|
||||
mask={false}
|
||||
width={importDrawerWidth}
|
||||
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||
extra={
|
||||
<Button onClick={closeImportDrawer}>Close</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
onChange={() => setPreview(null)}
|
||||
@ -709,7 +884,7 @@ export default function SmsContactsPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -471,7 +471,7 @@ export default function SmsSetupPage() {
|
||||
<li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
|
||||
<li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
|
||||
<li>Set up Termux:Boot auto-start (if installed)</li>
|
||||
<li>Start the server with the watchdog</li>
|
||||
<li>Install runit service supervisor and start the server</li>
|
||||
</ul>
|
||||
<Paragraph style={{ marginTop: 8 }}>
|
||||
When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step.
|
||||
@ -514,10 +514,10 @@ export default function SmsSetupPage() {
|
||||
<div>
|
||||
<Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
|
||||
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
|
||||
<CmdLine comment="Update key, restart server" cmd={`export SMS_API_SECRET="${generatedKey}" && sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && pkill -f termux-sms-api-server.py && sleep 2 && cd ~/sms-server/android && bash sms-watchdog.sh`} />
|
||||
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
Or pull latest code and re-run full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup.sh ${generatedKey}` }}>cd ~/sms-server && git pull && bash android/setup.sh {generatedKey.substring(0, 8)}...</Text>
|
||||
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/setup-services.sh</Text>
|
||||
</Paragraph>
|
||||
</div>
|
||||
}
|
||||
@ -898,7 +898,7 @@ export default function SmsSetupPage() {
|
||||
The Termux API server is not responding. This can mean:
|
||||
</Paragraph>
|
||||
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
|
||||
<li><Text strong>Server not running</Text> — Android may have killed the Termux process. Restart it on the phone: <Text code>cd ~/sms-server/android && bash sms-watchdog.sh</Text></li>
|
||||
<li><Text strong>Server not running</Text> — Android may have killed the Termux process. Open Termux on the phone and check: <Text code>sv status sms-api</Text>. If down, run: <Text code>sv up sms-api</Text></li>
|
||||
<li><Text strong>API key mismatch</Text> — the key saved here doesn't match the phone's <Text code>SMS_API_SECRET</Text>. Click Reconfigure to generate a new key and update both sides.</li>
|
||||
<li><Text strong>Network issue</Text> — the phone may not be reachable. Check Tailscale is connected on the phone.</li>
|
||||
</ul>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
|
||||
import { Table, Button, Modal, Drawer, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
@ -67,8 +67,8 @@ export default function SmsTemplatesPage() {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||
|
||||
// Modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// Drawer
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -100,11 +100,13 @@ export default function SmsTemplatesPage() {
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
|
||||
|
||||
const closeDrawer = () => { setDrawerOpen(false); setLiveTemplate(''); };
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setLiveTemplate('');
|
||||
setModalOpen(true);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SmsMessageTemplate) => {
|
||||
@ -116,7 +118,7 @@ export default function SmsTemplatesPage() {
|
||||
category: record.category || undefined,
|
||||
});
|
||||
setLiveTemplate(record.template);
|
||||
setModalOpen(true);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openDuplicate = (record: SmsMessageTemplate) => {
|
||||
@ -128,7 +130,7 @@ export default function SmsTemplatesPage() {
|
||||
category: record.category || undefined,
|
||||
});
|
||||
setLiveTemplate(record.template);
|
||||
setModalOpen(true);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
|
||||
@ -141,7 +143,7 @@ export default function SmsTemplatesPage() {
|
||||
await api.post('/sms/templates', values);
|
||||
message.success('Template created');
|
||||
}
|
||||
setModalOpen(false);
|
||||
closeDrawer();
|
||||
form.resetFields();
|
||||
fetchTemplates();
|
||||
} catch (err: unknown) {
|
||||
@ -171,7 +173,7 @@ export default function SmsTemplatesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Computed values for modal
|
||||
// Computed values for drawer
|
||||
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
|
||||
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
|
||||
const charCount = liveTemplate.length;
|
||||
@ -200,17 +202,6 @@ export default function SmsTemplatesPage() {
|
||||
width: 120,
|
||||
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Template',
|
||||
dataIndex: 'template',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
render: (tmpl) => (
|
||||
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{tmpl.length > 80 ? `${tmpl.slice(0, 80)}...` : tmpl}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Variables',
|
||||
width: 200,
|
||||
@ -277,55 +268,66 @@ export default function SmsTemplatesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 480;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Template
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="Search templates..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Category"
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'campaign', label: 'Campaign' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
|
||||
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Template
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="Search templates..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Category"
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'campaign', label: 'Campaign' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={templates}
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||
size="middle"
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={templates}
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
<Drawer
|
||||
title={editingId ? 'Edit Template' : 'New Template'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); setLiveTemplate(''); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
destroyOnHidden
|
||||
mask={false}
|
||||
width={drawerWidth}
|
||||
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeDrawer}>Cancel</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => form.submit()}>
|
||||
{editingId ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
|
||||
@ -352,7 +354,7 @@ export default function SmsTemplatesPage() {
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
rows={5}
|
||||
rows={4}
|
||||
maxLength={1600}
|
||||
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
|
||||
onChange={(e) => setLiveTemplate(e.target.value)}
|
||||
@ -400,7 +402,7 @@ export default function SmsTemplatesPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1149,6 +1149,7 @@ export interface SiteSettings {
|
||||
enablePeople: boolean;
|
||||
enableSocial: boolean;
|
||||
enableMeet: boolean;
|
||||
enableMeetingPlanner: boolean;
|
||||
autoSyncPeopleToMap: boolean;
|
||||
// SMS connection config (only present from admin endpoint)
|
||||
smsTermuxApiUrl?: string;
|
||||
@ -2252,7 +2253,7 @@ export interface DashboardRecentSignupsResult {
|
||||
|
||||
export interface UnifiedCalendarItem {
|
||||
id: string;
|
||||
type: 'shift' | 'event';
|
||||
type: 'shift' | 'event' | 'poll';
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
@ -2264,6 +2265,10 @@ export interface UnifiedCalendarItem {
|
||||
currentVolunteers?: number;
|
||||
gancioEventId?: number;
|
||||
gancioUrl?: string;
|
||||
pollId?: string;
|
||||
pollSlug?: string;
|
||||
pollStatus?: SchedulingPollStatus;
|
||||
pollVoteCount?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedCalendarResponse {
|
||||
@ -2740,3 +2745,111 @@ export interface ListmonkCampaignsData {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// --- Scheduling Polls (Meeting Planner) ---
|
||||
|
||||
export type SchedulingPollStatus = 'OPEN' | 'CLOSED' | 'FINALIZED' | 'CANCELLED';
|
||||
export type PollVoteValue = 'YES' | 'IF_NEED_BE' | 'NO';
|
||||
|
||||
export const POLL_STATUS_COLORS: Record<SchedulingPollStatus, string> = {
|
||||
OPEN: 'green',
|
||||
CLOSED: 'orange',
|
||||
FINALIZED: 'blue',
|
||||
CANCELLED: 'red',
|
||||
};
|
||||
|
||||
export const POLL_STATUS_LABELS: Record<SchedulingPollStatus, string> = {
|
||||
OPEN: 'Open',
|
||||
CLOSED: 'Closed',
|
||||
FINALIZED: 'Finalized',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
export const VOTE_VALUE_COLORS: Record<PollVoteValue, string> = {
|
||||
YES: '#52c41a',
|
||||
IF_NEED_BE: '#faad14',
|
||||
NO: '#d9d9d9',
|
||||
};
|
||||
|
||||
export const VOTE_VALUE_LABELS: Record<PollVoteValue, string> = {
|
||||
YES: 'Yes',
|
||||
IF_NEED_BE: 'If Need Be',
|
||||
NO: 'No',
|
||||
};
|
||||
|
||||
export interface SchedulingPollOption {
|
||||
id: string;
|
||||
pollId: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
startTime: string; // HH:MM
|
||||
endTime: string; // HH:MM
|
||||
sortOrder: number;
|
||||
votes?: SchedulingPollVote[];
|
||||
_count?: { votes: number };
|
||||
// Aggregated vote counts (from API)
|
||||
yesCount?: number;
|
||||
ifNeedBeCount?: number;
|
||||
noCount?: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface SchedulingPollVote {
|
||||
id: string;
|
||||
pollId: string;
|
||||
optionId: string;
|
||||
userId: string | null;
|
||||
voterName: string;
|
||||
voterEmail: string | null;
|
||||
voterToken: string | null;
|
||||
value: PollVoteValue;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchedulingPollComment {
|
||||
id: string;
|
||||
pollId: string;
|
||||
userId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SchedulingPoll {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
status: SchedulingPollStatus;
|
||||
timezone: string;
|
||||
finalizedOptionId: string | null;
|
||||
finalizedOption: SchedulingPollOption | null;
|
||||
convertedShiftId: string | null;
|
||||
convertedGancioEventId: number | null;
|
||||
votingDeadline: string | null;
|
||||
allowAnonymous: boolean;
|
||||
notifyOnVote: boolean;
|
||||
createdByUserId: string;
|
||||
createdBy?: { id: string; name: string | null; email: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
options?: SchedulingPollOption[];
|
||||
votes?: SchedulingPollVote[];
|
||||
comments?: SchedulingPollComment[];
|
||||
_count?: { options: number; votes: number; comments: number };
|
||||
}
|
||||
|
||||
export interface PollsListResponse {
|
||||
polls: SchedulingPoll[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface PollDetailResponse extends SchedulingPoll {
|
||||
options: SchedulingPollOption[];
|
||||
comments: SchedulingPollComment[];
|
||||
voters: Array<{
|
||||
name: string;
|
||||
votes: Record<string, PollVoteValue>;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,127 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SchedulingPollStatus" AS ENUM ('OPEN', 'CLOSED', 'FINALIZED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PollVoteValue" AS ENUM ('YES', 'IF_NEED_BE', 'NO');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_meeting_planner" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduling_polls" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"location" TEXT,
|
||||
"status" "SchedulingPollStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'America/Edmonton',
|
||||
"finalized_option_id" TEXT,
|
||||
"converted_shift_id" TEXT,
|
||||
"converted_gancio_event_id" INTEGER,
|
||||
"voting_deadline" TIMESTAMP(3),
|
||||
"allow_anonymous" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notify_on_vote" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "scheduling_polls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduling_poll_options" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"start_time" TEXT NOT NULL,
|
||||
"end_time" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "scheduling_poll_options_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduling_poll_votes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"option_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"voter_name" TEXT NOT NULL,
|
||||
"voter_email" TEXT,
|
||||
"voter_token" TEXT,
|
||||
"value" "PollVoteValue" NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "scheduling_poll_votes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduling_poll_comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"author_name" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "scheduling_poll_comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduling_polls_slug_key" ON "scheduling_polls"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduling_polls_finalized_option_id_key" ON "scheduling_polls"("finalized_option_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduling_polls_converted_shift_id_key" ON "scheduling_polls"("converted_shift_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_polls_created_by_user_id_idx" ON "scheduling_polls"("created_by_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_polls_status_idx" ON "scheduling_polls"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_poll_options_poll_id_idx" ON "scheduling_poll_options"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_poll_votes_poll_id_idx" ON "scheduling_poll_votes"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_user_id_key" ON "scheduling_poll_votes"("option_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_voter_token_key" ON "scheduling_poll_votes"("option_id", "voter_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_poll_comments_poll_id_idx" ON "scheduling_poll_comments"("poll_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_finalized_option_id_fkey" FOREIGN KEY ("finalized_option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_converted_shift_id_fkey" FOREIGN KEY ("converted_shift_id") REFERENCES "shifts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_options" ADD CONSTRAINT "scheduling_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -155,6 +155,11 @@ model User {
|
||||
// People CRM
|
||||
contact Contact? @relation("UserContact")
|
||||
|
||||
// Scheduling polls
|
||||
schedulingPollsCreated SchedulingPoll[] @relation("PollCreator")
|
||||
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@ -686,6 +691,9 @@ model Shift {
|
||||
canvassVisits CanvassVisit[]
|
||||
canvassSessions CanvassSession[]
|
||||
|
||||
// Scheduling poll conversion
|
||||
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
|
||||
|
||||
@@index([cutId])
|
||||
@@index([seriesId])
|
||||
@@map("shifts")
|
||||
@ -889,6 +897,7 @@ model SiteSettings {
|
||||
enablePeople Boolean @default(false) @map("enable_people")
|
||||
enableSocial Boolean @default(false) @map("enable_social")
|
||||
enableMeet Boolean @default(false) @map("enable_meet")
|
||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||
|
||||
// SMS connection config (overrides env vars when non-empty)
|
||||
@ -4294,3 +4303,103 @@ model Meeting {
|
||||
|
||||
@@map("meetings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SCHEDULING POLLS (Meeting Planner)
|
||||
// ============================================================================
|
||||
|
||||
enum SchedulingPollStatus {
|
||||
OPEN
|
||||
CLOSED
|
||||
FINALIZED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PollVoteValue {
|
||||
YES
|
||||
IF_NEED_BE
|
||||
NO
|
||||
}
|
||||
|
||||
model SchedulingPoll {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
description String? @db.Text
|
||||
location String?
|
||||
status SchedulingPollStatus @default(OPEN)
|
||||
timezone String @default("America/Edmonton")
|
||||
finalizedOptionId String? @unique @map("finalized_option_id")
|
||||
finalizedOption SchedulingPollOption? @relation("FinalizedOption", fields: [finalizedOptionId], references: [id], onDelete: SetNull)
|
||||
convertedShiftId String? @unique @map("converted_shift_id")
|
||||
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
|
||||
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
||||
votingDeadline DateTime? @map("voting_deadline")
|
||||
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
||||
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
options SchedulingPollOption[] @relation("PollOptions")
|
||||
votes SchedulingPollVote[] @relation("PollVotes")
|
||||
comments SchedulingPollComment[] @relation("PollComments")
|
||||
|
||||
@@index([createdByUserId])
|
||||
@@index([status])
|
||||
@@map("scheduling_polls")
|
||||
}
|
||||
|
||||
model SchedulingPollOption {
|
||||
id String @id @default(cuid())
|
||||
pollId String @map("poll_id")
|
||||
poll SchedulingPoll @relation("PollOptions", fields: [pollId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
startTime String @map("start_time") // HH:MM
|
||||
endTime String @map("end_time") // HH:MM
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
|
||||
votes SchedulingPollVote[] @relation("OptionVotes")
|
||||
|
||||
// Reverse 1:1 for finalized option
|
||||
finalizedForPoll SchedulingPoll? @relation("FinalizedOption")
|
||||
|
||||
@@index([pollId])
|
||||
@@map("scheduling_poll_options")
|
||||
}
|
||||
|
||||
model SchedulingPollVote {
|
||||
id String @id @default(cuid())
|
||||
pollId String @map("poll_id")
|
||||
poll SchedulingPoll @relation("PollVotes", fields: [pollId], references: [id], onDelete: Cascade)
|
||||
optionId String @map("option_id")
|
||||
option SchedulingPollOption @relation("OptionVotes", fields: [optionId], references: [id], onDelete: Cascade)
|
||||
userId String? @map("user_id")
|
||||
user User? @relation("PollVoter", fields: [userId], references: [id], onDelete: SetNull)
|
||||
voterName String @map("voter_name")
|
||||
voterEmail String? @map("voter_email")
|
||||
voterToken String? @map("voter_token") // anonymous edit access (cuid)
|
||||
value PollVoteValue
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([optionId, userId])
|
||||
@@unique([optionId, voterToken])
|
||||
@@index([pollId])
|
||||
@@map("scheduling_poll_votes")
|
||||
}
|
||||
|
||||
model SchedulingPollComment {
|
||||
id String @id @default(cuid())
|
||||
pollId String @map("poll_id")
|
||||
poll SchedulingPoll @relation("PollComments", fields: [pollId], references: [id], onDelete: Cascade)
|
||||
userId String? @map("user_id")
|
||||
user User? @relation("PollCommenter", fields: [userId], references: [id], onDelete: SetNull)
|
||||
authorName String @map("author_name")
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([pollId])
|
||||
@@map("scheduling_poll_comments")
|
||||
}
|
||||
|
||||
@ -445,6 +445,23 @@ async function main() {
|
||||
showTitle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-scheduling-poll',
|
||||
type: 'scheduling-poll',
|
||||
label: 'Scheduling Poll',
|
||||
category: 'Influence',
|
||||
sortOrder: 17,
|
||||
schema: {
|
||||
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||
showComments: { type: 'boolean', label: 'Show Comments', default: true },
|
||||
title: { type: 'string', label: 'Section Title', default: 'Vote on a Meeting Time' },
|
||||
},
|
||||
defaults: {
|
||||
pollSlug: '',
|
||||
showComments: true,
|
||||
title: 'Vote on a Meeting Time',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const block of defaultBlocks) {
|
||||
|
||||
@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
export interface UnifiedCalendarItem {
|
||||
id: string;
|
||||
type: 'shift' | 'event';
|
||||
type: 'shift' | 'event' | 'poll';
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
startTime: string; // HH:MM
|
||||
@ -23,6 +23,11 @@ export interface UnifiedCalendarItem {
|
||||
// Event-specific
|
||||
gancioEventId?: number;
|
||||
gancioUrl?: string;
|
||||
// Poll-specific
|
||||
pollId?: string;
|
||||
pollSlug?: string;
|
||||
pollStatus?: string;
|
||||
pollVoteCount?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedCalendarResponse {
|
||||
@ -50,10 +55,11 @@ export const unifiedCalendarService = {
|
||||
// Set end to end of day
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
// Fetch shifts and Gancio events in parallel
|
||||
const [shifts, gancioEvents] = await Promise.all([
|
||||
// Fetch shifts, Gancio events, and polls in parallel
|
||||
const [shifts, gancioEvents, pollItems] = await Promise.all([
|
||||
this.fetchShifts(start, end),
|
||||
this.fetchGancioEvents(start, end),
|
||||
this.fetchPolls(start, end),
|
||||
]);
|
||||
|
||||
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
|
||||
@ -99,7 +105,7 @@ export const unifiedCalendarService = {
|
||||
});
|
||||
|
||||
// Merge and group by date
|
||||
const allItems = [...shiftItems, ...eventItems];
|
||||
const allItems = [...shiftItems, ...eventItems, ...pollItems];
|
||||
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
|
||||
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
|
||||
@ -163,6 +169,53 @@ export const unifiedCalendarService = {
|
||||
});
|
||||
},
|
||||
|
||||
async fetchPolls(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
|
||||
try {
|
||||
const polls = await prisma.schedulingPoll.findMany({
|
||||
where: {
|
||||
status: { in: ['OPEN', 'FINALIZED'] },
|
||||
options: {
|
||||
some: { date: { gte: start, lte: end } },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
_count: { select: { votes: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const items: UnifiedCalendarItem[] = [];
|
||||
for (const poll of polls) {
|
||||
// For finalized polls, only show the selected option
|
||||
const optionsToShow = poll.finalizedOptionId
|
||||
? poll.options.filter(o => o.id === poll.finalizedOptionId)
|
||||
: poll.options;
|
||||
|
||||
for (const opt of optionsToShow) {
|
||||
const optDate = opt.date.toISOString().split('T')[0];
|
||||
items.push({
|
||||
id: `poll-${poll.id}-${opt.id}`,
|
||||
type: 'poll',
|
||||
title: poll.title,
|
||||
date: optDate,
|
||||
startTime: opt.startTime,
|
||||
endTime: opt.endTime,
|
||||
location: poll.location,
|
||||
tags: ['scheduling', 'poll'],
|
||||
pollId: poll.id,
|
||||
pollSlug: poll.slug,
|
||||
pollStatus: poll.status,
|
||||
pollVoteCount: poll._count.votes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch polls for calendar:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
|
||||
try {
|
||||
const events = await gancioClient.fetchPublicEvents();
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { redis } from '../../config/redis';
|
||||
|
||||
export const pollVoteRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:poll-vote:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many vote submissions, please try again later',
|
||||
code: 'POLL_VOTE_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pollCommentRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:poll-comment:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many comments, please try again later',
|
||||
code: 'POLL_COMMENT_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal file
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { meetingPlannerService } from './meeting-planner.service';
|
||||
import {
|
||||
createPollSchema,
|
||||
updatePollSchema,
|
||||
addOptionsSchema,
|
||||
submitVotesSchema,
|
||||
submitCommentSchema,
|
||||
finalizePollSchema,
|
||||
convertToShiftSchema,
|
||||
listPollsSchema,
|
||||
} from './meeting-planner.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
|
||||
// --- Admin Router ---
|
||||
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
|
||||
// List polls
|
||||
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await meetingPlannerService.findAll(req.query as any);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get poll detail
|
||||
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await meetingPlannerService.findById(id);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Create poll
|
||||
adminRouter.post('/', validate(createPollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await meetingPlannerService.create(req.body, req.user!.id);
|
||||
res.status(201).json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update poll
|
||||
adminRouter.put('/:id', validate(updatePollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await meetingPlannerService.update(id, req.body);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete poll
|
||||
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
await meetingPlannerService.delete(id);
|
||||
res.json({ message: 'Poll deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Add options
|
||||
adminRouter.post('/:id/options', validate(addOptionsSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await meetingPlannerService.addOptions(id, req.body);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Remove option
|
||||
adminRouter.delete('/:id/options/:optionId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const optionId = req.params.optionId as string;
|
||||
const poll = await meetingPlannerService.removeOption(id, optionId);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Finalize poll
|
||||
adminRouter.post('/:id/finalize', validate(finalizePollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await meetingPlannerService.finalize(id, req.body);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Convert to shift
|
||||
adminRouter.post('/:id/convert-to-shift', validate(convertToShiftSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const shift = await meetingPlannerService.convertToShift(id, req.body);
|
||||
res.json(shift);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Convert to Gancio event
|
||||
adminRouter.post('/:id/convert-to-event', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const result = await meetingPlannerService.convertToEvent(id);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete comment
|
||||
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
await meetingPlannerService.deleteComment(id, commentId);
|
||||
res.json({ message: 'Comment deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Public Router ---
|
||||
|
||||
const publicRouter = Router();
|
||||
|
||||
// Public listing of open polls
|
||||
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await meetingPlannerService.findAll({
|
||||
status: 'OPEN',
|
||||
limit: 50,
|
||||
page: 1,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// View poll by slug
|
||||
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const poll = await meetingPlannerService.findBySlug(slug);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Submit votes
|
||||
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
// Try to get userId from optional auth header
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { env } = await import('../../config/env');
|
||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||
userId = decoded.id;
|
||||
}
|
||||
} catch { /* not authenticated, that's fine */ }
|
||||
|
||||
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Add comment
|
||||
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { env } = await import('../../config/env');
|
||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||
userId = decoded.id;
|
||||
}
|
||||
} catch { /* not authenticated */ }
|
||||
|
||||
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
|
||||
res.status(201).json(comment);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { adminRouter as meetingPlannerAdminRouter, publicRouter as meetingPlannerPublicRouter };
|
||||
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal file
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
import { SchedulingPollStatus, PollVoteValue } from '@prisma/client';
|
||||
|
||||
export const createPollSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
location: z.string().max(500).optional(),
|
||||
timezone: z.string().default('America/Edmonton'),
|
||||
allowAnonymous: z.boolean().optional().default(true),
|
||||
notifyOnVote: z.boolean().optional().default(true),
|
||||
votingDeadline: z.string().datetime().optional(),
|
||||
options: z.array(z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options'),
|
||||
});
|
||||
|
||||
export const updatePollSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
location: z.string().max(500).nullable().optional(),
|
||||
timezone: z.string().optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
notifyOnVote: z.boolean().optional(),
|
||||
votingDeadline: z.string().datetime().nullable().optional(),
|
||||
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||
});
|
||||
|
||||
export const addOptionsSchema = z.object({
|
||||
options: z.array(z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||
})).min(1).max(20),
|
||||
});
|
||||
|
||||
export const submitVotesSchema = z.object({
|
||||
voterName: z.string().min(1, 'Name is required').max(100),
|
||||
voterEmail: z.string().email().max(200).optional(),
|
||||
voterToken: z.string().optional(),
|
||||
votes: z.array(z.object({
|
||||
optionId: z.string().min(1),
|
||||
value: z.nativeEnum(PollVoteValue),
|
||||
})).min(1, 'At least one vote required'),
|
||||
});
|
||||
|
||||
export const submitCommentSchema = z.object({
|
||||
authorName: z.string().min(1, 'Name is required').max(100),
|
||||
content: z.string().min(1, 'Comment is required').max(2000),
|
||||
});
|
||||
|
||||
export const finalizePollSchema = z.object({
|
||||
optionId: z.string().min(1, 'Option ID is required'),
|
||||
});
|
||||
|
||||
export const convertToShiftSchema = z.object({
|
||||
maxVolunteers: z.number().int().min(1).default(10),
|
||||
isPublic: z.boolean().optional().default(true),
|
||||
cutId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listPollsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||
});
|
||||
|
||||
export type CreatePollInput = z.infer<typeof createPollSchema>;
|
||||
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
|
||||
export type AddOptionsInput = z.infer<typeof addOptionsSchema>;
|
||||
export type SubmitVotesInput = z.infer<typeof submitVotesSchema>;
|
||||
export type SubmitCommentInput = z.infer<typeof submitCommentSchema>;
|
||||
export type FinalizePollInput = z.infer<typeof finalizePollSchema>;
|
||||
export type ConvertToShiftInput = z.infer<typeof convertToShiftSchema>;
|
||||
export type ListPollsInput = z.infer<typeof listPollsSchema>;
|
||||
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal file
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal file
@ -0,0 +1,491 @@
|
||||
import { Prisma, PollVoteValue } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { generateSlug } from '../../utils/slug';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type {
|
||||
CreatePollInput,
|
||||
UpdatePollInput,
|
||||
AddOptionsInput,
|
||||
SubmitVotesInput,
|
||||
SubmitCommentInput,
|
||||
FinalizePollInput,
|
||||
ConvertToShiftInput,
|
||||
ListPollsInput,
|
||||
} from './meeting-planner.schemas';
|
||||
|
||||
const pollInclude = {
|
||||
options: { orderBy: { sortOrder: 'asc' as const } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
const pollDetailInclude = {
|
||||
options: {
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
include: {
|
||||
votes: { orderBy: { createdAt: 'asc' as const } },
|
||||
},
|
||||
},
|
||||
comments: { orderBy: { createdAt: 'asc' as const } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
|
||||
return options.map((opt) => {
|
||||
let yesCount = 0;
|
||||
let ifNeedBeCount = 0;
|
||||
let noCount = 0;
|
||||
for (const v of opt.votes) {
|
||||
if (v.value === 'YES') yesCount++;
|
||||
else if (v.value === 'IF_NEED_BE') ifNeedBeCount++;
|
||||
else noCount++;
|
||||
}
|
||||
return {
|
||||
...opt,
|
||||
yesCount,
|
||||
ifNeedBeCount,
|
||||
noCount,
|
||||
score: yesCount * 2 + ifNeedBeCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function groupVotesByVoter(votes: Array<{
|
||||
voterName: string;
|
||||
voterToken: string | null;
|
||||
userId: string | null;
|
||||
optionId: string;
|
||||
value: PollVoteValue;
|
||||
}>) {
|
||||
const voterMap = new Map<string, { name: string; votes: Record<string, PollVoteValue> }>();
|
||||
for (const vote of votes) {
|
||||
const key = vote.userId || vote.voterToken || vote.voterName;
|
||||
if (!voterMap.has(key)) {
|
||||
voterMap.set(key, { name: vote.voterName, votes: {} });
|
||||
}
|
||||
voterMap.get(key)!.votes[vote.optionId] = vote.value;
|
||||
}
|
||||
return Array.from(voterMap.values());
|
||||
}
|
||||
|
||||
export const meetingPlannerService = {
|
||||
async findAll(filters: ListPollsInput) {
|
||||
const { page, limit, search, status } = filters;
|
||||
const where: Prisma.SchedulingPollWhereInput = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [polls, total] = await Promise.all([
|
||||
prisma.schedulingPoll.findMany({
|
||||
where,
|
||||
include: pollInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.schedulingPoll.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
polls,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id },
|
||||
include: pollDetailInclude,
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
|
||||
const optionsWithCounts = aggregateVotes(poll.options);
|
||||
const allVotes = poll.options.flatMap((opt) =>
|
||||
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
|
||||
);
|
||||
const voters = groupVotesByVoter(allVotes);
|
||||
|
||||
return { ...poll, options: optionsWithCounts, voters };
|
||||
},
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { slug },
|
||||
include: pollDetailInclude,
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
|
||||
const optionsWithCounts = aggregateVotes(poll.options);
|
||||
const allVotes = poll.options.flatMap((opt) =>
|
||||
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
|
||||
);
|
||||
const voters = groupVotesByVoter(allVotes);
|
||||
|
||||
return { ...poll, options: optionsWithCounts, voters };
|
||||
},
|
||||
|
||||
async create(data: CreatePollInput, userId: string) {
|
||||
const slug = generateSlug(data.title);
|
||||
|
||||
const poll = await prisma.schedulingPoll.create({
|
||||
data: {
|
||||
slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
location: data.location,
|
||||
timezone: data.timezone,
|
||||
allowAnonymous: data.allowAnonymous,
|
||||
notifyOnVote: data.notifyOnVote,
|
||||
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
||||
createdByUserId: userId,
|
||||
options: {
|
||||
create: data.options.map((opt, i) => ({
|
||||
date: new Date(opt.date),
|
||||
startTime: opt.startTime,
|
||||
endTime: opt.endTime,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: pollInclude,
|
||||
});
|
||||
|
||||
return poll;
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdatePollInput) {
|
||||
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Poll not found');
|
||||
|
||||
const updateData: Prisma.SchedulingPollUncheckedUpdateInput = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.location !== undefined) updateData.location = data.location;
|
||||
if (data.timezone !== undefined) updateData.timezone = data.timezone;
|
||||
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
|
||||
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
|
||||
if (data.votingDeadline !== undefined) {
|
||||
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
||||
}
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
|
||||
return prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: pollInclude,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Poll not found');
|
||||
await prisma.schedulingPoll.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async addOptions(pollId: string, data: AddOptionsInput) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id: pollId },
|
||||
include: { options: true },
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot add options to a non-open poll');
|
||||
|
||||
const maxSort = poll.options.reduce((max, o) => Math.max(max, o.sortOrder), -1);
|
||||
|
||||
await prisma.schedulingPollOption.createMany({
|
||||
data: data.options.map((opt, i) => ({
|
||||
pollId,
|
||||
date: new Date(opt.date),
|
||||
startTime: opt.startTime,
|
||||
endTime: opt.endTime,
|
||||
sortOrder: maxSort + 1 + i,
|
||||
})),
|
||||
});
|
||||
|
||||
return this.findById(pollId);
|
||||
},
|
||||
|
||||
async removeOption(pollId: string, optionId: string) {
|
||||
const option = await prisma.schedulingPollOption.findFirst({
|
||||
where: { id: optionId, pollId },
|
||||
});
|
||||
if (!option) throw new AppError(404, 'Option not found');
|
||||
|
||||
await prisma.schedulingPollOption.delete({ where: { id: optionId } });
|
||||
return this.findById(pollId);
|
||||
},
|
||||
|
||||
async submitVotes(slug: string, data: SubmitVotesInput, userId?: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { slug },
|
||||
include: { options: true, createdBy: { select: { email: true, name: true } } },
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.status !== 'OPEN') throw new AppError(400, 'This poll is no longer accepting votes');
|
||||
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
|
||||
throw new AppError(400, 'The voting deadline has passed');
|
||||
}
|
||||
if (!poll.allowAnonymous && !userId) {
|
||||
throw new AppError(401, 'This poll requires authentication to vote');
|
||||
}
|
||||
|
||||
// Validate all optionIds belong to this poll
|
||||
const optionIds = new Set(poll.options.map((o) => o.id));
|
||||
for (const vote of data.votes) {
|
||||
if (!optionIds.has(vote.optionId)) {
|
||||
throw new AppError(400, `Invalid option ID: ${vote.optionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token for anonymous voters (or reuse existing)
|
||||
const voterToken = userId ? null : (data.voterToken || generateVoterToken());
|
||||
|
||||
// Upsert votes in a transaction
|
||||
await prisma.$transaction(
|
||||
data.votes.map((vote) => {
|
||||
if (userId) {
|
||||
return prisma.schedulingPollVote.upsert({
|
||||
where: { optionId_userId: { optionId: vote.optionId, userId } },
|
||||
create: {
|
||||
pollId: poll.id,
|
||||
optionId: vote.optionId,
|
||||
userId,
|
||||
voterName: data.voterName,
|
||||
voterEmail: data.voterEmail,
|
||||
value: vote.value,
|
||||
},
|
||||
update: {
|
||||
voterName: data.voterName,
|
||||
voterEmail: data.voterEmail,
|
||||
value: vote.value,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return prisma.schedulingPollVote.upsert({
|
||||
where: { optionId_voterToken: { optionId: vote.optionId, voterToken: voterToken! } },
|
||||
create: {
|
||||
pollId: poll.id,
|
||||
optionId: vote.optionId,
|
||||
voterName: data.voterName,
|
||||
voterEmail: data.voterEmail,
|
||||
voterToken,
|
||||
value: vote.value,
|
||||
},
|
||||
update: {
|
||||
voterName: data.voterName,
|
||||
voterEmail: data.voterEmail,
|
||||
value: vote.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Notify organizer
|
||||
if (poll.notifyOnVote) {
|
||||
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
|
||||
logger.error('Failed to send vote notification', { error: err })
|
||||
);
|
||||
}
|
||||
|
||||
return { voterToken };
|
||||
},
|
||||
|
||||
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
|
||||
return prisma.schedulingPollComment.create({
|
||||
data: {
|
||||
pollId: poll.id,
|
||||
userId,
|
||||
authorName: data.authorName,
|
||||
content: data.content,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async deleteComment(pollId: string, commentId: string) {
|
||||
const comment = await prisma.schedulingPollComment.findFirst({
|
||||
where: { id: commentId, pollId },
|
||||
});
|
||||
if (!comment) throw new AppError(404, 'Comment not found');
|
||||
await prisma.schedulingPollComment.delete({ where: { id: commentId } });
|
||||
},
|
||||
|
||||
async finalize(id: string, data: FinalizePollInput) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id },
|
||||
include: { options: true },
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.status === 'FINALIZED') throw new AppError(400, 'Poll is already finalized');
|
||||
|
||||
const option = poll.options.find((o) => o.id === data.optionId);
|
||||
if (!option) throw new AppError(400, 'Option not found in this poll');
|
||||
|
||||
const updated = await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
finalizedOptionId: data.optionId,
|
||||
},
|
||||
include: pollDetailInclude,
|
||||
});
|
||||
|
||||
// Notify all voters with emails
|
||||
this.notifyVotersFinalized(updated).catch((err) =>
|
||||
logger.error('Failed to send finalization notifications', { error: err })
|
||||
);
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async convertToShift(id: string, data: ConvertToShiftInput) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id },
|
||||
include: { options: true },
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
|
||||
if (poll.convertedShiftId) throw new AppError(400, 'Poll has already been converted to a shift');
|
||||
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
|
||||
|
||||
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
|
||||
if (!option) throw new AppError(400, 'Finalized option not found');
|
||||
|
||||
const [shift] = await prisma.$transaction([
|
||||
prisma.shift.create({
|
||||
data: {
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
date: option.date,
|
||||
startTime: option.startTime,
|
||||
endTime: option.endTime,
|
||||
location: poll.location,
|
||||
maxVolunteers: data.maxVolunteers,
|
||||
isPublic: data.isPublic,
|
||||
cutId: data.cutId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: { convertedShiftId: shift.id },
|
||||
});
|
||||
|
||||
return shift;
|
||||
},
|
||||
|
||||
async convertToEvent(id: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id },
|
||||
include: { options: true },
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
|
||||
if (poll.convertedGancioEventId) throw new AppError(400, 'Poll has already been converted to an event');
|
||||
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
|
||||
|
||||
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
|
||||
if (!option) throw new AppError(400, 'Finalized option not found');
|
||||
|
||||
// Dynamically import gancio client to avoid hard dependency
|
||||
const { gancioClient } = await import('../../services/gancio.client');
|
||||
const eventId = await gancioClient.createEvent({
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
location: poll.location,
|
||||
date: option.date,
|
||||
startTime: option.startTime,
|
||||
endTime: option.endTime,
|
||||
});
|
||||
|
||||
if (!eventId) throw new AppError(500, 'Failed to create Gancio event');
|
||||
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: { convertedGancioEventId: eventId },
|
||||
});
|
||||
|
||||
return { gancioEventId: eventId };
|
||||
},
|
||||
|
||||
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
|
||||
try {
|
||||
await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: `New vote on "${pollTitle}"`,
|
||||
html: `<p><strong>${escapeHtml(voterName)}</strong> voted on your scheduling poll "<strong>${escapeHtml(pollTitle)}</strong>".</p>`,
|
||||
text: `${voterName} voted on your scheduling poll "${pollTitle}".`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to send vote notification email', { error: err });
|
||||
}
|
||||
},
|
||||
|
||||
async notifyVotersFinalized(poll: any) {
|
||||
const finalOption = poll.options.find((o: any) => o.id === poll.finalizedOptionId);
|
||||
if (!finalOption) return;
|
||||
|
||||
const dateStr = new Date(finalOption.date).toLocaleDateString('en-CA', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
const timeStr = `${finalOption.startTime} - ${finalOption.endTime}`;
|
||||
|
||||
// Collect unique voter emails
|
||||
const voterEmails = new Set<string>();
|
||||
for (const opt of poll.options) {
|
||||
for (const vote of opt.votes) {
|
||||
if (vote.voterEmail) voterEmails.add(vote.voterEmail);
|
||||
}
|
||||
}
|
||||
|
||||
for (const email of voterEmails) {
|
||||
try {
|
||||
await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: `Date confirmed for "${poll.title}"`,
|
||||
html: `<p>The date for "<strong>${escapeHtml(poll.title)}</strong>" has been confirmed:</p>
|
||||
<p><strong>${dateStr}</strong><br/>${timeStr}</p>
|
||||
${poll.location ? `<p>Location: ${escapeHtml(poll.location)}</p>` : ''}`,
|
||||
text: `The date for "${poll.title}" has been confirmed:\n${dateStr}\n${timeStr}${poll.location ? `\nLocation: ${poll.location}` : ''}`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to send finalization email', { error: err, email });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function generateVoterToken(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let token = '';
|
||||
for (let i = 0; i < 24; i++) {
|
||||
token += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@ -56,6 +56,7 @@ export const updateSiteSettingsSchema = z.object({
|
||||
enablePeople: z.boolean().optional(),
|
||||
enableSocial: z.boolean().optional(),
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableMeetingPlanner: z.boolean().optional(),
|
||||
autoSyncPeopleToMap: z.boolean().optional(),
|
||||
|
||||
// SMS connection config
|
||||
|
||||
@ -3,7 +3,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { smsContactsService } from './sms-contacts.service';
|
||||
import { createContactListSchema, updateContactListSchema, createContactEntrySchema } from './sms-contacts.schemas';
|
||||
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -144,6 +144,14 @@ router.post('/:id/entries', validate(createContactEntrySchema), async (req, res,
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/sms/contacts/:id/entries/bulk — add multiple entries at once
|
||||
router.post('/:id/entries/bulk', validate(bulkAddEntriesSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await smsContactsService.addEntriesBulk(req.params.id as string, req.body.entries);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
|
||||
router.delete('/:id/entries/:entryId', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@ -15,6 +15,15 @@ export const createContactEntrySchema = z.object({
|
||||
customFields: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const bulkAddEntriesSchema = z.object({
|
||||
entries: z.array(z.object({
|
||||
phone: z.string().min(7).max(20),
|
||||
name: z.string().max(200).optional(),
|
||||
email: z.string().email().max(200).optional(),
|
||||
})).min(1).max(1000),
|
||||
});
|
||||
|
||||
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
|
||||
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
|
||||
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
|
||||
export type BulkAddEntriesInput = z.infer<typeof bulkAddEntriesSchema>;
|
||||
|
||||
@ -197,6 +197,32 @@ export const smsContactsService = {
|
||||
return entry;
|
||||
},
|
||||
|
||||
async addEntriesBulk(listId: string, entries: { phone: string; name?: string; email?: string }[]) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const phone = normalizePhone(entry.phone);
|
||||
if (!phone) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await prisma.smsContactListEntry.upsert({
|
||||
where: { listId_phone: { listId, phone } },
|
||||
create: { listId, phone, name: entry.name, email: entry.email },
|
||||
update: { name: entry.name, email: entry.email },
|
||||
});
|
||||
imported++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.smsContactListEntry.count({ where: { listId } });
|
||||
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
|
||||
|
||||
return { imported, skipped, total };
|
||||
},
|
||||
|
||||
async deleteEntry(id: string) {
|
||||
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
|
||||
// Update total count
|
||||
|
||||
@ -31,6 +31,7 @@ import { mapSettingsRouter } from './modules/map/settings/settings.routes';
|
||||
import { qrRouter } from './modules/qr/qr.routes';
|
||||
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
||||
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
|
||||
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
|
||||
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
||||
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
||||
import { blocksRouter } from './modules/pages/blocks.routes';
|
||||
@ -211,6 +212,8 @@ app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (au
|
||||
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
|
||||
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
|
||||
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
||||
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
||||
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
|
||||
app.use('/api/qr', qrRouter); // QR code generation (public)
|
||||
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
||||
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 2457662e12b5fd4c2e62a22503f3ffd93dc5e303
|
||||
Subproject commit d9be9c961d4ffcf32abac81fd32589abfb146fd3
|
||||
189
mkdocs/docs/assets/js/scheduling-poll.js
Normal file
189
mkdocs/docs/assets/js/scheduling-poll.js
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Scheduling Poll Block Hydration for MkDocs
|
||||
*
|
||||
* Scans for .scheduling-poll-block elements, fetches poll data from the API,
|
||||
* and renders a read-only poll summary with a "Vote Now" link to the full
|
||||
* interactive voting page on the app.
|
||||
*
|
||||
* Follows the gancio-events.js hydration pattern.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function getApiUrl() {
|
||||
// env-config.js injects these globals
|
||||
if (window.PAYMENT_API_URL) return window.PAYMENT_API_URL;
|
||||
if (window.API_URL) return window.API_URL;
|
||||
|
||||
var host = window.location.hostname;
|
||||
if (host !== 'localhost' && host.indexOf('.') !== -1) {
|
||||
var parts = host.split('.');
|
||||
var base = parts.slice(-2).join('.');
|
||||
return window.location.protocol + '//api.' + base;
|
||||
}
|
||||
return 'http://localhost:4000';
|
||||
}
|
||||
|
||||
function getAppUrl() {
|
||||
if (window.APP_URL) return window.APP_URL;
|
||||
|
||||
var host = window.location.hostname;
|
||||
if (host !== 'localhost' && host.indexOf('.') !== -1) {
|
||||
var parts = host.split('.');
|
||||
var base = parts.slice(-2).join('.');
|
||||
return window.location.protocol + '//app.' + base;
|
||||
}
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
var STATUS_COLORS = {
|
||||
OPEN: '#52c41a',
|
||||
CLOSED: '#fa8c16',
|
||||
FINALIZED: '#1890ff',
|
||||
CANCELLED: '#ff4d4f',
|
||||
};
|
||||
|
||||
var STATUS_LABELS = {
|
||||
OPEN: 'Open for Voting',
|
||||
CLOSED: 'Closed',
|
||||
FINALIZED: 'Date Confirmed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
function formatDate(dateStr) {
|
||||
var d = new Date(dateStr + 'T00:00:00');
|
||||
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return days[d.getDay()] + ', ' + months[d.getMonth()] + ' ' + d.getDate();
|
||||
}
|
||||
|
||||
function hydrateBlocks() {
|
||||
var blocks = document.querySelectorAll('.scheduling-poll-block');
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
var apiUrl = getApiUrl();
|
||||
var appUrl = getAppUrl();
|
||||
|
||||
blocks.forEach(function (block) {
|
||||
// Skip if already hydrated
|
||||
if (block.getAttribute('data-hydrated') === 'true') return;
|
||||
|
||||
var slug = block.getAttribute('data-poll-slug');
|
||||
if (!slug) return;
|
||||
|
||||
var showComments = block.getAttribute('data-show-comments') !== 'false';
|
||||
var title = block.getAttribute('data-title') || '';
|
||||
|
||||
block.setAttribute('data-hydrated', 'true');
|
||||
block.innerHTML = '<div style="text-align:center; padding:20px; opacity:0.6;">Loading poll...</div>';
|
||||
|
||||
fetch(apiUrl + '/api/meeting-planner/public/' + encodeURIComponent(slug))
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('Poll not found');
|
||||
return res.json();
|
||||
})
|
||||
.then(function (poll) {
|
||||
var statusColor = STATUS_COLORS[poll.status] || '#666';
|
||||
var statusLabel = STATUS_LABELS[poll.status] || poll.status;
|
||||
var isFinalized = poll.status === 'FINALIZED';
|
||||
var options = poll.options || [];
|
||||
var bestScore = 0;
|
||||
options.forEach(function (o) {
|
||||
if ((o.score || 0) > bestScore) bestScore = o.score || 0;
|
||||
});
|
||||
|
||||
var html = '';
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
html += '<h2 style="text-align:center; margin:0 0 8px; font-size:1.5rem;">' + title + '</h2>';
|
||||
}
|
||||
|
||||
// Poll title + status
|
||||
html += '<h3 style="margin:0 0 8px; font-size:1.2rem;">' + poll.title + '</h3>';
|
||||
if (poll.description) {
|
||||
html += '<p style="opacity:0.75; margin:0 0 8px; line-height:1.5;">' + poll.description + '</p>';
|
||||
}
|
||||
html += '<div style="margin-bottom:12px;">';
|
||||
html += '<span style="display:inline-block; padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600; background:' + statusColor + '22; color:' + statusColor + '; border:1px solid ' + statusColor + '44;">' + statusLabel + '</span>';
|
||||
if (poll.location) {
|
||||
html += ' <span style="font-size:13px; opacity:0.65; margin-left:8px;">' + poll.location + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Finalized banner
|
||||
if (isFinalized && poll.finalizedOption) {
|
||||
html += '<div style="padding:10px 14px; border-radius:6px; background:rgba(82,196,26,0.1); border:1px solid rgba(82,196,26,0.3); margin-bottom:12px; color:#52c41a;">';
|
||||
html += '<strong>Confirmed:</strong> ' + formatDate(poll.finalizedOption.date) + ' — ' + poll.finalizedOption.startTime + '–' + poll.finalizedOption.endTime;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Options table
|
||||
if (options.length > 0) {
|
||||
html += '<div style="overflow-x:auto; margin-bottom:12px;">';
|
||||
html += '<table style="width:100%; border-collapse:collapse; font-size:13px;">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:left;">Date / Time</th>';
|
||||
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Yes</th>';
|
||||
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">If Need Be</th>';
|
||||
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">No</th>';
|
||||
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Score</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
options.forEach(function (opt) {
|
||||
var isBest = bestScore > 0 && (opt.score || 0) === bestScore;
|
||||
var isConfirmed = isFinalized && poll.finalizedOptionId === opt.id;
|
||||
var rowBg = isConfirmed ? 'rgba(82,196,26,0.08)' : isBest ? 'rgba(82,196,26,0.04)' : '';
|
||||
html += '<tr style="background:' + rowBg + ';">';
|
||||
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1);">';
|
||||
html += '<strong>' + formatDate(opt.date) + '</strong><br><span style="font-size:11px; opacity:0.7;">' + opt.startTime + '–' + opt.endTime + '</span>';
|
||||
if (isConfirmed) html += ' <span style="color:#52c41a; font-size:10px; font-weight:600;">✓</span>';
|
||||
html += '</td>';
|
||||
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#52c41a;">' + (opt.yesCount || 0) + '</td>';
|
||||
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#faad14;">' + (opt.ifNeedBeCount || 0) + '</td>';
|
||||
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#d9d9d9;">' + (opt.noCount || 0) + '</td>';
|
||||
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; font-weight:600;">' + (opt.score || 0) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Comments count
|
||||
if (showComments && poll.comments && poll.comments.length > 0) {
|
||||
html += '<p style="font-size:13px; opacity:0.65;">' + poll.comments.length + ' comment' + (poll.comments.length !== 1 ? 's' : '') + '</p>';
|
||||
}
|
||||
|
||||
// Vote Now CTA
|
||||
if (poll.status === 'OPEN') {
|
||||
html += '<div style="text-align:center; margin-top:16px;">';
|
||||
html += '<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" ';
|
||||
html += 'style="display:inline-block; padding:12px 32px; background:#fa8c16; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
|
||||
html += 'Vote Now →</a></div>';
|
||||
}
|
||||
|
||||
block.innerHTML = '<div style="max-width:700px; margin:0 auto;">' + html + '</div>';
|
||||
})
|
||||
.catch(function () {
|
||||
block.innerHTML = '<div style="text-align:center; padding:24px; opacity:0.5;">' +
|
||||
'<p>Poll unavailable</p>' +
|
||||
'<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" style="color:#fa8c16;">View poll →</a>' +
|
||||
'</div>';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial hydration
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hydrateBlocks);
|
||||
} else {
|
||||
hydrateBlocks();
|
||||
}
|
||||
|
||||
// Re-hydrate on MkDocs SPA navigation
|
||||
if (typeof document$ !== 'undefined') {
|
||||
document$.subscribe(function () {
|
||||
setTimeout(hydrateBlocks, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@ -95,10 +95,23 @@ The setup script automatically:
|
||||
- Saves the API key to `~/.bashrc`
|
||||
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
|
||||
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
|
||||
- Starts the SMS server with the watchdog (auto-restarts on crash)
|
||||
- Starts the SMS server
|
||||
|
||||
When done, note the **Phone URL** displayed (e.g. `http://100.64.0.5:5001`).
|
||||
|
||||
#### Recommended: Install Service Supervisor
|
||||
|
||||
After initial setup, install `termux-services` for reliable process management. This uses runit, a proper UNIX service supervisor that automatically restarts the server if it crashes:
|
||||
|
||||
```bash
|
||||
cd ~/sms-server && bash android/setup-services.sh
|
||||
```
|
||||
|
||||
This registers two supervised services:
|
||||
|
||||
- **sms-api** — Flask SMS API server (port 5001)
|
||||
- **sshd-custom** — SSH daemon for remote management (port 8022)
|
||||
|
||||
### Step 4: Prevent Android from Killing Termux
|
||||
|
||||
This is **required** for the server to run reliably in the background:
|
||||
@ -115,17 +128,35 @@ To pull the latest server code and re-run setup:
|
||||
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
|
||||
```
|
||||
|
||||
### Manual Control
|
||||
### Service Management
|
||||
|
||||
If you installed `termux-services` (recommended):
|
||||
|
||||
```bash
|
||||
# Check if the server is running
|
||||
curl http://127.0.0.1:5001/health
|
||||
# Check status
|
||||
sv status sms-api
|
||||
|
||||
# Restart
|
||||
sv restart sms-api
|
||||
|
||||
# Stop
|
||||
sv down sms-api
|
||||
|
||||
# Start
|
||||
sv up sms-api
|
||||
|
||||
# View logs
|
||||
tail -f ~/logs/sms-api.log
|
||||
|
||||
# Stop the server
|
||||
pkill -f sms-watchdog.sh && pkill -f termux-sms-api-server.py
|
||||
# Health check
|
||||
curl http://127.0.0.1:5001/health
|
||||
```
|
||||
|
||||
Without `termux-services` (legacy watchdog):
|
||||
|
||||
```bash
|
||||
# Check if the server is running
|
||||
curl http://127.0.0.1:5001/health
|
||||
|
||||
# Restart manually
|
||||
cd ~/sms-server/android && bash sms-watchdog.sh
|
||||
@ -353,8 +384,8 @@ export SMS_API_SECRET='correct-key-from-admin-panel'
|
||||
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
|
||||
|
||||
# Restart the server
|
||||
pkill -f termux-sms-api-server.py
|
||||
cd ~/sms-server/android && python termux-sms-api-server.py
|
||||
sv restart sms-api
|
||||
# Or without termux-services: pkill -f termux-sms-api-server.py && cd ~/sms-server/android && python termux-sms-api-server.py
|
||||
```
|
||||
|
||||
### SMS not sending
|
||||
@ -372,12 +403,13 @@ cd ~/sms-server/android && python termux-sms-api-server.py
|
||||
|
||||
**Symptoms:** Server stops after some time, especially when phone screen is off.
|
||||
|
||||
**Fix:** Disable battery optimization for Termux:
|
||||
**Fix:**
|
||||
|
||||
1. Android Settings → Apps → Termux → Battery → **Unrestricted**
|
||||
2. Lock Termux in recent apps (long-press app card → Lock)
|
||||
3. Some phones: Settings → Battery → Battery Optimization → find Termux → Don't Optimize
|
||||
4. Samsung: Settings → Device Care → Battery → App Power Management → add Termux to "Never sleeping apps"
|
||||
1. **Install `termux-services`** (if not already): `bash ~/sms-server/android/setup-services.sh` — this uses runit, a proper service supervisor that auto-restarts the server immediately if it crashes
|
||||
2. **Disable battery optimization:** Android Settings → Apps → Termux → Battery → **Unrestricted**
|
||||
3. **Lock Termux in recent apps** — long-press the app card → Lock/Pin
|
||||
4. Samsung: also add Termux, Termux:API, and Termux:Boot to **Settings → Device Care → Battery → Never Sleeping Apps**
|
||||
5. **Acquire wake lock:** Run `termux-wake-lock` in Termux (included in boot script)
|
||||
|
||||
### Server won't start — "Missing SMS_API_SECRET"
|
||||
|
||||
@ -425,6 +457,6 @@ cd ~/sms-server
|
||||
git pull
|
||||
|
||||
# Restart the server
|
||||
pkill -f termux-sms-api-server.py
|
||||
cd android && python termux-sms-api-server.py
|
||||
sv restart sms-api
|
||||
# Or without termux-services: pkill -f termux-sms-api-server.py && cd android && python termux-sms-api-server.py
|
||||
```
|
||||
|
||||
@ -183,6 +183,7 @@ Listmonk handles newsletter/marketing campaigns. Sync with the main platform is
|
||||
| `LISTMONK_ADMIN_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
|
||||
| `LISTMONK_ADMIN_PASSWORD` | — | Same as `LISTMONK_API_TOKEN`. |
|
||||
| `LISTMONK_SYNC_ENABLED` | `false` | :material-flask: Set to `true` to sync participants/locations/users to Listmonk lists. |
|
||||
| `LISTMONK_WEBHOOK_SECRET` | *(empty)* | Shared secret for Listmonk webhook callbacks. |
|
||||
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
|
||||
|
||||
??? example "Listmonk SMTP settings"
|
||||
@ -253,6 +254,7 @@ Self-hosted Git repository. Optional service.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GITEA_URL` | `http://gitea-changemaker:3000` | Internal container URL for Gitea. |
|
||||
| `GITEA_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
|
||||
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
|
||||
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
|
||||
@ -264,6 +266,18 @@ Self-hosted Git repository. Optional service.
|
||||
| `GITEA_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
|
||||
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
|
||||
|
||||
??? example "Gitea Docs Comments"
|
||||
Enable comments on MkDocs documentation pages, backed by Gitea Issues.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GITEA_COMMENTS_ENABLED` | `false` | :material-flask: Enable comments on MkDocs pages. |
|
||||
| `GITEA_API_TOKEN` | *(empty)* | Personal access token with repo write scope. Create in Gitea → Settings → Applications. |
|
||||
| `GITEA_COMMENTS_REPO_OWNER` | *(empty)* | Gitea username that owns the docs-comments repo. |
|
||||
| `GITEA_COMMENTS_REPO_NAME` | `docs-comments` | Repository name (auto-created via admin setup). |
|
||||
| `GITEA_OAUTH_CLIENT_ID` | *(empty)* | OAuth2 application client ID (create in Gitea → Settings → Applications → OAuth2). |
|
||||
| `GITEA_OAUTH_CLIENT_SECRET` | *(empty)* | OAuth2 application client secret. |
|
||||
|
||||
---
|
||||
|
||||
## n8n (Workflow Automation) :material-tune-variant:
|
||||
@ -382,6 +396,48 @@ Self-hosted event management platform. Uses the shared PostgreSQL database (auto
|
||||
|
||||
---
|
||||
|
||||
## Jitsi Meet (Video Conferencing) :material-flask:
|
||||
|
||||
Self-hosted video conferencing with JWT authentication. Integrates with Rocket.Chat for in-channel video calls.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ENABLE_MEET` | `false` | :material-flask: Set to `true` to enable the Jitsi Meet integration. The initial default; once saved in admin Settings, the DB value is authoritative. |
|
||||
| `JITSI_APP_ID` | `changemaker` | JWT application ID. Must match across Jitsi Prosody, Rocket.Chat app settings, and `JWT_ACCEPTED_ISSUERS`/`JWT_ACCEPTED_AUDIENCES`. |
|
||||
| `JITSI_APP_SECRET` | — | :material-alert-circle:{ .text-red } JWT secret for signing Jitsi tokens. Generate with `openssl rand -hex 32`. Shared between Jitsi Prosody, Rocket.Chat, and the API. |
|
||||
| `JITSI_JICOFO_AUTH_PASSWORD` | — | Internal XMPP password for Jicofo (conference focus). Generate with `openssl rand -hex 16`. |
|
||||
| `JITSI_JVB_AUTH_PASSWORD` | — | Internal XMPP password for JVB (video bridge). Generate with `openssl rand -hex 16`. |
|
||||
| `JITSI_EMBED_PORT` | `8893` | Port for iframe embedding in admin. |
|
||||
| `JITSI_URL` | `http://jitsi-web-changemaker:80` | Internal container URL. |
|
||||
| `JVB_ADVERTISE_IP` | *(empty)* | Server's public IP address. **Required in production** for NAT traversal so remote participants can connect. |
|
||||
| `JVB_PORT` | `10000` | UDP port for media traffic. Must be open in your firewall. |
|
||||
|
||||
!!! warning "Production requirements"
|
||||
- `JVB_ADVERTISE_IP` must be set to your server's public IP for calls to work outside the local network.
|
||||
- Port `10000/udp` must be open in your firewall for media traffic.
|
||||
- Calls must go through the production domain (not localhost) for SSL/JWT to work.
|
||||
|
||||
---
|
||||
|
||||
## SMS Campaigns (Termux Android Bridge) :material-flask:
|
||||
|
||||
Send SMS messages via an Android phone running the Termux API server. The phone acts as an SMS gateway.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ENABLE_SMS` | `false` | :material-flask: Set to `true` to enable SMS campaigns. The initial default; once saved in admin Settings, the DB value is authoritative. |
|
||||
| `TERMUX_API_URL` | `http://10.0.0.193:5001` | URL of the Termux API server running on the Android phone. |
|
||||
| `TERMUX_API_KEY` | *(empty)* | API key for authenticating with the Termux server (HMAC auth via `X-API-Key` header). |
|
||||
| `SMS_DELAY_BETWEEN_MS` | `3000` | Delay between sending individual SMS messages (ms). Prevents carrier throttling. |
|
||||
| `SMS_MAX_RETRIES` | `3` | Maximum retry attempts for failed SMS sends. |
|
||||
| `SMS_RESPONSE_SYNC_INTERVAL_MS` | `30000` | How often to poll the phone's inbox for responses (ms). |
|
||||
| `SMS_DEVICE_MONITOR_INTERVAL_MS` | `30000` | How often to check device health — battery, connectivity (ms). |
|
||||
|
||||
!!! tip "GUI configuration"
|
||||
The Termux API URL and API key can also be configured from **Admin → Settings → SMS**. Database values override these env vars when set.
|
||||
|
||||
---
|
||||
|
||||
## MailHog (Development Email)
|
||||
|
||||
| Variable | Default | Description |
|
||||
@ -474,6 +530,20 @@ docker compose --profile monitoring up -d
|
||||
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
|
||||
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
|
||||
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
|
||||
| `GRAFANA_EMBED_PORT` | `8894` | Port for iframe embedding Grafana in admin. |
|
||||
| `ALERTMANAGER_EMBED_PORT` | `8895` | Port for iframe embedding Alertmanager in admin. |
|
||||
|
||||
---
|
||||
|
||||
## Bunker Ops (Fleet Management) :material-flask:
|
||||
|
||||
Remote metrics push for managing multiple Changemaker Lite instances from a central monitoring server.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INSTANCE_LABEL` | *(empty)* | Unique label for this instance (used as a Prometheus metric label). Falls back to `DOMAIN` if empty. |
|
||||
| `BUNKER_OPS_ENABLED` | `false` | :material-flask: Enable remote metrics push to a central VictoriaMetrics server. |
|
||||
| `BUNKER_OPS_REMOTE_WRITE_URL` | *(empty)* | VictoriaMetrics `remote_write` endpoint (e.g., `https://ops.example.com/api/v1/write`). |
|
||||
|
||||
---
|
||||
|
||||
@ -516,6 +586,11 @@ echo "ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
||||
|
||||
# Gancio
|
||||
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
||||
|
||||
# Jitsi Meet
|
||||
echo "JITSI_APP_SECRET=$(openssl rand -hex 32)"
|
||||
echo "JITSI_JICOFO_AUTH_PASSWORD=$(openssl rand -hex 16)"
|
||||
echo "JITSI_JVB_AUTH_PASSWORD=$(openssl rand -hex 16)"
|
||||
```
|
||||
|
||||
!!! tip
|
||||
@ -551,6 +626,8 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
||||
ENABLE_MEDIA_FEATURES=true
|
||||
ENABLE_PAYMENTS=true
|
||||
ENABLE_CHAT=true
|
||||
ENABLE_MEET=true
|
||||
ENABLE_SMS=true
|
||||
LISTMONK_SYNC_ENABLED=true
|
||||
GANCIO_SYNC_ENABLED=true
|
||||
LISTMONK_DB_PASSWORD=...
|
||||
@ -564,6 +641,10 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
||||
VAULTWARDEN_ADMIN_TOKEN=...
|
||||
ROCKETCHAT_ADMIN_PASSWORD=...
|
||||
GANCIO_ADMIN_PASSWORD=...
|
||||
JITSI_APP_SECRET=...
|
||||
JITSI_JICOFO_AUTH_PASSWORD=...
|
||||
JITSI_JVB_AUTH_PASSWORD=...
|
||||
JVB_ADVERTISE_IP=your.public.ip.here
|
||||
EMAIL_TEST_MODE=false
|
||||
SMTP_HOST=smtp.your-provider.com
|
||||
SMTP_PORT=587
|
||||
|
||||
@ -87,6 +87,7 @@ extra_javascript:
|
||||
- assets/js/image-gallery.js
|
||||
- assets/js/gancio-events.js
|
||||
- assets/js/payment-widgets.js
|
||||
- assets/js/scheduling-poll.js
|
||||
- javascripts/ad-widgets.js
|
||||
- javascripts/docs-comments.js
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user