Compare commits
10 Commits
06ce9dac1b
...
e95bc8883e
| Author | SHA1 | Date | |
|---|---|---|---|
| e95bc8883e | |||
| aaba7df97d | |||
| d835f0837b | |||
| d98488c1dc | |||
| 41d86782b4 | |||
| 46fc92fab8 | |||
| 1f2ce681a6 | |||
| 98acd4917d | |||
| 18997da3eb | |||
| ce590ccae8 |
@ -329,13 +329,15 @@ ALERTMANAGER_EMBED_PORT=8895
|
|||||||
|
|
||||||
# --- SMS Campaigns (Termux Android Bridge) ---
|
# --- SMS Campaigns (Termux Android Bridge) ---
|
||||||
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
|
# 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
|
ENABLE_SMS=false
|
||||||
TERMUX_API_URL=http://10.0.0.193:5001
|
TERMUX_API_URL=http://100.x.x.x:5001
|
||||||
TERMUX_API_KEY=
|
TERMUX_API_KEY=
|
||||||
SMS_DELAY_BETWEEN_MS=3000
|
SMS_DELAY_BETWEEN_MS=3000
|
||||||
SMS_MAX_RETRIES=3
|
SMS_MAX_RETRIES=3
|
||||||
SMS_RESPONSE_SYNC_INTERVAL_MS=30000
|
SMS_RESPONSE_SYNC_INTERVAL_MS=120000
|
||||||
SMS_DEVICE_MONITOR_INTERVAL_MS=30000
|
SMS_DEVICE_MONITOR_INTERVAL_MS=300000
|
||||||
|
|
||||||
# --- Monitoring (only used with --profile monitoring) ---
|
# --- Monitoring (only used with --profile monitoring) ---
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
|
|||||||
@ -14,6 +14,34 @@
|
|||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#1a1025">
|
<body style="margin:0;background:#1a1025">
|
||||||
<div id="root"></div>
|
<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>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -107,6 +107,7 @@ import SmsDashboardPage from '@/pages/sms/SmsDashboardPage';
|
|||||||
import SmsContactsPage from '@/pages/sms/SmsContactsPage';
|
import SmsContactsPage from '@/pages/sms/SmsContactsPage';
|
||||||
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
|
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
|
||||||
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
|
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
|
||||||
|
import SmsTemplatesPage from '@/pages/sms/SmsTemplatesPage';
|
||||||
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
|
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
|
||||||
import PeoplePage from '@/pages/PeoplePage';
|
import PeoplePage from '@/pages/PeoplePage';
|
||||||
import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
||||||
@ -114,7 +115,11 @@ import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
|
|||||||
import SocialGraphPage from '@/pages/social/SocialGraphPage';
|
import SocialGraphPage from '@/pages/social/SocialGraphPage';
|
||||||
import SocialModerationPage from '@/pages/social/SocialModerationPage';
|
import SocialModerationPage from '@/pages/social/SocialModerationPage';
|
||||||
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
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 JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
|
||||||
function RoleAwareRedirect() {
|
function RoleAwareRedirect() {
|
||||||
@ -223,6 +228,14 @@ export default function App() {
|
|||||||
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<EventsPage />} />
|
<Route index element={<EventsPage />} />
|
||||||
</Route>
|
</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 */}
|
{/* Public meeting join page — feature-gated */}
|
||||||
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MeetingJoinPage />} />
|
<Route index element={<MeetingJoinPage />} />
|
||||||
@ -306,6 +319,7 @@ export default function App() {
|
|||||||
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
|
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
|
||||||
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
|
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
|
||||||
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
|
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
|
||||||
|
<Route path="/volunteer/*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Redirect old canvass routes to map with query param */}
|
{/* Redirect old canvass routes to map with query param */}
|
||||||
@ -615,6 +629,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="sms/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<SmsTemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
element={
|
||||||
@ -663,6 +685,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="meeting-planner"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<MeetingPlannerPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="map/cuts"
|
path="map/cuts"
|
||||||
element={
|
element={
|
||||||
@ -807,8 +837,12 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/" element={<RoleAwareRedirect />} />
|
||||||
|
<Route path="*" element={<PublicLayout />}>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<RoleAwareRedirect />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AntApp>
|
</AntApp>
|
||||||
|
|||||||
@ -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: '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: '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: '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: '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: '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' },
|
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
|
||||||
@ -212,6 +213,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
{ key: '/app/sms/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
|
{ key: '/app/sms/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
|
||||||
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
|
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
|
||||||
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
|
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
|
||||||
|
{ key: '/app/sms/templates', icon: <FileTextOutlined />, label: 'SMS Templates' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
@ -248,7 +250,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
children: [
|
children: [
|
||||||
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
||||||
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
|
{ 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/cuts', icon: <ScissorOutlined />, label: 'Areas' },
|
||||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||||
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||||
@ -256,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) {
|
if (settings?.enableMediaFeatures !== false) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'media-submenu',
|
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',
|
enableEvents: 'Events',
|
||||||
enableSocial: 'Social Connections',
|
enableSocial: 'Social Connections',
|
||||||
enableMeet: 'Video Meetings',
|
enableMeet: 'Video Meetings',
|
||||||
|
enableMeetingPlanner: 'Meeting Planner',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatureGateProps {
|
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;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -549,6 +549,28 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</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:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
|
||||||
{visible.map(item => {
|
{visible.map(item => {
|
||||||
|
const isPoll = item.type === 'poll';
|
||||||
const isShift = item.type === 'shift';
|
const isShift = item.type === 'shift';
|
||||||
const bg = isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
|
const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : 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 border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
|
||||||
const accent = isShift ? '#1890ff' : '#52c41a';
|
const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@ -195,6 +196,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
|
|
||||||
const renderItemCard = (item: UnifiedCalendarItem) => {
|
const renderItemCard = (item: UnifiedCalendarItem) => {
|
||||||
const isShift = item.type === 'shift';
|
const isShift = item.type === 'shift';
|
||||||
|
const isPoll = item.type === 'poll';
|
||||||
const spotsLeft = isShift && item.maxVolunteers
|
const spotsLeft = isShift && item.maxVolunteers
|
||||||
? item.maxVolunteers - (item.currentVolunteers || 0)
|
? item.maxVolunteers - (item.currentVolunteers || 0)
|
||||||
: null;
|
: null;
|
||||||
@ -202,13 +204,16 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
|
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
|
||||||
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
|
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||||
|
const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green';
|
||||||
|
const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `4px solid ${isShift ? '#1890ff' : '#52c41a'}`,
|
borderLeft: `4px solid ${borderColor}`,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -221,8 +226,8 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color={isShift ? 'blue' : 'green'} style={{ margin: 0, fontSize: 11 }}>
|
<Tag color={tagColor} style={{ margin: 0, fontSize: 11 }}>
|
||||||
{isShift ? 'Shift' : 'Event'}
|
{tagLabel}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -286,7 +291,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isShift && item.gancioUrl && (
|
{!isShift && !isPoll && item.gancioUrl && (
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@ -298,6 +303,19 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
View
|
View
|
||||||
</Button>
|
</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>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Modal, Typography, Grid, Spin, theme, type GlobalToken } from 'antd';
|
import { Modal, Typography, Grid, Spin, Tag, theme, type GlobalToken } from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
@ -40,11 +40,16 @@ import {
|
|||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
FileMarkdownOutlined,
|
FileMarkdownOutlined,
|
||||||
|
ContactsOutlined,
|
||||||
|
ScissorOutlined,
|
||||||
|
StarFilled,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useCommandPaletteStore } from '@/stores/command-palette.store';
|
import { useCommandPaletteStore } from '@/stores/command-palette.store';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { useFavoritesStore } from '@/stores/favorites.store';
|
||||||
import { useCommandIndex } from './useCommandIndex';
|
import { useCommandIndex } from './useCommandIndex';
|
||||||
import { useEntitySearch } from './useEntitySearch';
|
import { useEntitySearch } from './useEntitySearch';
|
||||||
|
import { parseQuery, SCOPE_DEFINITIONS } from './scopeFilter';
|
||||||
import type { CommandItem, EntityResult, CommandCategory } from './types';
|
import type { CommandItem, EntityResult, CommandCategory } from './types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@ -88,6 +93,8 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
TagOutlined: <TagOutlined />,
|
TagOutlined: <TagOutlined />,
|
||||||
FileMarkdownOutlined: <FileMarkdownOutlined />,
|
FileMarkdownOutlined: <FileMarkdownOutlined />,
|
||||||
UserOutlined: <UserOutlined />,
|
UserOutlined: <UserOutlined />,
|
||||||
|
ContactsOutlined: <ContactsOutlined />,
|
||||||
|
ScissorOutlined: <ScissorOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<CommandCategory, string> = {
|
const CATEGORY_LABELS: Record<CommandCategory, string> = {
|
||||||
@ -104,6 +111,7 @@ type FlatItem =
|
|||||||
export default function CommandPalette() {
|
export default function CommandPalette() {
|
||||||
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
|
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const { favorites } = useFavoritesStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -115,8 +123,14 @@ export default function CommandPalette() {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Parse query for scope prefix
|
||||||
|
const parsed = useMemo(() => parseQuery(query), [query]);
|
||||||
|
|
||||||
const { search, allItems } = useCommandIndex();
|
const { search, allItems } = useCommandIndex();
|
||||||
const { results: entityResults, loading: entityLoading } = useEntitySearch(query);
|
const { results: entityResults, loading: entityLoading } = useEntitySearch(
|
||||||
|
parsed.strippedQuery,
|
||||||
|
parsed.scope?.entityTypes,
|
||||||
|
);
|
||||||
|
|
||||||
// Only render inside admin panel
|
// Only render inside admin panel
|
||||||
const isAdminRoute = location.pathname.startsWith('/app');
|
const isAdminRoute = location.pathname.startsWith('/app');
|
||||||
@ -152,37 +166,61 @@ export default function CommandPalette() {
|
|||||||
|
|
||||||
// Build command results from search
|
// Build command results from search
|
||||||
const commandResults = useMemo(() => {
|
const commandResults = useMemo(() => {
|
||||||
if (!query) return [];
|
if (!parsed.strippedQuery && !parsed.scope) return [];
|
||||||
return search(query);
|
// If scope is active but query is empty, show all items in that scope's groups
|
||||||
}, [query, search]);
|
if (parsed.scope && !parsed.strippedQuery) {
|
||||||
|
return allItems.filter((item) => parsed.scope!.groups.includes(item.group));
|
||||||
|
}
|
||||||
|
return search(parsed.strippedQuery, parsed.scope?.groups);
|
||||||
|
}, [parsed, search, allItems]);
|
||||||
|
|
||||||
// Build recent items list (when no query)
|
// Build favorite items (when no query)
|
||||||
|
const favoriteCommandItems = useMemo(() => {
|
||||||
|
if (query) return [];
|
||||||
|
const favoriteSet = new Set(favorites);
|
||||||
|
return allItems.filter((item) => favoriteSet.has(item.path));
|
||||||
|
}, [query, favorites, allItems]);
|
||||||
|
|
||||||
|
// Set of favorited paths for badge rendering
|
||||||
|
const favoritePaths = useMemo(() => new Set(favorites), [favorites]);
|
||||||
|
|
||||||
|
// Build recent items list (when no query), excluding favorites
|
||||||
const recentCommandItems = useMemo(() => {
|
const recentCommandItems = useMemo(() => {
|
||||||
if (query) return [];
|
if (query) return [];
|
||||||
|
const favIds = new Set(favoriteCommandItems.map((i) => i.id));
|
||||||
return recentItems
|
return recentItems
|
||||||
.map((id) => allItems.find((item) => item.id === id))
|
.map((id) => allItems.find((item) => item.id === id))
|
||||||
.filter((item): item is CommandItem => !!item);
|
.filter((item): item is CommandItem => !!item && !favIds.has(item.id));
|
||||||
}, [query, recentItems, allItems]);
|
}, [query, recentItems, allItems, favoriteCommandItems]);
|
||||||
|
|
||||||
// Flatten all results into a single list for keyboard navigation
|
// Flatten all results into a single list for keyboard navigation.
|
||||||
|
// Order: Favorites → Recent → Pages → Entities → Actions → Settings
|
||||||
const flatList = useMemo((): FlatItem[] => {
|
const flatList = useMemo((): FlatItem[] => {
|
||||||
const items: FlatItem[] = [];
|
const items: FlatItem[] = [];
|
||||||
if (!query) {
|
if (!query) {
|
||||||
// Show recents
|
for (const item of favoriteCommandItems) {
|
||||||
|
items.push({ type: 'command', item });
|
||||||
|
}
|
||||||
for (const item of recentCommandItems) {
|
for (const item of recentCommandItems) {
|
||||||
items.push({ type: 'command', item });
|
items.push({ type: 'command', item });
|
||||||
}
|
}
|
||||||
|
} else if (parsed.showScopeList) {
|
||||||
|
// No items when showing scope selector
|
||||||
} else {
|
} else {
|
||||||
// Show search results
|
const pages = commandResults.filter((i) => i.category === 'navigation');
|
||||||
for (const item of commandResults) {
|
const actions = commandResults.filter((i) => i.category === 'action');
|
||||||
items.push({ type: 'command', item });
|
const settings = commandResults.filter((i) => i.category === 'settings');
|
||||||
}
|
// Pages first (exact name matches ranked highest by MiniSearch)
|
||||||
for (const item of entityResults) {
|
for (const item of pages) items.push({ type: 'command', item });
|
||||||
items.push({ type: 'entity', item });
|
// Entities next (database records like doc files, campaigns, users)
|
||||||
}
|
for (const item of entityResults) items.push({ type: 'entity', item });
|
||||||
|
// Actions after entities
|
||||||
|
for (const item of actions) items.push({ type: 'command', item });
|
||||||
|
// Settings last
|
||||||
|
for (const item of settings) items.push({ type: 'command', item });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [query, recentCommandItems, commandResults, entityResults]);
|
}, [query, favoriteCommandItems, recentCommandItems, parsed.showScopeList, commandResults, entityResults]);
|
||||||
|
|
||||||
// Clamp selected index
|
// Clamp selected index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -229,19 +267,40 @@ export default function CommandPalette() {
|
|||||||
[flatList, selectedIndex, handleSelect],
|
[flatList, selectedIndex, handleSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!shouldRender) return null;
|
// Group commands by category for display, in priority order:
|
||||||
|
// Pages (navigation) → Actions → Settings (least frequent)
|
||||||
|
// Entities are interleaved between pages and actions in the render phase.
|
||||||
|
// NOTE: These hooks must stay above the early return to satisfy Rules of Hooks
|
||||||
|
const CATEGORY_ORDER: (CommandCategory | 'favorites' | 'recent')[] = [
|
||||||
|
'favorites', 'recent', 'navigation', 'action', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
// Group commands by category for display
|
|
||||||
const groupedCommands = useMemo(() => {
|
const groupedCommands = useMemo(() => {
|
||||||
const items = query ? commandResults : recentCommandItems;
|
const unordered = new Map<string, CommandItem[]>();
|
||||||
const groups = new Map<string, CommandItem[]>();
|
|
||||||
for (const item of items) {
|
if (!query) {
|
||||||
const key = query ? item.category : 'recent';
|
// No-query mode: favorites then recents
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (favoriteCommandItems.length > 0) {
|
||||||
groups.get(key)!.push(item);
|
unordered.set('favorites', favoriteCommandItems);
|
||||||
}
|
}
|
||||||
return groups;
|
if (recentCommandItems.length > 0) {
|
||||||
}, [query, commandResults, recentCommandItems]);
|
unordered.set('recent', recentCommandItems);
|
||||||
|
}
|
||||||
|
} else if (!parsed.showScopeList) {
|
||||||
|
for (const item of commandResults) {
|
||||||
|
const key = item.category;
|
||||||
|
if (!unordered.has(key)) unordered.set(key, []);
|
||||||
|
unordered.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-insert in priority order
|
||||||
|
const ordered = new Map<string, CommandItem[]>();
|
||||||
|
for (const key of CATEGORY_ORDER) {
|
||||||
|
if (unordered.has(key)) ordered.set(key, unordered.get(key)!);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [query, parsed.showScopeList, commandResults, favoriteCommandItems, recentCommandItems]);
|
||||||
|
|
||||||
// Group entities by type
|
// Group entities by type
|
||||||
const groupedEntities = useMemo(() => {
|
const groupedEntities = useMemo(() => {
|
||||||
@ -253,11 +312,18 @@ export default function CommandPalette() {
|
|||||||
return groups;
|
return groups;
|
||||||
}, [entityResults]);
|
}, [entityResults]);
|
||||||
|
|
||||||
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
// Get flat index for a given item
|
// Get flat index for a given item
|
||||||
let flatIndex = 0;
|
let flatIndex = 0;
|
||||||
|
|
||||||
const isMac = navigator.platform?.toLowerCase().includes('mac');
|
const isMac = navigator.platform?.toLowerCase().includes('mac');
|
||||||
|
|
||||||
|
// Dynamic placeholder
|
||||||
|
const placeholder = parsed.scope
|
||||||
|
? `Search ${parsed.scope.label.toLowerCase()}...`
|
||||||
|
: 'Search pages, settings, data...';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@ -290,6 +356,16 @@ export default function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} />
|
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} />
|
||||||
|
{parsed.scope && (
|
||||||
|
<Tag
|
||||||
|
color={token.colorPrimary}
|
||||||
|
style={{ margin: 0, fontSize: 11, lineHeight: '18px', padding: '0 6px' }}
|
||||||
|
closable
|
||||||
|
onClose={() => setQuery(parsed.strippedQuery)}
|
||||||
|
>
|
||||||
|
{parsed.scope.label}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
@ -297,7 +373,7 @@ export default function CommandPalette() {
|
|||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search pages, settings, data..."
|
placeholder={placeholder}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@ -320,9 +396,9 @@ export default function CommandPalette() {
|
|||||||
padding: '4px 0',
|
padding: '4px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Command groups */}
|
{/* Scope selector (when user types bare @) */}
|
||||||
{Array.from(groupedCommands.entries()).map(([groupKey, items]) => (
|
{parsed.showScopeList && (
|
||||||
<div key={groupKey}>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px 4px',
|
padding: '8px 16px 4px',
|
||||||
@ -333,85 +409,143 @@ export default function CommandPalette() {
|
|||||||
color: token.colorTextSecondary,
|
color: token.colorTextSecondary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groupKey === 'recent'
|
Filter by scope
|
||||||
? 'Recent'
|
|
||||||
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey}
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
|
|
||||||
>
|
|
||||||
{items.length} {items.length === 1 ? 'result' : 'results'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
{items.map((item) => {
|
{SCOPE_DEFINITIONS.map((scope) => (
|
||||||
const idx = flatIndex++;
|
|
||||||
return (
|
|
||||||
<ResultRow
|
|
||||||
key={item.id}
|
|
||||||
index={idx}
|
|
||||||
selected={idx === selectedIndex}
|
|
||||||
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
|
|
||||||
title={item.title}
|
|
||||||
badge={item.category === 'action' ? <ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} /> : null}
|
|
||||||
subtitle={item.group}
|
|
||||||
token={token}
|
|
||||||
onSelect={() => handleSelect({ type: 'command', item })}
|
|
||||||
onHover={() => setSelectedIndex(idx)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Entity groups */}
|
|
||||||
{query &&
|
|
||||||
Array.from(groupedEntities.entries()).map(([entityType, items]) => (
|
|
||||||
<div key={entityType}>
|
|
||||||
<div
|
<div
|
||||||
|
key={scope.prefix}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery(`@${scope.prefix}:`);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px 4px',
|
padding: '8px 16px',
|
||||||
fontSize: 11,
|
display: 'flex',
|
||||||
fontWeight: 600,
|
alignItems: 'center',
|
||||||
textTransform: 'uppercase',
|
gap: 10,
|
||||||
letterSpacing: '0.05em',
|
cursor: 'pointer',
|
||||||
color: token.colorTextSecondary,
|
borderRadius: 4,
|
||||||
|
margin: '0 4px',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.background = token.colorPrimaryBg;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entityType}s
|
<span
|
||||||
<Text
|
style={{
|
||||||
type="secondary"
|
fontFamily: 'monospace',
|
||||||
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
|
fontSize: 13,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
width: 90,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{items.length} {items.length === 1 ? 'result' : 'results'}
|
@{scope.prefix}:
|
||||||
</Text>
|
</span>
|
||||||
</div>
|
<span style={{ color: token.colorText, fontSize: 14 }}>{scope.label}</span>
|
||||||
{items.map((item) => {
|
|
||||||
const idx = flatIndex++;
|
|
||||||
return (
|
|
||||||
<ResultRow
|
|
||||||
key={item.id}
|
|
||||||
index={idx}
|
|
||||||
selected={idx === selectedIndex}
|
|
||||||
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
|
|
||||||
title={item.title}
|
|
||||||
subtitle={item.subtitle || item.entityType}
|
|
||||||
token={token}
|
|
||||||
onSelect={() => handleSelect({ type: 'entity', item })}
|
|
||||||
onHover={() => setSelectedIndex(idx)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{flatList.length === 0 && !entityLoading && query && (
|
|
||||||
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
|
||||||
<Text type="secondary">No results for "{query}"</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No query, no recents */}
|
{/* Render in priority order: Pages → Entities → Actions → Settings
|
||||||
|
For no-query mode: Favorites → Recent (no entities) */}
|
||||||
|
{(() => {
|
||||||
|
const sections: React.ReactNode[] = [];
|
||||||
|
const commandEntries = Array.from(groupedCommands.entries());
|
||||||
|
|
||||||
|
// Pages (navigation) first — skip actions and settings for now
|
||||||
|
for (const [groupKey, items] of commandEntries) {
|
||||||
|
if (groupKey === 'settings' || groupKey === 'action') continue;
|
||||||
|
sections.push(
|
||||||
|
<CommandGroupSection
|
||||||
|
key={groupKey}
|
||||||
|
groupKey={groupKey}
|
||||||
|
items={items}
|
||||||
|
flatIndexRef={{ current: flatIndex }}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
favoritePaths={favoritePaths}
|
||||||
|
token={token}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
flatIndex += items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities right after pages (doc files, campaigns, users, etc.)
|
||||||
|
if (query && !parsed.showScopeList) {
|
||||||
|
for (const [entityType, items] of groupedEntities.entries()) {
|
||||||
|
sections.push(
|
||||||
|
<EntityGroupSection
|
||||||
|
key={entityType}
|
||||||
|
entityType={entityType}
|
||||||
|
items={items}
|
||||||
|
flatIndexRef={{ current: flatIndex }}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
token={token}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
flatIndex += items.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions after entities
|
||||||
|
const actionItems = groupedCommands.get('action');
|
||||||
|
if (actionItems) {
|
||||||
|
sections.push(
|
||||||
|
<CommandGroupSection
|
||||||
|
key="action"
|
||||||
|
groupKey="action"
|
||||||
|
items={actionItems}
|
||||||
|
flatIndexRef={{ current: flatIndex }}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
favoritePaths={favoritePaths}
|
||||||
|
token={token}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
flatIndex += actionItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings last
|
||||||
|
const settingsItems = groupedCommands.get('settings');
|
||||||
|
if (settingsItems) {
|
||||||
|
sections.push(
|
||||||
|
<CommandGroupSection
|
||||||
|
key="settings"
|
||||||
|
groupKey="settings"
|
||||||
|
items={settingsItems}
|
||||||
|
flatIndexRef={{ current: flatIndex }}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
favoritePaths={favoritePaths}
|
||||||
|
token={token}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
flatIndex += settingsItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{flatList.length === 0 && !entityLoading && query && !parsed.showScopeList && (
|
||||||
|
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
||||||
|
<Text type="secondary">No results for "{parsed.strippedQuery || query}"</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No query, no recents, no favorites */}
|
||||||
{flatList.length === 0 && !query && (
|
{flatList.length === 0 && !query && (
|
||||||
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
||||||
<Text type="secondary">Type to search pages, settings, and data</Text>
|
<Text type="secondary">Type to search pages, settings, and data</Text>
|
||||||
@ -428,11 +562,13 @@ export default function CommandPalette() {
|
|||||||
gap: 16,
|
gap: 16,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: token.colorTextTertiary,
|
color: token.colorTextTertiary,
|
||||||
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span><kbd style={kbdStyle(token)}>↑↓</kbd> navigate</span>
|
<span><kbd style={kbdStyle(token)}>↑↓</kbd> navigate</span>
|
||||||
<span><kbd style={kbdStyle(token)}>↵</kbd> open</span>
|
<span><kbd style={kbdStyle(token)}>↵</kbd> open</span>
|
||||||
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
|
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
|
||||||
|
<span><kbd style={kbdStyle(token)}>@</kbd> scope</span>
|
||||||
<span style={{ marginLeft: 'auto' }}>
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
|
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
|
||||||
</span>
|
</span>
|
||||||
@ -454,12 +590,138 @@ function kbdStyle(token: GlobalToken): React.CSSProperties {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GROUP_HEADER_STYLE: React.CSSProperties = {
|
||||||
|
padding: '8px 16px 4px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Render a group of command items with a header */
|
||||||
|
function CommandGroupSection({
|
||||||
|
groupKey,
|
||||||
|
items,
|
||||||
|
flatIndexRef,
|
||||||
|
selectedIndex,
|
||||||
|
favoritePaths,
|
||||||
|
token,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: {
|
||||||
|
groupKey: string;
|
||||||
|
items: CommandItem[];
|
||||||
|
flatIndexRef: { current: number };
|
||||||
|
selectedIndex: number;
|
||||||
|
favoritePaths: Set<string>;
|
||||||
|
token: GlobalToken;
|
||||||
|
onSelect: (item: FlatItem) => void;
|
||||||
|
onHover: (idx: number) => void;
|
||||||
|
}) {
|
||||||
|
const label =
|
||||||
|
groupKey === 'recent'
|
||||||
|
? 'Recent'
|
||||||
|
: groupKey === 'favorites'
|
||||||
|
? 'Favorites'
|
||||||
|
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
|
||||||
|
{label}
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
|
||||||
|
>
|
||||||
|
{items.length} {items.length === 1 ? 'result' : 'results'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{items.map((item) => {
|
||||||
|
const idx = flatIndexRef.current++;
|
||||||
|
const isFav = favoritePaths.has(item.path);
|
||||||
|
return (
|
||||||
|
<ResultRow
|
||||||
|
key={item.id}
|
||||||
|
index={idx}
|
||||||
|
selected={idx === selectedIndex}
|
||||||
|
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
badge={
|
||||||
|
<>
|
||||||
|
{isFav && <StarFilled style={{ fontSize: 10, color: '#faad14' }} />}
|
||||||
|
{item.category === 'action' && (
|
||||||
|
<ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subtitle={item.group}
|
||||||
|
token={token}
|
||||||
|
onSelect={() => onSelect({ type: 'command', item })}
|
||||||
|
onHover={() => onHover(idx)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a group of entity results with a header */
|
||||||
|
function EntityGroupSection({
|
||||||
|
entityType,
|
||||||
|
items,
|
||||||
|
flatIndexRef,
|
||||||
|
selectedIndex,
|
||||||
|
token,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: {
|
||||||
|
entityType: string;
|
||||||
|
items: EntityResult[];
|
||||||
|
flatIndexRef: { current: number };
|
||||||
|
selectedIndex: number;
|
||||||
|
token: GlobalToken;
|
||||||
|
onSelect: (item: FlatItem) => void;
|
||||||
|
onHover: (idx: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
|
||||||
|
{entityType}s
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
|
||||||
|
>
|
||||||
|
{items.length} {items.length === 1 ? 'result' : 'results'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{items.map((item) => {
|
||||||
|
const idx = flatIndexRef.current++;
|
||||||
|
return (
|
||||||
|
<ResultRow
|
||||||
|
key={item.id}
|
||||||
|
index={idx}
|
||||||
|
selected={idx === selectedIndex}
|
||||||
|
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
|
||||||
|
title={item.title}
|
||||||
|
subtitle={item.subtitle || item.entityType}
|
||||||
|
token={token}
|
||||||
|
onSelect={() => onSelect({ type: 'entity', item })}
|
||||||
|
onHover={() => onHover(idx)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Single result row */
|
/** Single result row */
|
||||||
function ResultRow({
|
function ResultRow({
|
||||||
index,
|
index,
|
||||||
selected,
|
selected,
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
subtitle,
|
subtitle,
|
||||||
badge,
|
badge,
|
||||||
token,
|
token,
|
||||||
@ -470,6 +732,7 @@ function ResultRow({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
description?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
badge?: React.ReactNode;
|
badge?: React.ReactNode;
|
||||||
token: GlobalToken;
|
token: GlobalToken;
|
||||||
@ -496,9 +759,35 @@ function ResultRow({
|
|||||||
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
|
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 14 }}>
|
<span style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: token.colorText,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
{description && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: token.colorTextTertiary,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{badge}
|
{badge}
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
|
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-dashboard',
|
id: 'nav-dashboard',
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
|
description: 'Platform overview with key metrics and activity',
|
||||||
group: 'General',
|
group: 'General',
|
||||||
path: '/app',
|
path: '/app',
|
||||||
icon: 'DashboardOutlined',
|
icon: 'DashboardOutlined',
|
||||||
@ -21,6 +22,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-users',
|
id: 'nav-users',
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
|
description: 'Manage user accounts, roles, and permissions',
|
||||||
group: 'People & Access',
|
group: 'People & Access',
|
||||||
path: '/app/users',
|
path: '/app/users',
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
@ -30,6 +32,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-people',
|
id: 'nav-people',
|
||||||
title: 'People',
|
title: 'People',
|
||||||
|
description: 'CRM contacts directory and engagement tracking',
|
||||||
group: 'People & Access',
|
group: 'People & Access',
|
||||||
path: '/app/people',
|
path: '/app/people',
|
||||||
icon: 'ContactsOutlined',
|
icon: 'ContactsOutlined',
|
||||||
@ -41,6 +44,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-settings',
|
id: 'nav-settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
description: 'Global platform configuration and preferences',
|
||||||
group: 'General',
|
group: 'General',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -49,10 +53,49 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Navigation: Social ───────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'nav-social-dashboard',
|
||||||
|
title: 'Social Dashboard',
|
||||||
|
description: 'Community engagement overview and social stats',
|
||||||
|
group: 'Social',
|
||||||
|
path: '/app/social',
|
||||||
|
icon: 'TeamOutlined',
|
||||||
|
keywords: ['social', 'community', 'friends', 'connections', 'engagement'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enableSocial',
|
||||||
|
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-social-graph',
|
||||||
|
title: 'Social Graph',
|
||||||
|
description: 'Visualize connections between community members',
|
||||||
|
group: 'Social',
|
||||||
|
path: '/app/social/graph',
|
||||||
|
icon: 'BranchesOutlined',
|
||||||
|
keywords: ['network', 'connections', 'graph', 'relationships', 'visualization'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enableSocial',
|
||||||
|
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-social-moderation',
|
||||||
|
title: 'Social Moderation',
|
||||||
|
description: 'Review flagged content and user reports',
|
||||||
|
group: 'Social',
|
||||||
|
path: '/app/social/moderation',
|
||||||
|
icon: 'MessageOutlined',
|
||||||
|
keywords: ['moderation', 'reports', 'flagged', 'content review', 'social moderation'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enableSocial',
|
||||||
|
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
||||||
|
},
|
||||||
|
|
||||||
// ── Navigation: Advocacy ──────────────────────────────
|
// ── Navigation: Advocacy ──────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'nav-campaigns',
|
id: 'nav-campaigns',
|
||||||
title: 'Campaigns',
|
title: 'Campaigns',
|
||||||
|
description: 'Create and manage advocacy email campaigns',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/campaigns',
|
path: '/app/campaigns',
|
||||||
icon: 'SendOutlined',
|
icon: 'SendOutlined',
|
||||||
@ -63,6 +106,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-campaign-review',
|
id: 'nav-campaign-review',
|
||||||
title: 'Campaign Review',
|
title: 'Campaign Review',
|
||||||
|
description: 'Moderate and approve pending campaign submissions',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/campaign-moderation',
|
path: '/app/campaign-moderation',
|
||||||
icon: 'FileTextOutlined',
|
icon: 'FileTextOutlined',
|
||||||
@ -73,6 +117,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-representatives',
|
id: 'nav-representatives',
|
||||||
title: 'Representatives',
|
title: 'Representatives',
|
||||||
|
description: 'Browse and manage elected official data cache',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/representatives',
|
path: '/app/representatives',
|
||||||
icon: 'IdcardOutlined',
|
icon: 'IdcardOutlined',
|
||||||
@ -83,6 +128,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-outgoing-emails',
|
id: 'nav-outgoing-emails',
|
||||||
title: 'Outgoing Emails',
|
title: 'Outgoing Emails',
|
||||||
|
description: 'Monitor the advocacy email sending queue',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/email-queue',
|
path: '/app/email-queue',
|
||||||
icon: 'MailOutlined',
|
icon: 'MailOutlined',
|
||||||
@ -93,6 +139,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-responses',
|
id: 'nav-responses',
|
||||||
title: 'Responses',
|
title: 'Responses',
|
||||||
|
description: 'Moderate public responses and feedback',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/responses',
|
path: '/app/responses',
|
||||||
icon: 'MessageOutlined',
|
icon: 'MessageOutlined',
|
||||||
@ -103,6 +150,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-effectiveness',
|
id: 'nav-effectiveness',
|
||||||
title: 'Effectiveness',
|
title: 'Effectiveness',
|
||||||
|
description: 'Campaign performance analytics and metrics',
|
||||||
group: 'Advocacy',
|
group: 'Advocacy',
|
||||||
path: '/app/influence/effectiveness',
|
path: '/app/influence/effectiveness',
|
||||||
icon: 'LineChartOutlined',
|
icon: 'LineChartOutlined',
|
||||||
@ -115,6 +163,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-newsletter',
|
id: 'nav-newsletter',
|
||||||
title: 'Newsletter',
|
title: 'Newsletter',
|
||||||
|
description: 'Manage mailing lists and broadcast emails',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/listmonk',
|
path: '/app/listmonk',
|
||||||
icon: 'MailOutlined',
|
icon: 'MailOutlined',
|
||||||
@ -126,6 +175,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-email-templates',
|
id: 'nav-email-templates',
|
||||||
title: 'Email Templates',
|
title: 'Email Templates',
|
||||||
|
description: 'Design reusable email templates',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/email-templates',
|
path: '/app/email-templates',
|
||||||
icon: 'FileTextOutlined',
|
icon: 'FileTextOutlined',
|
||||||
@ -136,6 +186,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-sms-setup',
|
id: 'nav-sms-setup',
|
||||||
title: 'SMS Setup',
|
title: 'SMS Setup',
|
||||||
|
description: 'Configure the Termux SMS bridge device',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/sms/setup',
|
path: '/app/sms/setup',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -147,6 +198,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-sms-dashboard',
|
id: 'nav-sms-dashboard',
|
||||||
title: 'SMS Dashboard',
|
title: 'SMS Dashboard',
|
||||||
|
description: 'Text messaging overview and delivery stats',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/sms',
|
path: '/app/sms',
|
||||||
icon: 'PhoneOutlined',
|
icon: 'PhoneOutlined',
|
||||||
@ -157,6 +209,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-sms-contacts',
|
id: 'nav-sms-contacts',
|
||||||
title: 'SMS Contacts',
|
title: 'SMS Contacts',
|
||||||
|
description: 'Manage contact lists and phone numbers',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/sms/contacts',
|
path: '/app/sms/contacts',
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
@ -167,6 +220,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-sms-campaigns',
|
id: 'nav-sms-campaigns',
|
||||||
title: 'SMS Campaigns',
|
title: 'SMS Campaigns',
|
||||||
|
description: 'Create and send bulk text message campaigns',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/sms/campaigns',
|
path: '/app/sms/campaigns',
|
||||||
icon: 'SendOutlined',
|
icon: 'SendOutlined',
|
||||||
@ -177,6 +231,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-sms-conversations',
|
id: 'nav-sms-conversations',
|
||||||
title: 'SMS Threads',
|
title: 'SMS Threads',
|
||||||
|
description: 'View and reply to text message conversations',
|
||||||
group: 'Broadcast',
|
group: 'Broadcast',
|
||||||
path: '/app/sms/conversations',
|
path: '/app/sms/conversations',
|
||||||
icon: 'MessageOutlined',
|
icon: 'MessageOutlined',
|
||||||
@ -184,11 +239,23 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
featureFlag: 'enableSms',
|
featureFlag: 'enableSms',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-sms-templates',
|
||||||
|
title: 'SMS Templates',
|
||||||
|
description: 'Manage reusable text message templates',
|
||||||
|
group: 'Broadcast',
|
||||||
|
path: '/app/sms/templates',
|
||||||
|
icon: 'FileTextOutlined',
|
||||||
|
keywords: ['sms template', 'message template', 'canned response'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enableSms',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Navigation: Web ───────────────────────────────────
|
// ── Navigation: Web ───────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'nav-landing-pages',
|
id: 'nav-landing-pages',
|
||||||
title: 'Landing Pages',
|
title: 'Landing Pages',
|
||||||
|
description: 'Build and manage website landing pages',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/pages',
|
path: '/app/pages',
|
||||||
icon: 'FileTextOutlined',
|
icon: 'FileTextOutlined',
|
||||||
@ -199,6 +266,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-navigation',
|
id: 'nav-navigation',
|
||||||
title: 'Navigation',
|
title: 'Navigation',
|
||||||
|
description: 'Configure public site header and menu links',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/navigation',
|
path: '/app/navigation',
|
||||||
icon: 'GlobalOutlined',
|
icon: 'GlobalOutlined',
|
||||||
@ -208,6 +276,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-documentation',
|
id: 'nav-documentation',
|
||||||
title: 'Documentation',
|
title: 'Documentation',
|
||||||
|
description: 'Manage MkDocs knowledge base articles',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/docs',
|
path: '/app/docs',
|
||||||
icon: 'BookOutlined',
|
icon: 'BookOutlined',
|
||||||
@ -217,6 +286,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-docs-analytics',
|
id: 'nav-docs-analytics',
|
||||||
title: 'Docs Analytics',
|
title: 'Docs Analytics',
|
||||||
|
description: 'View documentation page views and metrics',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/docs/analytics',
|
path: '/app/docs/analytics',
|
||||||
icon: 'BarChartOutlined',
|
icon: 'BarChartOutlined',
|
||||||
@ -226,6 +296,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-docs-comments',
|
id: 'nav-docs-comments',
|
||||||
title: 'Docs Comments',
|
title: 'Docs Comments',
|
||||||
|
description: 'Moderate documentation feedback and discussion',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/docs/comments',
|
path: '/app/docs/comments',
|
||||||
icon: 'MessageOutlined',
|
icon: 'MessageOutlined',
|
||||||
@ -235,6 +306,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-docs-settings',
|
id: 'nav-docs-settings',
|
||||||
title: 'Docs Settings',
|
title: 'Docs Settings',
|
||||||
|
description: 'Configure MkDocs site and theme options',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/docs/settings',
|
path: '/app/docs/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -244,6 +316,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-code-editor',
|
id: 'nav-code-editor',
|
||||||
title: 'Code Editor',
|
title: 'Code Editor',
|
||||||
|
description: 'Open the web-based Code Server IDE',
|
||||||
group: 'Web',
|
group: 'Web',
|
||||||
path: '/app/code',
|
path: '/app/code',
|
||||||
icon: 'CodeOutlined',
|
icon: 'CodeOutlined',
|
||||||
@ -256,6 +329,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-locations',
|
id: 'nav-locations',
|
||||||
title: 'Locations',
|
title: 'Locations',
|
||||||
|
description: 'Manage addresses, geocoding, and CSV imports',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map',
|
path: '/app/map',
|
||||||
icon: 'EnvironmentOutlined',
|
icon: 'EnvironmentOutlined',
|
||||||
@ -266,6 +340,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-data-quality',
|
id: 'nav-data-quality',
|
||||||
title: 'Data Quality',
|
title: 'Data Quality',
|
||||||
|
description: 'Geocoding quality metrics and data health',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map/data-quality',
|
path: '/app/map/data-quality',
|
||||||
icon: 'BarChartOutlined',
|
icon: 'BarChartOutlined',
|
||||||
@ -276,6 +351,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-shifts',
|
id: 'nav-shifts',
|
||||||
title: 'Shifts',
|
title: 'Shifts',
|
||||||
|
description: 'Schedule volunteer shifts and manage signups',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map/shifts',
|
path: '/app/map/shifts',
|
||||||
icon: 'ScheduleOutlined',
|
icon: 'ScheduleOutlined',
|
||||||
@ -286,6 +362,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-areas',
|
id: 'nav-areas',
|
||||||
title: 'Areas',
|
title: 'Areas',
|
||||||
|
description: 'Draw and manage canvassing territory boundaries',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map/cuts',
|
path: '/app/map/cuts',
|
||||||
icon: 'ScissorOutlined',
|
icon: 'ScissorOutlined',
|
||||||
@ -296,6 +373,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-canvassing',
|
id: 'nav-canvassing',
|
||||||
title: 'Canvassing',
|
title: 'Canvassing',
|
||||||
|
description: 'View volunteer sessions, visits, and routes',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map/canvass',
|
path: '/app/map/canvass',
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
@ -306,6 +384,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-map-settings',
|
id: 'nav-map-settings',
|
||||||
title: 'Map Settings',
|
title: 'Map Settings',
|
||||||
|
description: 'Configure map center, zoom, and geocoding provider',
|
||||||
group: 'Map',
|
group: 'Map',
|
||||||
path: '/app/map/settings',
|
path: '/app/map/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -318,6 +397,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-media-library',
|
id: 'nav-media-library',
|
||||||
title: 'Library',
|
title: 'Library',
|
||||||
|
description: 'Upload and manage video files',
|
||||||
group: 'Media',
|
group: 'Media',
|
||||||
path: '/app/media/library',
|
path: '/app/media/library',
|
||||||
icon: 'FolderOutlined',
|
icon: 'FolderOutlined',
|
||||||
@ -328,6 +408,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-media-analytics',
|
id: 'nav-media-analytics',
|
||||||
title: 'Media Analytics',
|
title: 'Media Analytics',
|
||||||
|
description: 'Video views, watch time, and engagement stats',
|
||||||
group: 'Media',
|
group: 'Media',
|
||||||
path: '/app/media/analytics',
|
path: '/app/media/analytics',
|
||||||
icon: 'BarChartOutlined',
|
icon: 'BarChartOutlined',
|
||||||
@ -338,6 +419,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-media-curated',
|
id: 'nav-media-curated',
|
||||||
title: 'Curated',
|
title: 'Curated',
|
||||||
|
description: 'Manage featured playlists and collections',
|
||||||
group: 'Media',
|
group: 'Media',
|
||||||
path: '/app/media/curated',
|
path: '/app/media/curated',
|
||||||
icon: 'OrderedListOutlined',
|
icon: 'OrderedListOutlined',
|
||||||
@ -348,6 +430,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-media-moderation',
|
id: 'nav-media-moderation',
|
||||||
title: 'Media Moderation',
|
title: 'Media Moderation',
|
||||||
|
description: 'Review and moderate video comments',
|
||||||
group: 'Media',
|
group: 'Media',
|
||||||
path: '/app/media/moderation',
|
path: '/app/media/moderation',
|
||||||
icon: 'MessageOutlined',
|
icon: 'MessageOutlined',
|
||||||
@ -358,6 +441,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-gallery-ads',
|
id: 'nav-gallery-ads',
|
||||||
title: 'Gallery Ads',
|
title: 'Gallery Ads',
|
||||||
|
description: 'Manage banner advertisements in the gallery',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/ads',
|
path: '/app/payments/ads',
|
||||||
icon: 'PictureOutlined',
|
icon: 'PictureOutlined',
|
||||||
@ -366,9 +450,22 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
featureFlag: 'enablePayments',
|
featureFlag: 'enablePayments',
|
||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-ad-analytics',
|
||||||
|
title: 'Ad Analytics',
|
||||||
|
description: 'Track ad impressions, clicks, and performance',
|
||||||
|
group: 'Payments',
|
||||||
|
path: '/app/payments/ads/analytics',
|
||||||
|
icon: 'LineChartOutlined',
|
||||||
|
keywords: ['ad performance', 'ad metrics', 'banner stats', 'ad dashboard'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enablePayments',
|
||||||
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'nav-media-jobs',
|
id: 'nav-media-jobs',
|
||||||
title: 'Processing Jobs',
|
title: 'Processing Jobs',
|
||||||
|
description: 'Monitor video encoding and processing tasks',
|
||||||
group: 'Media',
|
group: 'Media',
|
||||||
path: '/app/media/jobs',
|
path: '/app/media/jobs',
|
||||||
icon: 'HistoryOutlined',
|
icon: 'HistoryOutlined',
|
||||||
@ -381,6 +478,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-payments-dashboard',
|
id: 'nav-payments-dashboard',
|
||||||
title: 'Payments Dashboard',
|
title: 'Payments Dashboard',
|
||||||
|
description: 'Revenue overview and transaction summary',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments',
|
path: '/app/payments',
|
||||||
icon: 'DashboardOutlined',
|
icon: 'DashboardOutlined',
|
||||||
@ -392,6 +490,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-plans',
|
id: 'nav-plans',
|
||||||
title: 'Plans',
|
title: 'Plans',
|
||||||
|
description: 'Create subscription plans and pricing tiers',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/plans',
|
path: '/app/payments/plans',
|
||||||
icon: 'TagOutlined',
|
icon: 'TagOutlined',
|
||||||
@ -403,6 +502,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-subscribers',
|
id: 'nav-subscribers',
|
||||||
title: 'Subscribers',
|
title: 'Subscribers',
|
||||||
|
description: 'View and manage paying subscribers',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/subscribers',
|
path: '/app/payments/subscribers',
|
||||||
icon: 'CrownOutlined',
|
icon: 'CrownOutlined',
|
||||||
@ -414,6 +514,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-products',
|
id: 'nav-products',
|
||||||
title: 'Products',
|
title: 'Products',
|
||||||
|
description: 'Manage store products and merchandise',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/products',
|
path: '/app/payments/products',
|
||||||
icon: 'ShoppingOutlined',
|
icon: 'ShoppingOutlined',
|
||||||
@ -425,6 +526,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-donation-pages',
|
id: 'nav-donation-pages',
|
||||||
title: 'Donation Pages',
|
title: 'Donation Pages',
|
||||||
|
description: 'Create fundraising and donation pages',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/donation-pages',
|
path: '/app/payments/donation-pages',
|
||||||
icon: 'HeartOutlined',
|
icon: 'HeartOutlined',
|
||||||
@ -436,6 +538,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-donation-orders',
|
id: 'nav-donation-orders',
|
||||||
title: 'Donation Orders',
|
title: 'Donation Orders',
|
||||||
|
description: 'Track received donations and transaction history',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/donations',
|
path: '/app/payments/donations',
|
||||||
icon: 'DollarOutlined',
|
icon: 'DollarOutlined',
|
||||||
@ -447,6 +550,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-payment-settings',
|
id: 'nav-payment-settings',
|
||||||
title: 'Payment Settings',
|
title: 'Payment Settings',
|
||||||
|
description: 'Configure payment gateway and Stripe integration',
|
||||||
group: 'Payments',
|
group: 'Payments',
|
||||||
path: '/app/payments/settings',
|
path: '/app/payments/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -460,6 +564,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-tunnel',
|
id: 'nav-tunnel',
|
||||||
title: 'Tunnel',
|
title: 'Tunnel',
|
||||||
|
description: 'Manage Pangolin reverse tunnel for public access',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/tunnel',
|
path: '/app/tunnel',
|
||||||
icon: 'CloudServerOutlined',
|
icon: 'CloudServerOutlined',
|
||||||
@ -470,6 +575,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-monitoring',
|
id: 'nav-monitoring',
|
||||||
title: 'Monitoring',
|
title: 'Monitoring',
|
||||||
|
description: 'Prometheus metrics, Grafana dashboards, and alerts',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/observability',
|
path: '/app/observability',
|
||||||
icon: 'LineChartOutlined',
|
icon: 'LineChartOutlined',
|
||||||
@ -480,6 +586,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-database',
|
id: 'nav-database',
|
||||||
title: 'Database',
|
title: 'Database',
|
||||||
|
description: 'Browse database tables with NocoDB',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/nocodb',
|
path: '/app/services/nocodb',
|
||||||
icon: 'DatabaseOutlined',
|
icon: 'DatabaseOutlined',
|
||||||
@ -490,6 +597,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-vault',
|
id: 'nav-vault',
|
||||||
title: 'Vault',
|
title: 'Vault',
|
||||||
|
description: 'Vaultwarden password manager for the team',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/vaultwarden',
|
path: '/app/services/vaultwarden',
|
||||||
icon: 'LockOutlined',
|
icon: 'LockOutlined',
|
||||||
@ -500,6 +608,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-mailhog',
|
id: 'nav-mailhog',
|
||||||
title: 'MailHog',
|
title: 'MailHog',
|
||||||
|
description: 'Capture and inspect test emails in development',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/mailhog',
|
path: '/app/services/mailhog',
|
||||||
icon: 'MailOutlined',
|
icon: 'MailOutlined',
|
||||||
@ -510,6 +619,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-workflows',
|
id: 'nav-workflows',
|
||||||
title: 'Workflows',
|
title: 'Workflows',
|
||||||
|
description: 'n8n workflow automation and integrations',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/n8n',
|
path: '/app/services/n8n',
|
||||||
icon: 'ApiOutlined',
|
icon: 'ApiOutlined',
|
||||||
@ -520,6 +630,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-git',
|
id: 'nav-git',
|
||||||
title: 'Git',
|
title: 'Git',
|
||||||
|
description: 'Gitea self-hosted Git repositories',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/gitea',
|
path: '/app/services/gitea',
|
||||||
icon: 'BranchesOutlined',
|
icon: 'BranchesOutlined',
|
||||||
@ -530,6 +641,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-whiteboard',
|
id: 'nav-whiteboard',
|
||||||
title: 'Whiteboard',
|
title: 'Whiteboard',
|
||||||
|
description: 'Excalidraw collaborative drawing board',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/excalidraw',
|
path: '/app/services/excalidraw',
|
||||||
icon: 'EditOutlined',
|
icon: 'EditOutlined',
|
||||||
@ -540,6 +652,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-team-chat',
|
id: 'nav-team-chat',
|
||||||
title: 'Team Chat',
|
title: 'Team Chat',
|
||||||
|
description: 'Rocket.Chat team messaging and channels',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/rocketchat',
|
path: '/app/services/rocketchat',
|
||||||
icon: 'MessageOutlined',
|
icon: 'MessageOutlined',
|
||||||
@ -550,6 +663,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'nav-events',
|
id: 'nav-events',
|
||||||
title: 'Events',
|
title: 'Events',
|
||||||
|
description: 'Gancio event calendar and scheduling',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/gancio',
|
path: '/app/services/gancio',
|
||||||
icon: 'CalendarOutlined',
|
icon: 'CalendarOutlined',
|
||||||
@ -557,9 +671,22 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-jitsi',
|
||||||
|
title: 'Video Meetings',
|
||||||
|
description: 'Jitsi Meet video conferencing setup',
|
||||||
|
group: 'Services',
|
||||||
|
path: '/app/services/jitsi',
|
||||||
|
icon: 'PlaySquareOutlined',
|
||||||
|
keywords: ['jitsi', 'video call', 'conference', 'meeting', 'webrtc'],
|
||||||
|
category: 'navigation',
|
||||||
|
featureFlag: 'enableMeet',
|
||||||
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'nav-qr-codes',
|
id: 'nav-qr-codes',
|
||||||
title: 'QR Codes',
|
title: 'QR Codes',
|
||||||
|
description: 'Generate QR code images for links',
|
||||||
group: 'Services',
|
group: 'Services',
|
||||||
path: '/app/services/miniqr',
|
path: '/app/services/miniqr',
|
||||||
icon: 'QrcodeOutlined',
|
icon: 'QrcodeOutlined',
|
||||||
@ -572,6 +699,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-general',
|
id: 'settings-general',
|
||||||
title: 'General Settings',
|
title: 'General Settings',
|
||||||
|
description: 'Organization name, branding, and basic config',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -583,6 +711,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-features',
|
id: 'settings-features',
|
||||||
title: 'Feature Flags',
|
title: 'Feature Flags',
|
||||||
|
description: 'Toggle platform modules on or off',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -594,6 +723,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-theme',
|
id: 'settings-theme',
|
||||||
title: 'Theme Settings',
|
title: 'Theme Settings',
|
||||||
|
description: 'Customize colors, appearance, and branding',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -605,6 +735,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-email',
|
id: 'settings-email',
|
||||||
title: 'Email Settings',
|
title: 'Email Settings',
|
||||||
|
description: 'SMTP server and email sender configuration',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'MailOutlined',
|
icon: 'MailOutlined',
|
||||||
@ -616,6 +747,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-provisioning',
|
id: 'settings-provisioning',
|
||||||
title: 'User Provisioning',
|
title: 'User Provisioning',
|
||||||
|
description: 'Auto-create accounts in Gitea, Vaultwarden, and more',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
@ -627,6 +759,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-navigation',
|
id: 'settings-navigation',
|
||||||
title: 'Navigation Settings',
|
title: 'Navigation Settings',
|
||||||
|
description: 'Configure public site header links and menus',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/navigation',
|
path: '/app/navigation',
|
||||||
icon: 'GlobalOutlined',
|
icon: 'GlobalOutlined',
|
||||||
@ -636,6 +769,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-map',
|
id: 'settings-map',
|
||||||
title: 'Map Settings',
|
title: 'Map Settings',
|
||||||
|
description: 'Set map center, zoom, and geocoding provider',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/map/settings',
|
path: '/app/map/settings',
|
||||||
icon: 'EnvironmentOutlined',
|
icon: 'EnvironmentOutlined',
|
||||||
@ -646,6 +780,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'settings-payments',
|
id: 'settings-payments',
|
||||||
title: 'Payment Settings',
|
title: 'Payment Settings',
|
||||||
|
description: 'Stripe gateway and payment configuration',
|
||||||
group: 'Settings',
|
group: 'Settings',
|
||||||
path: '/app/payments/settings',
|
path: '/app/payments/settings',
|
||||||
icon: 'DollarOutlined',
|
icon: 'DollarOutlined',
|
||||||
@ -659,6 +794,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-create-campaign',
|
id: 'action-create-campaign',
|
||||||
title: 'Create Campaign',
|
title: 'Create Campaign',
|
||||||
|
description: 'Start a new advocacy email campaign',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/campaigns',
|
path: '/app/campaigns',
|
||||||
icon: 'SendOutlined',
|
icon: 'SendOutlined',
|
||||||
@ -670,6 +806,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-add-location',
|
id: 'action-add-location',
|
||||||
title: 'Add Location',
|
title: 'Add Location',
|
||||||
|
description: 'Add a new address to the map database',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/map',
|
path: '/app/map',
|
||||||
icon: 'EnvironmentOutlined',
|
icon: 'EnvironmentOutlined',
|
||||||
@ -681,6 +818,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-shift',
|
id: 'action-new-shift',
|
||||||
title: 'New Shift',
|
title: 'New Shift',
|
||||||
|
description: 'Schedule a new volunteer shift',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/map/shifts',
|
path: '/app/map/shifts',
|
||||||
icon: 'ScheduleOutlined',
|
icon: 'ScheduleOutlined',
|
||||||
@ -692,6 +830,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-landing-page',
|
id: 'action-new-landing-page',
|
||||||
title: 'New Landing Page',
|
title: 'New Landing Page',
|
||||||
|
description: 'Create a new landing page with the editor',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/pages',
|
path: '/app/pages',
|
||||||
icon: 'FileTextOutlined',
|
icon: 'FileTextOutlined',
|
||||||
@ -703,6 +842,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-email-template',
|
id: 'action-new-email-template',
|
||||||
title: 'New Email Template',
|
title: 'New Email Template',
|
||||||
|
description: 'Create a reusable email template',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/email-templates',
|
path: '/app/email-templates',
|
||||||
icon: 'FileTextOutlined',
|
icon: 'FileTextOutlined',
|
||||||
@ -714,6 +854,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-product',
|
id: 'action-new-product',
|
||||||
title: 'New Product',
|
title: 'New Product',
|
||||||
|
description: 'Add a new product to the store',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/payments/products',
|
path: '/app/payments/products',
|
||||||
icon: 'ShoppingOutlined',
|
icon: 'ShoppingOutlined',
|
||||||
@ -726,6 +867,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-add-contact',
|
id: 'action-add-contact',
|
||||||
title: 'Add Contact',
|
title: 'Add Contact',
|
||||||
|
description: 'Create a new CRM contact record',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/people',
|
path: '/app/people',
|
||||||
icon: 'ContactsOutlined',
|
icon: 'ContactsOutlined',
|
||||||
@ -738,6 +880,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-plan',
|
id: 'action-new-plan',
|
||||||
title: 'New Plan',
|
title: 'New Plan',
|
||||||
|
description: 'Create a new subscription pricing plan',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/payments/plans',
|
path: '/app/payments/plans',
|
||||||
icon: 'TagOutlined',
|
icon: 'TagOutlined',
|
||||||
@ -750,6 +893,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-donation-page',
|
id: 'action-new-donation-page',
|
||||||
title: 'New Donation Page',
|
title: 'New Donation Page',
|
||||||
|
description: 'Create a new fundraising page',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/payments/donation-pages',
|
path: '/app/payments/donation-pages',
|
||||||
icon: 'HeartOutlined',
|
icon: 'HeartOutlined',
|
||||||
@ -762,6 +906,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'action-new-sms-campaign',
|
id: 'action-new-sms-campaign',
|
||||||
title: 'New SMS Campaign',
|
title: 'New SMS Campaign',
|
||||||
|
description: 'Start a new bulk text message campaign',
|
||||||
group: 'Actions',
|
group: 'Actions',
|
||||||
path: '/app/sms/campaigns',
|
path: '/app/sms/campaigns',
|
||||||
icon: 'PhoneOutlined',
|
icon: 'PhoneOutlined',
|
||||||
@ -770,4 +915,16 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
featureFlag: 'enableSms',
|
featureFlag: 'enableSms',
|
||||||
navigationState: { openCreate: true },
|
navigationState: { openCreate: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'action-new-sms-template',
|
||||||
|
title: 'New SMS Template',
|
||||||
|
description: 'Create a reusable text message template',
|
||||||
|
group: 'Actions',
|
||||||
|
path: '/app/sms/templates',
|
||||||
|
icon: 'FileTextOutlined',
|
||||||
|
keywords: ['create sms template', 'new message template', 'canned response'],
|
||||||
|
category: 'action',
|
||||||
|
featureFlag: 'enableSms',
|
||||||
|
navigationState: { openCreate: true },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
69
admin/src/components/command-palette/scopeFilter.ts
Normal file
69
admin/src/components/command-palette/scopeFilter.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Search scope definitions for the command palette.
|
||||||
|
* Users type `@prefix:query` to restrict results to a specific domain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScopeDefinition {
|
||||||
|
/** The prefix users type, e.g. "user" for `@user:` */
|
||||||
|
prefix: string;
|
||||||
|
/** Human-readable label shown in the scope list */
|
||||||
|
label: string;
|
||||||
|
/** Command registry groups to include */
|
||||||
|
groups: string[];
|
||||||
|
/** Entity types to include (from useEntitySearch configs) */
|
||||||
|
entityTypes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCOPE_DEFINITIONS: ScopeDefinition[] = [
|
||||||
|
{ prefix: 'user', label: 'People & Access', groups: ['People & Access'], entityTypes: ['User', 'Person'] },
|
||||||
|
{ prefix: 'campaign', label: 'Campaigns', groups: ['Advocacy'], entityTypes: ['Campaign'] },
|
||||||
|
{ prefix: 'sms', label: 'Broadcast (SMS)', groups: ['Broadcast'] },
|
||||||
|
{ prefix: 'settings', label: 'Settings', groups: ['Settings'] },
|
||||||
|
{ prefix: 'media', label: 'Media', groups: ['Media'] },
|
||||||
|
{ prefix: 'map', label: 'Map & Locations', groups: ['Map'], entityTypes: ['Location', 'Shift'] },
|
||||||
|
{ prefix: 'web', label: 'Web & Docs', groups: ['Web'], entityTypes: ['Landing Page', 'Email Template', 'Doc File'] },
|
||||||
|
{ prefix: 'service', label: 'Services', groups: ['Services'] },
|
||||||
|
{ prefix: 'payment', label: 'Payments', groups: ['Payments'], entityTypes: ['Product', 'Donation Page', 'Plan'] },
|
||||||
|
{ prefix: 'social', label: 'Social', groups: ['Social'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOPE_MAP = new Map(SCOPE_DEFINITIONS.map((s) => [s.prefix, s]));
|
||||||
|
|
||||||
|
export interface ParsedQuery {
|
||||||
|
/** The active scope, or null if no valid scope prefix */
|
||||||
|
scope: ScopeDefinition | null;
|
||||||
|
/** The query string with scope prefix stripped */
|
||||||
|
strippedQuery: string;
|
||||||
|
/** Whether the user typed a bare `@` (show scope list) */
|
||||||
|
showScopeList: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw query string for scope prefixes.
|
||||||
|
*
|
||||||
|
* - `@user:john` → scope=user, strippedQuery="john"
|
||||||
|
* - `@user:` → scope=user, strippedQuery=""
|
||||||
|
* - `@` → showScopeList=true
|
||||||
|
* - `@foo:bar` → invalid prefix, treated as normal search
|
||||||
|
* - `hello` → no scope, strippedQuery="hello"
|
||||||
|
*/
|
||||||
|
export function parseQuery(raw: string): ParsedQuery {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
|
||||||
|
// Bare `@` — show scope selector
|
||||||
|
if (trimmed === '@') {
|
||||||
|
return { scope: null, strippedQuery: '', showScopeList: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match `@prefix:rest`
|
||||||
|
const match = trimmed.match(/^@(\w+):(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const scope = SCOPE_MAP.get(match[1]!.toLowerCase()) ?? null;
|
||||||
|
if (scope) {
|
||||||
|
return { scope, strippedQuery: match[2]!.trimStart(), showScopeList: false };
|
||||||
|
}
|
||||||
|
// Invalid prefix — fall through to normal search
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scope: null, strippedQuery: trimmed, showScopeList: false };
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ export type CommandCategory = 'navigation' | 'settings' | 'action';
|
|||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description?: string;
|
||||||
group: string;
|
group: string;
|
||||||
path: string;
|
path: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import MiniSearch from 'minisearch';
|
import MiniSearch from 'minisearch';
|
||||||
import { commandRegistry } from './registry';
|
import { commandRegistry } from './registry';
|
||||||
import type { CommandItem } from './types';
|
import type { CommandItem } from './types';
|
||||||
@ -11,6 +12,33 @@ interface IndexedItem extends CommandItem {
|
|||||||
keywordsJoined: string;
|
keywordsJoined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map current path prefixes to the command groups they relate to */
|
||||||
|
const CONTEXT_PREFIXES: Record<string, string[]> = {
|
||||||
|
'/app/campaigns': ['Advocacy'],
|
||||||
|
'/app/campaign-moderation': ['Advocacy'],
|
||||||
|
'/app/representatives': ['Advocacy'],
|
||||||
|
'/app/email-queue': ['Advocacy'],
|
||||||
|
'/app/responses': ['Advocacy'],
|
||||||
|
'/app/influence': ['Advocacy'],
|
||||||
|
'/app/sms': ['Broadcast'],
|
||||||
|
'/app/listmonk': ['Broadcast'],
|
||||||
|
'/app/email-templates': ['Broadcast'],
|
||||||
|
'/app/map': ['Map'],
|
||||||
|
'/app/media': ['Media'],
|
||||||
|
'/app/pages': ['Web'],
|
||||||
|
'/app/docs': ['Web'],
|
||||||
|
'/app/code': ['Web'],
|
||||||
|
'/app/navigation': ['Web'],
|
||||||
|
'/app/payments': ['Payments'],
|
||||||
|
'/app/social': ['Social'],
|
||||||
|
'/app/services': ['Services'],
|
||||||
|
'/app/tunnel': ['Services'],
|
||||||
|
'/app/observability': ['Services'],
|
||||||
|
'/app/settings': ['Settings'],
|
||||||
|
'/app/users': ['People & Access'],
|
||||||
|
'/app/people': ['People & Access'],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a MiniSearch index from the command registry,
|
* Builds a MiniSearch index from the command registry,
|
||||||
* filtered by the current user's roles and enabled feature flags.
|
* filtered by the current user's roles and enabled feature flags.
|
||||||
@ -18,6 +46,7 @@ interface IndexedItem extends CommandItem {
|
|||||||
export function useCommandIndex() {
|
export function useCommandIndex() {
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const { search, allItems } = useMemo(() => {
|
const { search, allItems } = useMemo(() => {
|
||||||
const userRoles = user ? getUserRoles(user) : [];
|
const userRoles = user ? getUserRoles(user) : [];
|
||||||
@ -28,8 +57,9 @@ export function useCommandIndex() {
|
|||||||
if (!flag) return true;
|
if (!flag) return true;
|
||||||
if (!settings) return true; // show all when settings haven't loaded
|
if (!settings) return true; // show all when settings haven't loaded
|
||||||
const value = (settings as unknown as Record<string, unknown>)[flag];
|
const value = (settings as unknown as Record<string, unknown>)[flag];
|
||||||
// enablePayments, enableSms, and enablePeople default off, others default on
|
// Flags that default to false — only show when explicitly enabled
|
||||||
if (flag === 'enablePayments' || flag === 'enableSms' || flag === 'enablePeople') return value === true;
|
const defaultsOff = ['enablePayments', 'enableSms', 'enablePeople', 'enableSocial', 'enableChat', 'enableMeet'];
|
||||||
|
if (defaultsOff.includes(flag)) return value === true;
|
||||||
return value !== false;
|
return value !== false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +87,9 @@ export function useCommandIndex() {
|
|||||||
searchOptions: {
|
searchOptions: {
|
||||||
boost: { title: 10, keywordsJoined: 3, group: 1 },
|
boost: { title: 10, keywordsJoined: 3, group: 1 },
|
||||||
prefix: true,
|
prefix: true,
|
||||||
fuzzy: 0.2,
|
// Only allow fuzzy matching for terms 5+ chars to prevent
|
||||||
|
// false positives like "test" → "text"
|
||||||
|
fuzzy: (term) => (term.length >= 5 ? 0.2 : false),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -66,16 +98,55 @@ export function useCommandIndex() {
|
|||||||
// Create lookup map for fast retrieval
|
// Create lookup map for fast retrieval
|
||||||
const itemMap = new Map(filtered.map((item) => [item.id, item]));
|
const itemMap = new Map(filtered.map((item) => [item.id, item]));
|
||||||
|
|
||||||
const searchFn = (query: string): CommandItem[] => {
|
// Resolve contextual groups from current path
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
const contextGroups: string[] = [];
|
||||||
|
if (currentPath !== '/app') {
|
||||||
|
for (const [prefix, groups] of Object.entries(CONTEXT_PREFIXES)) {
|
||||||
|
if (currentPath.startsWith(prefix)) {
|
||||||
|
contextGroups.push(...groups);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFn = (query: string, scopeGroups?: string[]): CommandItem[] => {
|
||||||
if (!query || query.length < 1) return [];
|
if (!query || query.length < 1) return [];
|
||||||
const results = idx.search(query);
|
const results = idx.search(query);
|
||||||
return results
|
|
||||||
.map((r) => itemMap.get(r.id as string))
|
// Map to items with scores
|
||||||
.filter((item): item is CommandItem => !!item);
|
const scored = results
|
||||||
|
.map((r) => {
|
||||||
|
const item = itemMap.get(r.id as string);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
// Apply scope filter: skip items not in scope groups
|
||||||
|
if (scopeGroups && scopeGroups.length > 0) {
|
||||||
|
if (!scopeGroups.includes(item.group)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply contextual boost
|
||||||
|
let boostedScore = r.score;
|
||||||
|
if (contextGroups.length > 0) {
|
||||||
|
if (currentPath.startsWith(item.path) && item.path !== '/app') {
|
||||||
|
boostedScore += 50;
|
||||||
|
} else if (contextGroups.includes(item.group)) {
|
||||||
|
boostedScore += 25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item, score: boostedScore };
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { item: CommandItem; score: number } => !!entry);
|
||||||
|
|
||||||
|
// Re-sort by boosted score (descending)
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return scored.map((s) => s.item);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { search: searchFn, allItems: filtered };
|
return { search: searchFn, allItems: filtered };
|
||||||
}, [settings, user]);
|
}, [settings, user, location.pathname]);
|
||||||
|
|
||||||
return { search, allItems };
|
return { search, allItems };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,8 +130,10 @@ const entityConfigs: EntitySearchConfig[] = [
|
|||||||
/**
|
/**
|
||||||
* Debounced API entity search. Fires after 300ms when query is 2+ chars.
|
* Debounced API entity search. Fires after 300ms when query is 2+ chars.
|
||||||
* Uses AbortController to cancel in-flight requests on query change.
|
* Uses AbortController to cancel in-flight requests on query change.
|
||||||
|
*
|
||||||
|
* @param scopeEntityTypes — when provided, only search these entity types
|
||||||
*/
|
*/
|
||||||
export function useEntitySearch(query: string) {
|
export function useEntitySearch(query: string, scopeEntityTypes?: string[]) {
|
||||||
const [results, setResults] = useState<EntityResult[]>([]);
|
const [results, setResults] = useState<EntityResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@ -154,11 +156,16 @@ export function useEntitySearch(query: string) {
|
|||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
// Filter configs by feature flags + roles
|
// Filter configs by feature flags + roles + scope
|
||||||
const activeConfigs = entityConfigs.filter((cfg) => {
|
const activeConfigs = entityConfigs.filter((cfg) => {
|
||||||
|
// Scope filter: only include entity types in the active scope
|
||||||
|
if (scopeEntityTypes && scopeEntityTypes.length > 0) {
|
||||||
|
if (!scopeEntityTypes.includes(cfg.entityType)) return false;
|
||||||
|
}
|
||||||
if (cfg.featureFlag && settings) {
|
if (cfg.featureFlag && settings) {
|
||||||
const val = (settings as unknown as Record<string, unknown>)[cfg.featureFlag];
|
const val = (settings as unknown as Record<string, unknown>)[cfg.featureFlag];
|
||||||
if (cfg.featureFlag === 'enablePayments' || cfg.featureFlag === 'enableSms' || cfg.featureFlag === 'enablePeople') {
|
const defaultsOff = ['enablePayments', 'enableSms', 'enablePeople', 'enableSocial', 'enableChat', 'enableMeet'];
|
||||||
|
if (defaultsOff.includes(cfg.featureFlag)) {
|
||||||
if (val !== true) return false;
|
if (val !== true) return false;
|
||||||
} else if (val === false) return false;
|
} else if (val === false) return false;
|
||||||
}
|
}
|
||||||
@ -210,7 +217,7 @@ export function useEntitySearch(query: string) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [query, settings, user]);
|
}, [query, settings, user, scopeEntityTypes]);
|
||||||
|
|
||||||
return { results, loading };
|
return { results, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,30 +106,59 @@ const connectionTypeOptions = Object.entries(CONNECTION_TYPE_LABELS).map(([value
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function applyDagreLayout(nodes: Node[], edges: Edge[]): Node[] {
|
function applyDagreLayout(nodes: Node[], edges: Edge[]): Node[] {
|
||||||
|
// Separate connected nodes (have at least one edge) from isolated ones
|
||||||
|
const connectedIds = new Set<string>();
|
||||||
|
for (const e of edges) {
|
||||||
|
connectedIds.add(e.source);
|
||||||
|
connectedIds.add(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedNodes = nodes.filter((n) => connectedIds.has(n.id));
|
||||||
|
const isolatedNodes = nodes.filter((n) => !connectedIds.has(n.id));
|
||||||
|
|
||||||
|
// Layout connected nodes with dagre (tree layout)
|
||||||
|
let dagreMaxY = 0;
|
||||||
|
if (connectedNodes.length > 0) {
|
||||||
const g = new dagre.graphlib.Graph();
|
const g = new dagre.graphlib.Graph();
|
||||||
g.setDefaultEdgeLabel(() => ({}));
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 });
|
g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 });
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
connectedNodes.forEach((node) => {
|
||||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||||
});
|
});
|
||||||
|
|
||||||
edges.forEach((edge) => {
|
edges.forEach((edge) => {
|
||||||
g.setEdge(edge.source, edge.target);
|
g.setEdge(edge.source, edge.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
dagre.layout(g);
|
dagre.layout(g);
|
||||||
|
|
||||||
return nodes.map((node) => {
|
for (const node of connectedNodes) {
|
||||||
const nodeWithPosition = g.node(node.id);
|
const pos = g.node(node.id);
|
||||||
return {
|
node.position = { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 };
|
||||||
...node,
|
dagreMaxY = Math.max(dagreMaxY, pos.y + NODE_HEIGHT / 2);
|
||||||
position: {
|
}
|
||||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
}
|
||||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
|
||||||
},
|
// Arrange isolated nodes in a grid below the connected graph
|
||||||
|
if (isolatedNodes.length > 0) {
|
||||||
|
const GAP_X = NODE_WIDTH + 30;
|
||||||
|
const GAP_Y = NODE_HEIGHT + 30;
|
||||||
|
const cols = Math.max(1, Math.ceil(Math.sqrt(isolatedNodes.length)));
|
||||||
|
const startY = connectedNodes.length > 0 ? dagreMaxY + 80 : 0;
|
||||||
|
// Center the grid horizontally
|
||||||
|
const gridWidth = cols * GAP_X;
|
||||||
|
const startX = -gridWidth / 2;
|
||||||
|
|
||||||
|
isolatedNodes.forEach((node, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
node.position = {
|
||||||
|
x: startX + col * GAP_X,
|
||||||
|
y: startY + row * GAP_Y,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...connectedNodes, ...isolatedNodes];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionGraphProps {
|
interface ConnectionGraphProps {
|
||||||
|
|||||||
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 '@ant-design/v5-patch-for-react-19';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import App from './App';
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -58,6 +58,7 @@ import {
|
|||||||
ShoppingCartOutlined,
|
ShoppingCartOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } 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: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
|
||||||
{ id: 'product-card', label: 'Product Card', 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: '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: '---' },
|
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -590,6 +592,8 @@ export default function DocsPage() {
|
|||||||
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
||||||
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
||||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||||
|
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||||
|
const [pollSlugInput, setPollSlugInput] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -816,6 +820,13 @@ export default function DocsPage() {
|
|||||||
return;
|
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)
|
// Pricing table — static CTA (plans are dynamic, so link out)
|
||||||
if (snippetId === 'pricing-table') {
|
if (snippetId === 'pricing-table') {
|
||||||
const appUrl = config
|
const appUrl = config
|
||||||
@ -1058,6 +1069,23 @@ export default function DocsPage() {
|
|||||||
setAdPickerOpen(false);
|
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) => {
|
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
handleToolbarSnippet(snippetId);
|
handleToolbarSnippet(snippetId);
|
||||||
@ -1976,7 +2004,7 @@ export default function DocsPage() {
|
|||||||
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
||||||
key: s.id,
|
key: s.id,
|
||||||
label: s.label,
|
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),
|
onClick: () => handleToolbarSnippet(s.id),
|
||||||
})) }} trigger={['click']}>
|
})) }} trigger={['click']}>
|
||||||
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
||||||
@ -2190,6 +2218,27 @@ export default function DocsPage() {
|
|||||||
onInsert={handleAdInsert}
|
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 */}
|
{/* Custom right-click context menu with submenus */}
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<div
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
admin/src/pages/NotFoundPage.tsx
Normal file
92
admin/src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Result, Button, Input, App, Space } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, HomeOutlined, MailOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { isAdmin } from '@/utils/roles';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
const [reportMessage, setReportMessage] = useState('');
|
||||||
|
|
||||||
|
const homePath = !isAuthenticated
|
||||||
|
? '/home'
|
||||||
|
: user && isAdmin(user)
|
||||||
|
? '/app'
|
||||||
|
: '/volunteer';
|
||||||
|
|
||||||
|
const handleReport = async () => {
|
||||||
|
if (!showInput) {
|
||||||
|
setShowInput(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReporting(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/api/public/error-report`, {
|
||||||
|
url: location.pathname + location.search,
|
||||||
|
message: reportMessage || undefined,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
message.success('Report sent to admins. Thank you!');
|
||||||
|
setShowInput(false);
|
||||||
|
setReportMessage('');
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to send report. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setReporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle={`The page "${location.pathname}" was not found.`}
|
||||||
|
extra={
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<HomeOutlined />} onClick={() => navigate(homePath)}>
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<MailOutlined />}
|
||||||
|
onClick={handleReport}
|
||||||
|
loading={reporting}
|
||||||
|
>
|
||||||
|
Report to Admin
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
{showInput && (
|
||||||
|
<div>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="Optional: describe what you were looking for..."
|
||||||
|
value={reportMessage}
|
||||||
|
onChange={(e) => setReportMessage(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={handleReport} loading={reporting} block>
|
||||||
|
Send Report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 }}>
|
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</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 />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { DonationWidget } from '@/components/payments/DonationWidget';
|
|||||||
import { PricingWidget } from '@/components/payments/PricingWidget';
|
import { PricingWidget } from '@/components/payments/PricingWidget';
|
||||||
import { ProductWidget } from '@/components/payments/ProductWidget';
|
import { ProductWidget } from '@/components/payments/ProductWidget';
|
||||||
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
||||||
|
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
|
||||||
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
||||||
import type { GalleryAd } from '@/types/gallery-ads';
|
import type { GalleryAd } from '@/types/gallery-ads';
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export default function PublicLandingPage() {
|
|||||||
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
const pollRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
|
||||||
// Track page view
|
// Track page view
|
||||||
useEffect(() => {
|
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
|
// Hydrate after DOM is ready
|
||||||
setTimeout(hydrateVideoBlocks, 100);
|
setTimeout(hydrateVideoBlocks, 100);
|
||||||
setTimeout(hydrateVideoCards, 200);
|
setTimeout(hydrateVideoCards, 200);
|
||||||
setTimeout(hydratePaymentBlocks, 150);
|
setTimeout(hydratePaymentBlocks, 150);
|
||||||
setTimeout(hydrateCampaignFormBlocks, 175);
|
setTimeout(hydrateCampaignFormBlocks, 175);
|
||||||
setTimeout(hydrateAdBlocks, 200);
|
setTimeout(hydrateAdBlocks, 200);
|
||||||
|
setTimeout(hydratePollBlocks, 180);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
@ -350,6 +384,11 @@ export default function PublicLandingPage() {
|
|||||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
|
||||||
});
|
});
|
||||||
adRootsRef.current = [];
|
adRootsRef.current = [];
|
||||||
|
|
||||||
|
pollRootsRef.current.forEach((root) => {
|
||||||
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root on cleanup:', err); }
|
||||||
|
});
|
||||||
|
pollRootsRef.current = [];
|
||||||
};
|
};
|
||||||
}, [page]);
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
admin/src/pages/sms/NewConversationModal.tsx
Normal file
231
admin/src/pages/sms/NewConversationModal.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Input, List, Tag, Typography, Space, Button, App } from 'antd';
|
||||||
|
import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
sms_contact: { label: 'SMS List', color: 'purple' },
|
||||||
|
crm_contact: { label: 'CRM', color: 'blue' },
|
||||||
|
conversation: { label: 'Previous', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (conv: SmsConversation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewConversationModal({ open, onClose, onCreated }: Props) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SmsContactSearchResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedContact, setSelectedContact] = useState<SmsContactSearchResult | null>(null);
|
||||||
|
const [msgText, setMsgText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
|
|
||||||
|
// Search contacts when debounced query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedQuery || debouncedQuery.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSearching(true);
|
||||||
|
api.get<{ results: SmsContactSearchResult[] }>('/sms/conversations/contact-search', {
|
||||||
|
params: { q: debouncedQuery },
|
||||||
|
}).then(({ data }) => {
|
||||||
|
if (!cancelled) setResults(data.results);
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setResults([]);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setSearching(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
// Check if query looks like a phone number (mostly digits, 7+)
|
||||||
|
const digitsOnly = query.replace(/\D/g, '');
|
||||||
|
const isPhoneQuery = digitsOnly.length >= 7;
|
||||||
|
const phoneAlreadyInResults = isPhoneQuery && results.some((r) => r.phone.includes(digitsOnly));
|
||||||
|
|
||||||
|
const handleSelect = (contact: SmsContactSearchResult) => {
|
||||||
|
setSelectedContact(contact);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualPhone = () => {
|
||||||
|
setSelectedContact({
|
||||||
|
phone: digitsOnly,
|
||||||
|
name: null,
|
||||||
|
source: 'sms_contact',
|
||||||
|
sourceId: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!selectedContact || !msgText.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<SmsConversation>('/sms/conversations', {
|
||||||
|
phone: selectedContact.phone,
|
||||||
|
message: msgText.trim(),
|
||||||
|
contactName: selectedContact.name || undefined,
|
||||||
|
contactId: selectedContact.contactId || undefined,
|
||||||
|
});
|
||||||
|
message.success('Message queued');
|
||||||
|
onCreated(data);
|
||||||
|
handleReset();
|
||||||
|
} catch (err: any) {
|
||||||
|
const errMsg = err.response?.data?.error || 'Failed to send message';
|
||||||
|
message.error(errMsg);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setSelectedContact(null);
|
||||||
|
setMsgText('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setSelectedContact(null);
|
||||||
|
setMsgText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="New Conversation"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleReset}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
{!selectedContact ? (
|
||||||
|
// Step 1: Contact Search
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or phone number..."
|
||||||
|
prefix={<PhoneOutlined />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, maxHeight: 320, overflowY: 'auto' }}>
|
||||||
|
{/* Manual phone entry option */}
|
||||||
|
{isPhoneQuery && !phoneAlreadyInResults && (
|
||||||
|
<div
|
||||||
|
onClick={handleManualPhone}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
border: '1px dashed rgba(255,255,255,0.15)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<PhoneOutlined />
|
||||||
|
<Text>Use number: {digitsOnly}</Text>
|
||||||
|
<Tag color="green">Manual</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List
|
||||||
|
loading={searching}
|
||||||
|
dataSource={results}
|
||||||
|
locale={{ emptyText: query.length >= 2 ? 'No contacts found' : 'Type to search...' }}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
style={{ cursor: 'pointer', padding: '8px 12px', borderRadius: 6 }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
<div>
|
||||||
|
<Text strong>{item.name || item.phone}</Text>
|
||||||
|
{item.name && <Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>{item.phone}</Text>}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Tag color={SOURCE_LABELS[item.source]?.color}>
|
||||||
|
{SOURCE_LABELS[item.source]?.label}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Step 2: Compose Message
|
||||||
|
<div>
|
||||||
|
<Button type="link" onClick={handleBack} style={{ padding: 0, marginBottom: 8 }}>
|
||||||
|
← Change contact
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
<div>
|
||||||
|
{selectedContact.name && <Text strong>{selectedContact.name}</Text>}
|
||||||
|
<Text type="secondary" style={{ marginLeft: selectedContact.name ? 8 : 0 }}>
|
||||||
|
{selectedContact.phone}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={msgText}
|
||||||
|
onChange={(e) => setMsgText(e.target.value)}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
maxLength={1600}
|
||||||
|
showCount
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleReset}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSend}
|
||||||
|
loading={sending}
|
||||||
|
disabled={!msgText.trim()}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -26,9 +26,10 @@ export default function SmsCampaignsPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
|
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Preview & Test
|
// Preview & Test
|
||||||
const [testPreview, setTestPreview] = useState('');
|
const [testPreview, setTestPreview] = useState('');
|
||||||
@ -67,16 +68,21 @@ export default function SmsCampaignsPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [campaigns, fetchCampaigns]);
|
}, [campaigns, fetchCampaigns]);
|
||||||
|
|
||||||
|
const closeDrawer = () => { setDrawerOpen(false); setTestPreview(''); };
|
||||||
|
|
||||||
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
|
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/sms/campaigns', values);
|
await api.post('/sms/campaigns', values);
|
||||||
message.success('Campaign created');
|
message.success('Campaign created');
|
||||||
setCreateOpen(false);
|
closeDrawer();
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchCampaigns();
|
fetchCampaigns();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Create failed';
|
const msg = err instanceof Error ? err.message : 'Create failed';
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -209,13 +215,16 @@ export default function SmsCampaignsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const drawerWidth = 480;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => { setCreateOpen(true); fetchContactLists(); }}
|
onClick={() => { setDrawerOpen(true); fetchContactLists(); }}
|
||||||
>
|
>
|
||||||
New Campaign
|
New Campaign
|
||||||
</Button>
|
</Button>
|
||||||
@ -229,14 +238,24 @@ export default function SmsCampaignsPage() {
|
|||||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Campaign Modal */}
|
<Drawer
|
||||||
<Modal
|
|
||||||
title="New SMS Campaign"
|
title="New SMS Campaign"
|
||||||
open={createOpen}
|
open={drawerOpen}
|
||||||
onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
|
onClose={closeDrawer}
|
||||||
onOk={() => createForm.submit()}
|
destroyOnHidden
|
||||||
width={600}
|
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 form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ delayBetweenMs: 3000 }}>
|
||||||
<Form.Item name="name" label="Campaign Name" rules={[{ required: true }]}>
|
<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%' }} />
|
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
|
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 } from '@ant-design/icons';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
||||||
import type { AppOutletContext } from '@/types/api';
|
import type { AppOutletContext } from '@/types/api';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -19,23 +20,39 @@ export default function SmsContactsPage() {
|
|||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
const [lists, setLists] = useState<SmsContactList[]>([]);
|
// --- Contacts (entries) state ---
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [importListId, setImportListId] = useState<string | null>(null);
|
|
||||||
const [csvText, setCsvText] = useState('');
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
const [selectedList, setSelectedList] = useState<SmsContactList | null>(null);
|
|
||||||
const [entries, setEntries] = useState<SmsContactListEntry[]>([]);
|
const [entries, setEntries] = useState<SmsContactListEntry[]>([]);
|
||||||
const [entriesTotal, setEntriesTotal] = useState(0);
|
const [entriesTotal, setEntriesTotal] = useState(0);
|
||||||
const [entriesPage, setEntriesPage] = useState(1);
|
const [entriesPage, setEntriesPage] = useState(1);
|
||||||
const [entriesLoading, setEntriesLoading] = useState(false);
|
const [entriesLoading, setEntriesLoading] = useState(true);
|
||||||
const [createForm] = Form.useForm();
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(searchText, 300);
|
||||||
|
const [filterListId, setFilterListId] = useState<string | undefined>();
|
||||||
|
|
||||||
// Import modal state
|
// --- Lists state ---
|
||||||
|
const [lists, setLists] = useState<SmsContactList[]>([]);
|
||||||
|
const [listsTotal, setListsTotal] = useState(0);
|
||||||
|
const [listsPage, setListsPage] = useState(1);
|
||||||
|
const [listsLoading, setListsLoading] = useState(true);
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
|
// --- 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 [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [importLoading, setImportLoading] = useState(false);
|
const [importLoading, setImportLoading] = useState(false);
|
||||||
@ -55,35 +72,47 @@ export default function SmsContactsPage() {
|
|||||||
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
|
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
|
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contacts across all lists' });
|
||||||
}, [setPageHeader]);
|
}, [setPageHeader]);
|
||||||
|
|
||||||
const fetchLists = useCallback(async () => {
|
// --- Fetch all entries (contacts-first view) ---
|
||||||
setLoading(true);
|
const fetchEntries = useCallback(async (p = 1) => {
|
||||||
try {
|
|
||||||
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page, limit: 50 } });
|
|
||||||
setLists(data.items);
|
|
||||||
setTotal(data.total);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchLists(); }, [fetchLists]);
|
|
||||||
|
|
||||||
const fetchEntries = useCallback(async (listId: string, p = 1) => {
|
|
||||||
setEntriesLoading(true);
|
setEntriesLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>(`/sms/contacts/${listId}/entries`, { params: { page: p, limit: 100 } });
|
const params: Record<string, string | number> = { page: p, limit: 50 };
|
||||||
|
if (filterListId) params.listId = filterListId;
|
||||||
|
if (debouncedSearch) params.search = debouncedSearch;
|
||||||
|
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>('/sms/contacts/all-entries', { params });
|
||||||
setEntries(data.items);
|
setEntries(data.items);
|
||||||
setEntriesTotal(data.total);
|
setEntriesTotal(data.total);
|
||||||
setEntriesPage(p);
|
setEntriesPage(p);
|
||||||
} finally {
|
} finally {
|
||||||
setEntriesLoading(false);
|
setEntriesLoading(false);
|
||||||
}
|
}
|
||||||
|
}, [filterListId, debouncedSearch]);
|
||||||
|
|
||||||
|
// --- Fetch lists (for dropdown + lists panel) ---
|
||||||
|
const fetchLists = useCallback(async (p = 1) => {
|
||||||
|
setListsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page: p, limit: 50 } });
|
||||||
|
setLists(data.items);
|
||||||
|
setListsTotal(data.total);
|
||||||
|
setListsPage(p);
|
||||||
|
} finally {
|
||||||
|
setListsLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
|
useEffect(() => { fetchLists(); }, [fetchLists]);
|
||||||
|
|
||||||
|
// Re-fetch entries when filters or page change
|
||||||
|
useEffect(() => {
|
||||||
|
setEntriesPage(1);
|
||||||
|
fetchEntries(1);
|
||||||
|
}, [fetchEntries]);
|
||||||
|
|
||||||
|
// Load shifts and SMS campaigns for filter dropdowns when import drawer opens
|
||||||
const loadFilterOptions = useCallback(async () => {
|
const loadFilterOptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [shiftsRes, campaignsRes] = await Promise.all([
|
const [shiftsRes, campaignsRes] = await Promise.all([
|
||||||
@ -97,15 +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 }) => {
|
const handleCreate = async (values: { name: string }) => {
|
||||||
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/sms/contacts', values);
|
const { data } = await api.post('/sms/contacts', values);
|
||||||
message.success('Contact list created');
|
message.success('Contact list created');
|
||||||
setCreateOpen(false);
|
closeCreateDrawer();
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
|
// Auto-select the new list in the filter
|
||||||
|
setFilterListId(data.id);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to create list');
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -113,7 +198,10 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/sms/contacts/${id}`);
|
await api.delete(`/sms/contacts/${id}`);
|
||||||
message.success('List archived');
|
message.success('List archived');
|
||||||
|
// If we're filtering by this list, clear the filter
|
||||||
|
if (filterListId === id) setFilterListId(undefined);
|
||||||
fetchLists();
|
fetchLists();
|
||||||
|
fetchEntries(1);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to archive list');
|
message.error('Failed to archive list');
|
||||||
}
|
}
|
||||||
@ -125,10 +213,9 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
|
||||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Import failed';
|
const msg = err instanceof Error ? err.message : 'Import failed';
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
@ -143,10 +230,9 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
|
||||||
message.success(`Imported ${data.imported} contacts from phone`);
|
message.success(`Imported ${data.imported} contacts from phone`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to import from phone');
|
message.error('Failed to import from phone');
|
||||||
} finally {
|
} finally {
|
||||||
@ -154,26 +240,28 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEntry = async (entryId: string) => {
|
const handleDeleteEntry = async (entry: SmsContactListEntry) => {
|
||||||
if (!selectedList) return;
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/sms/contacts/${selectedList.id}/entries/${entryId}`);
|
await api.delete(`/sms/contacts/${entry.listId}/entries/${entry.id}`);
|
||||||
message.success('Entry removed');
|
message.success('Contact removed');
|
||||||
fetchEntries(selectedList.id, entriesPage);
|
fetchEntries(entriesPage);
|
||||||
fetchLists();
|
fetchLists();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to remove entry');
|
message.error('Failed to remove contact');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDrawer = (list: SmsContactList) => {
|
const openImportDrawer = (listId?: string) => {
|
||||||
setSelectedList(list);
|
if (listId) {
|
||||||
setDrawerOpen(true);
|
|
||||||
fetchEntries(list.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openImportModal = (listId: string) => {
|
|
||||||
setImportListId(listId);
|
setImportListId(listId);
|
||||||
|
} else if (filterListId) {
|
||||||
|
setImportListId(filterListId);
|
||||||
|
} else if (lists.length === 1) {
|
||||||
|
setImportListId(lists[0]!.id);
|
||||||
|
} else {
|
||||||
|
message.info('Select a list first, or use the filter dropdown to choose one');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
loadFilterOptions();
|
loadFilterOptions();
|
||||||
};
|
};
|
||||||
@ -251,10 +339,9 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
|
||||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Import failed');
|
message.error('Import failed');
|
||||||
} finally {
|
} finally {
|
||||||
@ -298,30 +385,75 @@ export default function SmsContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<SmsContactList> = [
|
// --- Main contacts table columns ---
|
||||||
|
const contactColumns: ColumnsType<SmsContactListEntry> = [
|
||||||
|
{ title: 'Phone', dataIndex: 'phone', width: 140 },
|
||||||
|
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
|
||||||
|
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text>, responsive: ['md'] },
|
||||||
|
{
|
||||||
|
title: 'List',
|
||||||
|
dataIndex: ['list', 'name'],
|
||||||
|
width: 180,
|
||||||
|
render: (name: string, record) => {
|
||||||
|
if (!record.list) return <Text type="secondary">-</Text>;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setFilterListId(record.list!.id)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Added',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 110,
|
||||||
|
render: (d) => new Date(d).toLocaleDateString(),
|
||||||
|
responsive: ['lg'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 40,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm title="Remove this contact?" onConfirm={() => handleDeleteEntry(record)}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Lists panel columns ---
|
||||||
|
const listColumns: ColumnsType<SmsContactList> = [
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (name, record) => <a onClick={() => openDrawer(record)}>{name}</a>,
|
render: (name, record) => (
|
||||||
|
<a onClick={() => setFilterListId(record.id)}>{name}</a>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: 'Contacts', dataIndex: 'totalContacts', width: 100 },
|
{ title: 'Contacts', dataIndex: 'totalContacts', width: 90 },
|
||||||
{
|
{
|
||||||
title: 'Source',
|
title: 'Source',
|
||||||
dataIndex: 'originalFilename',
|
dataIndex: 'originalFilename',
|
||||||
render: (f) => f || <Text type="secondary">Manual</Text>,
|
render: (f) => f || <Text type="secondary">Manual</Text>,
|
||||||
|
responsive: ['md'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Created',
|
title: 'Created',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
render: (d) => new Date(d).toLocaleDateString(),
|
render: (d) => new Date(d).toLocaleDateString(),
|
||||||
width: 120,
|
width: 110,
|
||||||
|
responsive: ['lg'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<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)}>
|
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@ -330,21 +462,6 @@ export default function SmsContactsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const entryColumns: ColumnsType<SmsContactListEntry> = [
|
|
||||||
{ title: 'Phone', dataIndex: 'phone', width: 140 },
|
|
||||||
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
|
|
||||||
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text> },
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
width: 40,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Popconfirm title="Remove?" onConfirm={() => handleDeleteEntry(record.id)}>
|
|
||||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const supportLevelOptions = [
|
const supportLevelOptions = [
|
||||||
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
|
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
|
||||||
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
|
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
|
||||||
@ -367,43 +484,211 @@ export default function SmsContactsPage() {
|
|||||||
{ value: 'MAP_ADMIN', label: 'Map Admin' },
|
{ value: 'MAP_ADMIN', label: 'Map Admin' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<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 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>
|
</Space>
|
||||||
|
|
||||||
|
{/* Lists management panel */}
|
||||||
|
<Collapse
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
items={[{
|
||||||
|
key: 'lists',
|
||||||
|
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
|
||||||
|
children: (
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={listColumns}
|
||||||
dataSource={lists}
|
dataSource={lists}
|
||||||
loading={loading}
|
loading={listsLoading}
|
||||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
pagination={{
|
||||||
size="middle"
|
current: listsPage,
|
||||||
|
total: listsTotal,
|
||||||
|
pageSize: 50,
|
||||||
|
onChange: (p) => fetchLists(p),
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Create List Modal */}
|
{/* Selection action bar */}
|
||||||
<Modal
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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"
|
title="New Contact List"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onClose={closeCreateDrawer}
|
||||||
onOk={() => createForm.submit()}
|
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 form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||||
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
|
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
|
||||||
<Input placeholder="e.g. Ward 6 Supporters" />
|
<Input placeholder="e.g. Ward 6 Supporters" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</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>
|
</Modal>
|
||||||
|
|
||||||
{/* Unified Import Modal */}
|
{/* Import Contacts Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Import Contacts"
|
title={`Import Contacts${importListName ? ` → ${importListName}` : ''}`}
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onCancel={() => { setImportOpen(false); resetImportState(); }}
|
onClose={closeImportDrawer}
|
||||||
footer={null}
|
destroyOnHidden
|
||||||
width={640}
|
mask={false}
|
||||||
destroyOnClose
|
width={importDrawerWidth}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Button onClick={closeImportDrawer}>Close</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
onChange={() => setPreview(null)}
|
onChange={() => setPreview(null)}
|
||||||
@ -599,28 +884,6 @@ export default function SmsContactsPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Entries Drawer */}
|
|
||||||
<Drawer
|
|
||||||
title={selectedList ? `${selectedList.name} (${selectedList.totalContacts} contacts)` : 'Entries'}
|
|
||||||
open={drawerOpen}
|
|
||||||
onClose={() => setDrawerOpen(false)}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
columns={entryColumns}
|
|
||||||
dataSource={entries}
|
|
||||||
loading={entriesLoading}
|
|
||||||
pagination={{
|
|
||||||
current: entriesPage,
|
|
||||||
total: entriesTotal,
|
|
||||||
pageSize: 100,
|
|
||||||
onChange: (p) => selectedList && fetchEntries(selectedList.id, p),
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
|
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
|
||||||
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
|
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
|
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
|
||||||
import type { AppOutletContext } from '@/types/api';
|
import type { AppOutletContext } from '@/types/api';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
|
import NewConversationModal from './NewConversationModal';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
const { Search, TextArea } = Input;
|
const { Search, TextArea } = Input;
|
||||||
@ -37,6 +38,9 @@ export default function SmsConversationsPage() {
|
|||||||
const [notesSaving, setNotesSaving] = useState(false);
|
const [notesSaving, setNotesSaving] = useState(false);
|
||||||
const [tagsSaving, setTagsSaving] = useState(false);
|
const [tagsSaving, setTagsSaving] = useState(false);
|
||||||
|
|
||||||
|
// New conversation modal
|
||||||
|
const [newConvOpen, setNewConvOpen] = useState(false);
|
||||||
|
|
||||||
// Bulk selection state
|
// Bulk selection state
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [bulkLoading, setBulkLoading] = useState(false);
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
@ -179,12 +183,17 @@ export default function SmsConversationsPage() {
|
|||||||
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
||||||
{/* Conversation List (left panel) */}
|
{/* Conversation List (left panel) */}
|
||||||
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
<Search
|
<Search
|
||||||
placeholder="Search by phone or name..."
|
placeholder="Search by phone or name..."
|
||||||
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
|
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: 8 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewConvOpen(true)}>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
@ -396,6 +405,15 @@ export default function SmsConversationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<NewConversationModal
|
||||||
|
open={newConvOpen}
|
||||||
|
onClose={() => setNewConvOpen(false)}
|
||||||
|
onCreated={(conv) => {
|
||||||
|
fetchConversations();
|
||||||
|
if (conv) setSelected(conv);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -471,7 +471,7 @@ export default function SmsSetupPage() {
|
|||||||
<li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
|
<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>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
|
||||||
<li>Set up Termux:Boot auto-start (if installed)</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>
|
</ul>
|
||||||
<Paragraph style={{ marginTop: 8 }}>
|
<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.
|
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>
|
<div>
|
||||||
<Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
|
<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 }}>
|
<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>
|
</div>
|
||||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
<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>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -898,7 +898,7 @@ export default function SmsSetupPage() {
|
|||||||
The Termux API server is not responding. This can mean:
|
The Termux API server is not responding. This can mean:
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
|
<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>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>
|
<li><Text strong>Network issue</Text> — the phone may not be reachable. Check Tailscale is connected on the phone.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
408
admin/src/pages/sms/SmsTemplatesPage.tsx
Normal file
408
admin/src/pages/sms/SmsTemplatesPage.tsx
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
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';
|
||||||
|
import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms';
|
||||||
|
import type { AppOutletContext } from '@/types/api';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
notification: 'blue',
|
||||||
|
campaign: 'purple',
|
||||||
|
custom: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Known placeholder sample values for live preview */
|
||||||
|
const SAMPLE_VALUES: Record<string, string> = {
|
||||||
|
name: 'Jane Doe',
|
||||||
|
phone: '+1 555 000 0000',
|
||||||
|
shiftTitle: 'Ward 6 Canvass',
|
||||||
|
shiftTime: '2:00 PM',
|
||||||
|
shiftDate: 'Mar 15',
|
||||||
|
shiftLocation: 'Community Centre',
|
||||||
|
organizationName: 'Changemaker',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extract {var} names from a template string */
|
||||||
|
function extractVars(template: string): string[] {
|
||||||
|
const vars: string[] = [];
|
||||||
|
const regex = /\{(\w+)\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(template)) !== null) {
|
||||||
|
const v = m[1] as string;
|
||||||
|
if (!vars.includes(v)) vars.push(v);
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate SMS segment count */
|
||||||
|
function segmentCount(length: number): number {
|
||||||
|
if (length === 0) return 0;
|
||||||
|
if (length <= 160) return 1;
|
||||||
|
return Math.ceil(length / 153);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a live preview with sample substitutions */
|
||||||
|
function renderPreview(template: string): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, key) => SAMPLE_VALUES[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmsTemplatesPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<SmsMessageTemplate[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||||
|
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Live template text for character counter + variable extraction
|
||||||
|
const [liveTemplate, setLiveTemplate] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({ title: 'SMS Templates', subtitle: 'Manage reusable SMS message templates' });
|
||||||
|
}, [setPageHeader]);
|
||||||
|
|
||||||
|
const fetchTemplates = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page, limit: 50 };
|
||||||
|
if (debouncedSearch) params.search = debouncedSearch;
|
||||||
|
if (categoryFilter) params.category = categoryFilter;
|
||||||
|
if (favoritesOnly) params.isFavorite = 'true';
|
||||||
|
const { data } = await api.get<SmsPaginatedResponse<SmsMessageTemplate>>('/sms/templates', { params });
|
||||||
|
setTemplates(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, debouncedSearch, categoryFilter, favoritesOnly]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (record: SmsMessageTemplate) => {
|
||||||
|
setEditingId(record.id);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.name,
|
||||||
|
template: record.template,
|
||||||
|
description: record.description || '',
|
||||||
|
category: record.category || undefined,
|
||||||
|
});
|
||||||
|
setLiveTemplate(record.template);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDuplicate = (record: SmsMessageTemplate) => {
|
||||||
|
setEditingId(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: `${record.name} (copy)`,
|
||||||
|
template: record.template,
|
||||||
|
description: record.description || '',
|
||||||
|
category: record.category || undefined,
|
||||||
|
});
|
||||||
|
setLiveTemplate(record.template);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await api.put(`/sms/templates/${editingId}`, values);
|
||||||
|
message.success('Template updated');
|
||||||
|
} else {
|
||||||
|
await api.post('/sms/templates', values);
|
||||||
|
message.success('Template created');
|
||||||
|
}
|
||||||
|
closeDrawer();
|
||||||
|
form.resetFields();
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||||
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/sms/templates/${id}`);
|
||||||
|
message.success('Template deleted');
|
||||||
|
fetchTemplates();
|
||||||
|
} catch {
|
||||||
|
message.error('Delete failed — system templates cannot be deleted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/sms/templates/${id}/favorite`);
|
||||||
|
fetchTemplates();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to toggle favorite');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed values for drawer
|
||||||
|
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
|
||||||
|
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
|
||||||
|
const charCount = liveTemplate.length;
|
||||||
|
const segments = segmentCount(charCount);
|
||||||
|
|
||||||
|
const columns: ColumnsType<SmsMessageTemplate> = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (name, record) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title={record.isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
||||||
|
<span style={{ cursor: 'pointer' }} onClick={() => handleToggleFavorite(record.id)}>
|
||||||
|
{record.isFavorite ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined style={{ color: 'rgba(255,255,255,0.3)' }} />}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<span>{name}</span>
|
||||||
|
{record.isSystem && <Tag color="geekblue">SYSTEM</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category',
|
||||||
|
width: 120,
|
||||||
|
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Variables',
|
||||||
|
width: 200,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space wrap size={2}>
|
||||||
|
{(record.variables || []).map((v) => (
|
||||||
|
<Tag key={v} style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
|
||||||
|
))}
|
||||||
|
{(!record.variables || record.variables.length === 0) && <Text type="secondary">-</Text>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Uses',
|
||||||
|
dataIndex: 'usageCount',
|
||||||
|
width: 70,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Updated',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 100,
|
||||||
|
render: (d) => {
|
||||||
|
const diff = Date.now() - new Date(d).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Duplicate">
|
||||||
|
<Button size="small" icon={<CopyOutlined />} onClick={() => openDuplicate(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
{!record.isSystem && (
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Delete template?',
|
||||||
|
content: `This will permanently delete "${record.name}".`,
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => handleDelete(record.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const drawerWidth = 480;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={templates}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={editingId ? 'Edit Template' : 'New Template'}
|
||||||
|
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' }]}>
|
||||||
|
<Input placeholder="e.g. shift-reminder-custom" maxLength={200} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="template"
|
||||||
|
label="Message Template"
|
||||||
|
rules={[{ required: true, message: 'Template body is required' }]}
|
||||||
|
extra={
|
||||||
|
<Space style={{ marginTop: 4 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{charCount} / 1,600 chars
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{segments} SMS segment{segments !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
{charCount > 160 && (
|
||||||
|
<Text type="warning" style={{ fontSize: 12 }}>
|
||||||
|
Multi-part message (153 chars/segment)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
maxLength={1600}
|
||||||
|
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
|
||||||
|
onChange={(e) => setLiveTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Live variables */}
|
||||||
|
{liveVars.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>Detected variables: </Text>
|
||||||
|
{liveVars.map((v) => (
|
||||||
|
<Tag key={v} color="blue" style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
{liveTemplate && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
}}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>Preview:</Text>
|
||||||
|
<Text style={{ fontSize: 13, fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{livePreview}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item name="description" label="Description">
|
||||||
|
<TextArea rows={2} maxLength={500} placeholder="Internal notes about when to use this template" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="Category">
|
||||||
|
<Select
|
||||||
|
placeholder="Select category"
|
||||||
|
allowClear
|
||||||
|
options={[
|
||||||
|
{ value: 'notification', label: 'Notification' },
|
||||||
|
{ value: 'campaign', label: 'Campaign' },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1149,6 +1149,7 @@ export interface SiteSettings {
|
|||||||
enablePeople: boolean;
|
enablePeople: boolean;
|
||||||
enableSocial: boolean;
|
enableSocial: boolean;
|
||||||
enableMeet: boolean;
|
enableMeet: boolean;
|
||||||
|
enableMeetingPlanner: boolean;
|
||||||
autoSyncPeopleToMap: boolean;
|
autoSyncPeopleToMap: boolean;
|
||||||
// SMS connection config (only present from admin endpoint)
|
// SMS connection config (only present from admin endpoint)
|
||||||
smsTermuxApiUrl?: string;
|
smsTermuxApiUrl?: string;
|
||||||
@ -2252,7 +2253,7 @@ export interface DashboardRecentSignupsResult {
|
|||||||
|
|
||||||
export interface UnifiedCalendarItem {
|
export interface UnifiedCalendarItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'shift' | 'event';
|
type: 'shift' | 'event' | 'poll';
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@ -2264,6 +2265,10 @@ export interface UnifiedCalendarItem {
|
|||||||
currentVolunteers?: number;
|
currentVolunteers?: number;
|
||||||
gancioEventId?: number;
|
gancioEventId?: number;
|
||||||
gancioUrl?: string;
|
gancioUrl?: string;
|
||||||
|
pollId?: string;
|
||||||
|
pollSlug?: string;
|
||||||
|
pollStatus?: SchedulingPollStatus;
|
||||||
|
pollVoteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedCalendarResponse {
|
export interface UnifiedCalendarResponse {
|
||||||
@ -2740,3 +2745,111 @@ export interface ListmonkCampaignsData {
|
|||||||
error?: string;
|
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>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface SmsContactListEntry {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
customFields: Record<string, string> | null;
|
customFields: Record<string, string> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
list?: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Campaigns ---
|
// --- Campaigns ---
|
||||||
@ -106,6 +107,34 @@ export interface SmsConversation {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact Search ---
|
||||||
|
|
||||||
|
export interface SmsContactSearchResult {
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
source: 'sms_contact' | 'crm_contact' | 'conversation';
|
||||||
|
sourceId: string;
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
export interface SmsMessageTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
template: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
isFavorite: boolean;
|
||||||
|
usageCount: number;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
createdByUser?: { id: string; name: string | null; email: string };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
variables?: string[];
|
||||||
|
isSystem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Setup ---
|
// --- Setup ---
|
||||||
|
|
||||||
export interface SmsSetupStatus {
|
export interface SmsSetupStatus {
|
||||||
|
|||||||
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
|
// People CRM
|
||||||
contact Contact? @relation("UserContact")
|
contact Contact? @relation("UserContact")
|
||||||
|
|
||||||
|
// Scheduling polls
|
||||||
|
schedulingPollsCreated SchedulingPoll[] @relation("PollCreator")
|
||||||
|
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||||
|
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -686,6 +691,9 @@ model Shift {
|
|||||||
canvassVisits CanvassVisit[]
|
canvassVisits CanvassVisit[]
|
||||||
canvassSessions CanvassSession[]
|
canvassSessions CanvassSession[]
|
||||||
|
|
||||||
|
// Scheduling poll conversion
|
||||||
|
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
|
||||||
|
|
||||||
@@index([cutId])
|
@@index([cutId])
|
||||||
@@index([seriesId])
|
@@index([seriesId])
|
||||||
@@map("shifts")
|
@@map("shifts")
|
||||||
@ -889,6 +897,7 @@ model SiteSettings {
|
|||||||
enablePeople Boolean @default(false) @map("enable_people")
|
enablePeople Boolean @default(false) @map("enable_people")
|
||||||
enableSocial Boolean @default(false) @map("enable_social")
|
enableSocial Boolean @default(false) @map("enable_social")
|
||||||
enableMeet Boolean @default(false) @map("enable_meet")
|
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")
|
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||||
|
|
||||||
// SMS connection config (overrides env vars when non-empty)
|
// SMS connection config (overrides env vars when non-empty)
|
||||||
@ -4294,3 +4303,103 @@ model Meeting {
|
|||||||
|
|
||||||
@@map("meetings")
|
@@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,
|
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) {
|
for (const block of defaultBlocks) {
|
||||||
|
|||||||
@ -153,6 +153,11 @@ const start = async () => {
|
|||||||
await fastify.register(photosPublicRoutes, { prefix: '/api' });
|
await fastify.register(photosPublicRoutes, { prefix: '/api' });
|
||||||
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
|
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
|
// 404 handler for unmatched routes
|
||||||
|
fastify.setNotFoundHandler((_request, reply) => {
|
||||||
|
reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
||||||
|
});
|
||||||
|
|
||||||
const port = env.MEDIA_API_PORT;
|
const port = env.MEDIA_API_PORT;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
|
|
||||||
|
|||||||
@ -360,6 +360,23 @@ export const eventSubmissionRateLimit = rateLimit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const errorReportRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
|
prefix: 'rl:error-report:',
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
message: 'Too many error reports, please try again later',
|
||||||
|
code: 'ERROR_REPORT_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const healthMetricsRateLimit = rateLimit({
|
export const healthMetricsRateLimit = rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 minute
|
windowMs: 60 * 1000, // 1 minute
|
||||||
max: 30, // 30 requests per minute
|
max: 30, // 30 requests per minute
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
|
|||||||
|
|
||||||
export interface UnifiedCalendarItem {
|
export interface UnifiedCalendarItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'shift' | 'event';
|
type: 'shift' | 'event' | 'poll';
|
||||||
title: string;
|
title: string;
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
startTime: string; // HH:MM
|
startTime: string; // HH:MM
|
||||||
@ -23,6 +23,11 @@ export interface UnifiedCalendarItem {
|
|||||||
// Event-specific
|
// Event-specific
|
||||||
gancioEventId?: number;
|
gancioEventId?: number;
|
||||||
gancioUrl?: string;
|
gancioUrl?: string;
|
||||||
|
// Poll-specific
|
||||||
|
pollId?: string;
|
||||||
|
pollSlug?: string;
|
||||||
|
pollStatus?: string;
|
||||||
|
pollVoteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedCalendarResponse {
|
export interface UnifiedCalendarResponse {
|
||||||
@ -50,10 +55,11 @@ export const unifiedCalendarService = {
|
|||||||
// Set end to end of day
|
// Set end to end of day
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// Fetch shifts and Gancio events in parallel
|
// Fetch shifts, Gancio events, and polls in parallel
|
||||||
const [shifts, gancioEvents] = await Promise.all([
|
const [shifts, gancioEvents, pollItems] = await Promise.all([
|
||||||
this.fetchShifts(start, end),
|
this.fetchShifts(start, end),
|
||||||
this.fetchGancioEvents(start, end),
|
this.fetchGancioEvents(start, end),
|
||||||
|
this.fetchPolls(start, end),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
|
// 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
|
// Merge and group by date
|
||||||
const allItems = [...shiftItems, ...eventItems];
|
const allItems = [...shiftItems, ...eventItems, ...pollItems];
|
||||||
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
|
|
||||||
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
|
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[]> {
|
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
|
||||||
try {
|
try {
|
||||||
const events = await gancioClient.fetchPublicEvents();
|
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, '"');
|
||||||
|
}
|
||||||
@ -1618,30 +1618,178 @@ export const peopleService = {
|
|||||||
currentDepth++;
|
currentDepth++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include registered Users that don't have Contact records yet
|
// -----------------------------------------------------------------------
|
||||||
// (so they appear in the graph alongside managed contacts)
|
// Include all source types (not just Contacts) so the graph reflects
|
||||||
if (!center && (!source || source === 'USER') && nodesMap.size < MAX_NODES) {
|
// the same universe of people shown in the table/cards views.
|
||||||
|
// We dedup by normalized email/phone to avoid double-counting people
|
||||||
|
// who already appear via a Contact or User node above.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Build a set of emails/phones already represented in the graph
|
||||||
|
const representedEmails = new Set<string>();
|
||||||
|
const representedPhones = new Set<string>();
|
||||||
|
for (const n of nodesMap.values()) {
|
||||||
|
const ne = normalizeEmail(n.email);
|
||||||
|
if (ne) representedEmails.add(ne);
|
||||||
|
}
|
||||||
|
// Contacts have phones too — check rootContacts and connection neighbors
|
||||||
|
for (const c of rootContacts) {
|
||||||
|
const np = normalizePhone(c.phone);
|
||||||
|
if (np) representedPhones.add(np);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyRepresented(email: string | null, phone: string | null): boolean {
|
||||||
|
const ne = normalizeEmail(email);
|
||||||
|
if (ne && representedEmails.has(ne)) return true;
|
||||||
|
const np = normalizePhone(phone);
|
||||||
|
if (np && representedPhones.has(np)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRepresented(email: string | null, phone: string | null) {
|
||||||
|
const ne = normalizeEmail(email);
|
||||||
|
if (ne) representedEmails.add(ne);
|
||||||
|
const np = normalizePhone(phone);
|
||||||
|
if (np) representedPhones.add(np);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all existing nodes
|
||||||
|
for (const n of nodesMap.values()) {
|
||||||
|
markRepresented(n.email, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!center) {
|
||||||
|
// Users (not yet represented via a Contact node)
|
||||||
|
if ((!source || source === 'USER') && nodesMap.size < MAX_NODES) {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true, phone: true },
|
||||||
take: MAX_NODES - nodesMap.size,
|
take: MAX_NODES - nodesMap.size,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (contactUserIds.has(user.id)) continue; // Already represented as a Contact node
|
if (contactUserIds.has(user.id)) continue;
|
||||||
|
if (isAlreadyRepresented(user.email, user.phone)) continue;
|
||||||
if (nodesMap.size >= MAX_NODES) break;
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
|
||||||
const nodeId = `user:${user.id}`;
|
const nodeId = `user:${user.id}`;
|
||||||
nodesMap.set(nodeId, {
|
nodesMap.set(nodeId, {
|
||||||
id: nodeId,
|
id: nodeId, contactId: null,
|
||||||
contactId: null,
|
|
||||||
displayName: user.name || user.email,
|
displayName: user.name || user.email,
|
||||||
email: user.email,
|
email: user.email, source: 'USER',
|
||||||
source: 'USER',
|
supportLevel: null, tags: [], engagementScore: null,
|
||||||
supportLevel: null,
|
|
||||||
tags: [],
|
|
||||||
engagementScore: null,
|
|
||||||
});
|
});
|
||||||
|
markRepresented(user.email, user.phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address occupants
|
||||||
|
if ((!source || source === 'ADDRESS_OCCUPANT') && nodesMap.size < MAX_NODES) {
|
||||||
|
const addresses = await prisma.address.findMany({
|
||||||
|
select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true },
|
||||||
|
where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] },
|
||||||
|
take: MAX_NODES - nodesMap.size,
|
||||||
|
});
|
||||||
|
for (const a of addresses) {
|
||||||
|
if (isAlreadyRepresented(a.email, a.phone)) continue;
|
||||||
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
const nodeId = `addr:${a.id}`;
|
||||||
|
nodesMap.set(nodeId, {
|
||||||
|
id: nodeId, contactId: null,
|
||||||
|
displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`),
|
||||||
|
email: a.email, source: 'ADDRESS_OCCUPANT',
|
||||||
|
supportLevel: a.supportLevel, tags: [], engagementScore: null,
|
||||||
|
});
|
||||||
|
markRepresented(a.email, a.phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign email senders
|
||||||
|
if ((!source || source === 'CAMPAIGN_SENDER') && nodesMap.size < MAX_NODES) {
|
||||||
|
const campaignEmails = await prisma.campaignEmail.findMany({
|
||||||
|
select: { userEmail: true, userName: true },
|
||||||
|
where: { userEmail: { not: null } },
|
||||||
|
distinct: ['userEmail'],
|
||||||
|
take: MAX_NODES - nodesMap.size,
|
||||||
|
orderBy: { sentAt: 'desc' },
|
||||||
|
});
|
||||||
|
for (const ce of campaignEmails) {
|
||||||
|
if (!ce.userEmail) continue;
|
||||||
|
if (isAlreadyRepresented(ce.userEmail, null)) continue;
|
||||||
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
const nodeId = `cemail:${ce.userEmail}`;
|
||||||
|
nodesMap.set(nodeId, {
|
||||||
|
id: nodeId, contactId: null,
|
||||||
|
displayName: ce.userName || ce.userEmail,
|
||||||
|
email: ce.userEmail, source: 'CAMPAIGN_SENDER',
|
||||||
|
supportLevel: null, tags: [], engagementScore: null,
|
||||||
|
});
|
||||||
|
markRepresented(ce.userEmail, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift signups
|
||||||
|
if ((!source || source === 'SHIFT_SIGNUP') && nodesMap.size < MAX_NODES) {
|
||||||
|
const shiftSignups = await prisma.shiftSignup.findMany({
|
||||||
|
select: { userEmail: true, userName: true, userPhone: true },
|
||||||
|
distinct: ['userEmail'],
|
||||||
|
take: MAX_NODES - nodesMap.size,
|
||||||
|
orderBy: { signupDate: 'desc' },
|
||||||
|
});
|
||||||
|
for (const ss of shiftSignups) {
|
||||||
|
if (isAlreadyRepresented(ss.userEmail, ss.userPhone)) continue;
|
||||||
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
const nodeId = `signup:${ss.userEmail}`;
|
||||||
|
nodesMap.set(nodeId, {
|
||||||
|
id: nodeId, contactId: null,
|
||||||
|
displayName: ss.userName || ss.userEmail,
|
||||||
|
email: ss.userEmail, source: 'SHIFT_SIGNUP',
|
||||||
|
supportLevel: null, tags: [], engagementScore: null,
|
||||||
|
});
|
||||||
|
markRepresented(ss.userEmail, ss.userPhone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMS contacts
|
||||||
|
if ((!source || source === 'SMS_CONTACT') && nodesMap.size < MAX_NODES) {
|
||||||
|
const smsEntries = await prisma.smsContactListEntry.findMany({
|
||||||
|
select: { phone: true, name: true, email: true },
|
||||||
|
distinct: ['phone'],
|
||||||
|
take: MAX_NODES - nodesMap.size,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
for (const sc of smsEntries) {
|
||||||
|
if (isAlreadyRepresented(sc.email, sc.phone)) continue;
|
||||||
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
const nodeId = `sms:${sc.phone}`;
|
||||||
|
nodesMap.set(nodeId, {
|
||||||
|
id: nodeId, contactId: null,
|
||||||
|
displayName: sc.name || sc.phone,
|
||||||
|
email: sc.email, source: 'SMS_CONTACT',
|
||||||
|
supportLevel: null, tags: [], engagementScore: null,
|
||||||
|
});
|
||||||
|
markRepresented(sc.email, sc.phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Donations (Orders)
|
||||||
|
if ((!source || source === 'DONATION') && nodesMap.size < MAX_NODES) {
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
select: { buyerEmail: true, buyerName: true },
|
||||||
|
distinct: ['buyerEmail'],
|
||||||
|
take: MAX_NODES - nodesMap.size,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
for (const o of orders) {
|
||||||
|
if (isAlreadyRepresented(o.buyerEmail, null)) continue;
|
||||||
|
if (nodesMap.size >= MAX_NODES) break;
|
||||||
|
const nodeId = `order:${o.buyerEmail}`;
|
||||||
|
nodesMap.set(nodeId, {
|
||||||
|
id: nodeId, contactId: null,
|
||||||
|
displayName: o.buyerName || o.buyerEmail || 'Unknown',
|
||||||
|
email: o.buyerEmail, source: 'DONATION',
|
||||||
|
supportLevel: null, tags: [], engagementScore: null,
|
||||||
|
});
|
||||||
|
markRepresented(o.buyerEmail, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
api/src/modules/reports/error-report.routes.ts
Normal file
83
api/src/modules/reports/error-report.routes.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { errorReportRateLimit } from '../../middleware/rate-limit';
|
||||||
|
import { getAdminEmailsByRole } from '../../services/notification.helper';
|
||||||
|
import { emailService } from '../../services/email.service';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
const errorReportSchema = z.object({
|
||||||
|
url: z.string().min(1).max(2000),
|
||||||
|
message: z.string().max(500).optional(),
|
||||||
|
userAgent: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const errorReportRouter = Router();
|
||||||
|
|
||||||
|
errorReportRouter.post('/', errorReportRateLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const parsed = errorReportSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid input', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, message: userMessage, userAgent } = parsed.data;
|
||||||
|
|
||||||
|
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN]);
|
||||||
|
if (adminEmails.length === 0) {
|
||||||
|
logger.warn('No super admin emails found for 404 error report');
|
||||||
|
res.json({ success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<h2>404 Error Report</h2>
|
||||||
|
<table style="border-collapse:collapse; font-family:sans-serif;">
|
||||||
|
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">URL:</td><td>${escapeHtml(url)}</td></tr>
|
||||||
|
${userMessage ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Message:</td><td>${escapeHtml(userMessage)}</td></tr>` : ''}
|
||||||
|
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">IP:</td><td>${escapeHtml(ip)}</td></tr>
|
||||||
|
${userAgent ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">User Agent:</td><td>${escapeHtml(userAgent)}</td></tr>` : ''}
|
||||||
|
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Timestamp:</td><td>${timestamp}</td></tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
'404 Error Report',
|
||||||
|
`URL: ${url}`,
|
||||||
|
userMessage ? `Message: ${userMessage}` : '',
|
||||||
|
`IP: ${ip}`,
|
||||||
|
userAgent ? `User Agent: ${userAgent}` : '',
|
||||||
|
`Timestamp: ${timestamp}`,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
// Send to each admin (fire-and-forget per recipient)
|
||||||
|
for (const email of adminEmails) {
|
||||||
|
emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `[404 Report] Page not found: ${url.slice(0, 100)}`,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error('Failed to send 404 report email', { to: email, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error report submission failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
res.status(500).json({ error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@ -56,6 +56,7 @@ export const updateSiteSettingsSchema = z.object({
|
|||||||
enablePeople: z.boolean().optional(),
|
enablePeople: z.boolean().optional(),
|
||||||
enableSocial: z.boolean().optional(),
|
enableSocial: z.boolean().optional(),
|
||||||
enableMeet: z.boolean().optional(),
|
enableMeet: z.boolean().optional(),
|
||||||
|
enableMeetingPlanner: z.boolean().optional(),
|
||||||
autoSyncPeopleToMap: z.boolean().optional(),
|
autoSyncPeopleToMap: z.boolean().optional(),
|
||||||
|
|
||||||
// SMS connection config
|
// SMS connection config
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
|||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { smsContactsService } from './sms-contacts.service';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@ -30,6 +30,18 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/sms/contacts/all-entries — list entries across all lists
|
||||||
|
router.get('/all-entries', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, Number(req.query.page) || 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
|
||||||
|
const listId = req.query.listId as string | undefined;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const result = await smsContactsService.getAllEntries(page, limit, listId, search);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// --- Database Import Previews (must be BEFORE /:id routes) ---
|
// --- Database Import Previews (must be BEFORE /:id routes) ---
|
||||||
|
|
||||||
// GET /api/sms/contacts/preview-users — preview users with phone numbers
|
// GET /api/sms/contacts/preview-users — preview users with phone numbers
|
||||||
@ -132,6 +144,14 @@ router.post('/:id/entries', validate(createContactEntrySchema), async (req, res,
|
|||||||
} catch (err) { next(err); }
|
} 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
|
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
|
||||||
router.delete('/:id/entries/:entryId', async (req, res, next) => {
|
router.delete('/:id/entries/:entryId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -15,6 +15,15 @@ export const createContactEntrySchema = z.object({
|
|||||||
customFields: z.record(z.string()).optional(),
|
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 CreateContactListInput = z.infer<typeof createContactListSchema>;
|
||||||
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
|
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
|
||||||
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
|
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
|
||||||
|
export type BulkAddEntriesInput = z.infer<typeof bulkAddEntriesSchema>;
|
||||||
|
|||||||
@ -116,6 +116,32 @@ export const smsContactsService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAllEntries(page = 1, limit = 50, listId?: string, search?: string) {
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const where: Prisma.SmsContactListEntryWhereInput = {
|
||||||
|
list: { status: 'ACTIVE' },
|
||||||
|
};
|
||||||
|
if (listId) where.listId = listId;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ phone: { contains: search } },
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.smsContactListEntry.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: { list: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.smsContactListEntry.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, limit };
|
||||||
|
},
|
||||||
|
|
||||||
async getEntries(listId: string, page = 1, limit = 100) {
|
async getEntries(listId: string, page = 1, limit = 100) {
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@ -171,6 +197,32 @@ export const smsContactsService = {
|
|||||||
return entry;
|
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) {
|
async deleteEntry(id: string) {
|
||||||
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
|
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
|
||||||
// Update total count
|
// Update total count
|
||||||
|
|||||||
@ -24,6 +24,60 @@ router.get('/', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/sms/conversations/contact-search — search contacts for new conversation
|
||||||
|
router.get('/contact-search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const q = (req.query.q as string || '').trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
res.json({ results: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await smsConversationsService.searchContacts(q);
|
||||||
|
res.json({ results });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sms/conversations — start new conversation
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { phone, message, contactName, contactId } = req.body as {
|
||||||
|
phone?: string;
|
||||||
|
message?: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactId?: string;
|
||||||
|
};
|
||||||
|
if (!phone || typeof phone !== 'string' || phone.replace(/\D/g, '').length < 7) {
|
||||||
|
res.status(400).json({ error: 'Valid phone number is required (min 7 digits)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||||
|
res.status(400).json({ error: 'Message is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.length > 1600) {
|
||||||
|
res.status(400).json({ error: 'Message cannot exceed 1600 characters' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conversation = await smsConversationsService.startConversation({
|
||||||
|
phone,
|
||||||
|
message: message.trim(),
|
||||||
|
contactName,
|
||||||
|
contactId,
|
||||||
|
});
|
||||||
|
res.status(201).json(conversation);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 409) {
|
||||||
|
res.status(409).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err.statusCode === 400) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/sms/conversations/stats — conversation stats
|
// GET /api/sms/conversations/stats — conversation stats
|
||||||
router.get('/stats', async (_req, res, next) => {
|
router.get('/stats', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,6 +2,24 @@ import { prisma } from '../../../config/database';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
import { smsQueueService } from '../../../services/sms-queue.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a phone number: strip non-digit characters, validate 10-11 digits.
|
||||||
|
*/
|
||||||
|
function normalizePhone(raw: string): string | null {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
if (digits.length === 10) return digits;
|
||||||
|
if (digits.length === 11 && digits.startsWith('1')) return digits;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSearchResult {
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
source: 'sms_contact' | 'crm_contact' | 'conversation';
|
||||||
|
sourceId: string;
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const smsConversationsService = {
|
export const smsConversationsService = {
|
||||||
async findAll(options: {
|
async findAll(options: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@ -171,4 +189,181 @@ export const smsConversationsService = {
|
|||||||
|
|
||||||
return { updated: result.count };
|
return { updated: result.count };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search contacts across SMS lists, CRM contacts, and existing conversations.
|
||||||
|
* Deduplicates by phone number, prioritizing SMS contacts > CRM > conversations.
|
||||||
|
*/
|
||||||
|
async searchContacts(query: string, limit = 20): Promise<ContactSearchResult[]> {
|
||||||
|
const seen = new Map<string, ContactSearchResult>();
|
||||||
|
|
||||||
|
const [smsEntries, crmContacts, existingConvs] = await Promise.all([
|
||||||
|
// 1. SMS Contact List Entries
|
||||||
|
prisma.smsContactListEntry.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ phone: { contains: query } },
|
||||||
|
{ name: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, phone: true, name: true },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 2. CRM Contacts (with phones) — exclude opted out
|
||||||
|
prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
doNotContact: false,
|
||||||
|
smsOptOut: false,
|
||||||
|
OR: [
|
||||||
|
{ displayName: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ phone: { contains: query } },
|
||||||
|
{ phones: { some: { phone: { contains: query } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
phone: true,
|
||||||
|
phones: { select: { phone: true }, take: 5 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 3. Existing conversations
|
||||||
|
prisma.smsConversation.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ phone: { contains: query } },
|
||||||
|
{ contactName: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, phone: true, contactName: true, contactId: true, status: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add SMS contacts first (highest priority)
|
||||||
|
for (const entry of smsEntries) {
|
||||||
|
if (!seen.has(entry.phone)) {
|
||||||
|
seen.set(entry.phone, {
|
||||||
|
phone: entry.phone,
|
||||||
|
name: entry.name,
|
||||||
|
source: 'sms_contact',
|
||||||
|
sourceId: entry.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CRM contacts
|
||||||
|
for (const contact of crmContacts) {
|
||||||
|
const phones: string[] = [];
|
||||||
|
if (contact.phone) phones.push(contact.phone);
|
||||||
|
for (const cp of contact.phones) {
|
||||||
|
if (!phones.includes(cp.phone)) phones.push(cp.phone);
|
||||||
|
}
|
||||||
|
for (const phone of phones) {
|
||||||
|
if (!seen.has(phone)) {
|
||||||
|
seen.set(phone, {
|
||||||
|
phone,
|
||||||
|
name: contact.displayName,
|
||||||
|
source: 'crm_contact',
|
||||||
|
sourceId: contact.id,
|
||||||
|
contactId: contact.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add existing conversations (lowest priority)
|
||||||
|
for (const conv of existingConvs) {
|
||||||
|
if (!seen.has(conv.phone)) {
|
||||||
|
seen.set(conv.phone, {
|
||||||
|
phone: conv.phone,
|
||||||
|
name: conv.contactName,
|
||||||
|
source: 'conversation',
|
||||||
|
sourceId: conv.id,
|
||||||
|
contactId: conv.contactId || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(seen.values()).slice(0, limit);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new ad-hoc conversation or reuse an existing one, then send the first message.
|
||||||
|
*/
|
||||||
|
async startConversation(input: {
|
||||||
|
phone: string;
|
||||||
|
message: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactId?: string;
|
||||||
|
}) {
|
||||||
|
const normalized = normalizePhone(input.phone);
|
||||||
|
if (!normalized) throw Object.assign(new Error('Invalid phone number'), { statusCode: 400 });
|
||||||
|
|
||||||
|
// Use a transaction to prevent race conditions on conversation lookup/create
|
||||||
|
const conversation = await prisma.$transaction(async (tx) => {
|
||||||
|
// Look for existing ad-hoc conversation (campaignId = null)
|
||||||
|
const existing = await tx.smsConversation.findFirst({
|
||||||
|
where: { phone: normalized, campaignId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'OPTED_OUT') {
|
||||||
|
throw Object.assign(new Error('Cannot message opted-out contact'), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen if closed, update stats
|
||||||
|
return tx.smsConversation.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
totalMessages: { increment: 1 },
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
// Update contact info if provided and not already set
|
||||||
|
contactName: existing.contactName || input.contactName || undefined,
|
||||||
|
contactId: existing.contactId || input.contactId || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new conversation
|
||||||
|
return tx.smsConversation.create({
|
||||||
|
data: {
|
||||||
|
phone: normalized,
|
||||||
|
contactName: input.contactName || null,
|
||||||
|
contactId: input.contactId || null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
totalMessages: 1,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create outbound message
|
||||||
|
const smsMessage = await prisma.smsMessage.create({
|
||||||
|
data: {
|
||||||
|
phone: normalized,
|
||||||
|
message: input.message,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
status: 'PENDING',
|
||||||
|
connectionType: 'termux',
|
||||||
|
conversationId: conversation.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue the SMS send
|
||||||
|
await smsQueueService.addSmsJob({
|
||||||
|
recipientId: smsMessage.id,
|
||||||
|
campaignId: '',
|
||||||
|
phone: normalized,
|
||||||
|
message: input.message,
|
||||||
|
attemptNumber: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return full conversation with messages
|
||||||
|
return this.findById(conversation.id);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
67
api/src/modules/sms/templates/sms-templates.routes.ts
Normal file
67
api/src/modules/sms/templates/sms-templates.routes.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { validate } from '../../../middleware/validate';
|
||||||
|
import { smsTemplatesService } from './sms-templates.service';
|
||||||
|
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
||||||
|
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||||
|
|
||||||
|
// GET /api/sms/templates — list with search/filter/pagination
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, Number(req.query.page) || 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
const isFavorite = req.query.isFavorite as string | undefined;
|
||||||
|
const result = await smsTemplatesService.findAll({ page, limit, search, category, isFavorite });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/sms/templates/:id — single template with computed fields
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const template = await smsTemplatesService.findById(req.params.id as string);
|
||||||
|
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
|
||||||
|
res.json(template);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sms/templates — create template
|
||||||
|
router.post('/', validate(createSmsTemplateSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const template = await smsTemplatesService.create(req.body, req.user!.id);
|
||||||
|
res.status(201).json(template);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/sms/templates/:id — update template
|
||||||
|
router.put('/:id', validate(updateSmsTemplateSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const template = await smsTemplatesService.update(req.params.id as string, req.body);
|
||||||
|
res.json(template);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/sms/templates/:id — delete (system-protected)
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await smsTemplatesService.delete(req.params.id as string);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sms/templates/:id/favorite — toggle favorite
|
||||||
|
router.post('/:id/favorite', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const template = await smsTemplatesService.toggleFavorite(req.params.id as string);
|
||||||
|
res.json(template);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export const smsTemplatesRouter = router;
|
||||||
28
api/src/modules/sms/templates/sms-templates.schemas.ts
Normal file
28
api/src/modules/sms/templates/sms-templates.schemas.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const listSmsTemplatesSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
isFavorite: z.enum(['true', 'false']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSmsTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
template: z.string().min(1).max(1600),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
category: z.string().max(50).nullable().optional(),
|
||||||
|
isFavorite: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSmsTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
template: z.string().min(1).max(1600).optional(),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
category: z.string().max(50).nullable().optional(),
|
||||||
|
isFavorite: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateSmsTemplateInput = z.infer<typeof createSmsTemplateSchema>;
|
||||||
|
export type UpdateSmsTemplateInput = z.infer<typeof updateSmsTemplateSchema>;
|
||||||
152
api/src/modules/sms/templates/sms-templates.service.ts
Normal file
152
api/src/modules/sms/templates/sms-templates.service.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { prisma } from '../../../config/database';
|
||||||
|
import type { CreateSmsTemplateInput, UpdateSmsTemplateInput } from './sms-templates.schemas';
|
||||||
|
|
||||||
|
/** Names of templates seeded by the system — cannot be deleted */
|
||||||
|
const SYSTEM_TEMPLATE_NAMES = ['shift-reminder', 'shift-signup-confirm', 'volunteer-welcome'];
|
||||||
|
|
||||||
|
/** Extract {var} placeholder names from a template string */
|
||||||
|
function extractVariables(template: string): string[] {
|
||||||
|
const vars: string[] = [];
|
||||||
|
const regex = /\{(\w+)\}/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(template)) !== null) {
|
||||||
|
if (!vars.includes(match[1])) vars.push(match[1]);
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const smsTemplatesService = {
|
||||||
|
async findAll(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
isFavorite?: string;
|
||||||
|
}) {
|
||||||
|
const page = params.page || 1;
|
||||||
|
const limit = params.limit || 50;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (params.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: params.search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: params.search, mode: 'insensitive' } },
|
||||||
|
{ template: { contains: params.search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.category) {
|
||||||
|
where.category = params.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.isFavorite === 'true') {
|
||||||
|
where.isFavorite = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.smsMessageTemplate.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ isFavorite: 'desc' }, { name: 'asc' }],
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
createdByUser: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.smsMessageTemplate.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enrich with computed fields
|
||||||
|
const enriched = items.map((t) => ({
|
||||||
|
...t,
|
||||||
|
variables: extractVariables(t.template),
|
||||||
|
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items: enriched, total, page, limit };
|
||||||
|
},
|
||||||
|
|
||||||
|
async findById(id: string) {
|
||||||
|
const t = await prisma.smsMessageTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
createdByUser: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!t) return null;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
variables: extractVariables(t.template),
|
||||||
|
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreateSmsTemplateInput, userId: string) {
|
||||||
|
// Check for duplicate name (SmsNotificationService looks up by name)
|
||||||
|
const existing = await prisma.smsMessageTemplate.findFirst({
|
||||||
|
where: { name: data.name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) throw new Error('A template with this name already exists');
|
||||||
|
|
||||||
|
return prisma.smsMessageTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
template: data.template,
|
||||||
|
description: data.description ?? null,
|
||||||
|
category: data.category ?? null,
|
||||||
|
isFavorite: data.isFavorite ?? false,
|
||||||
|
createdByUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: UpdateSmsTemplateInput) {
|
||||||
|
const existing = await prisma.smsMessageTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new Error('Template not found');
|
||||||
|
|
||||||
|
// If renaming, check for duplicate
|
||||||
|
if (data.name && data.name !== existing.name) {
|
||||||
|
const dup = await prisma.smsMessageTemplate.findFirst({
|
||||||
|
where: { name: data.name, NOT: { id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (dup) throw new Error('A template with this name already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.smsMessageTemplate.update({ where: { id }, data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const t = await prisma.smsMessageTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { name: true, createdByUserId: true },
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Template not found');
|
||||||
|
if (SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null) {
|
||||||
|
throw new Error('System templates cannot be deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.smsMessageTemplate.delete({ where: { id } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleFavorite(id: string) {
|
||||||
|
const t = await prisma.smsMessageTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { isFavorite: true },
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Template not found');
|
||||||
|
|
||||||
|
return prisma.smsMessageTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isFavorite: !t.isFavorite },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
extractVariables,
|
||||||
|
};
|
||||||
@ -31,6 +31,7 @@ import { mapSettingsRouter } from './modules/map/settings/settings.routes';
|
|||||||
import { qrRouter } from './modules/qr/qr.routes';
|
import { qrRouter } from './modules/qr/qr.routes';
|
||||||
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
||||||
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.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 { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
||||||
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
||||||
import { blocksRouter } from './modules/pages/blocks.routes';
|
import { blocksRouter } from './modules/pages/blocks.routes';
|
||||||
@ -83,6 +84,7 @@ import { smsConversationsRouter } from './modules/sms/conversations/sms-conversa
|
|||||||
import { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes';
|
import { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes';
|
||||||
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
|
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
|
||||||
import { smsSetupRouter } from './modules/sms/setup/sms-setup.routes';
|
import { smsSetupRouter } from './modules/sms/setup/sms-setup.routes';
|
||||||
|
import { smsTemplatesRouter } from './modules/sms/templates/sms-templates.routes';
|
||||||
import { smsQueueService } from './services/sms-queue.service';
|
import { smsQueueService } from './services/sms-queue.service';
|
||||||
import { smsResponseSyncService } from './services/sms-response-sync.service';
|
import { smsResponseSyncService } from './services/sms-response-sync.service';
|
||||||
import { smsDeviceMonitorService } from './services/sms-device-monitor.service';
|
import { smsDeviceMonitorService } from './services/sms-device-monitor.service';
|
||||||
@ -99,6 +101,7 @@ import { eventsListPublicRouter } from './modules/events/events-public.routes';
|
|||||||
import { homepageRouter } from './modules/homepage/homepage.routes';
|
import { homepageRouter } from './modules/homepage/homepage.routes';
|
||||||
import { ogRouter } from './modules/og/og.routes';
|
import { ogRouter } from './modules/og/og.routes';
|
||||||
import { socialRouter } from './modules/social/social.routes';
|
import { socialRouter } from './modules/social/social.routes';
|
||||||
|
import { errorReportRouter } from './modules/reports/error-report.routes';
|
||||||
import { sseService } from './modules/social/sse.service';
|
import { sseService } from './modules/social/sse.service';
|
||||||
import { presenceService } from './modules/social/presence.service';
|
import { presenceService } from './modules/social/presence.service';
|
||||||
|
|
||||||
@ -209,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/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
|
||||||
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
|
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/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/qr', qrRouter); // QR code generation (public)
|
||||||
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
||||||
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
||||||
@ -246,6 +251,7 @@ app.use('/api/sms/campaigns', smsCampaignsRouter); // SMS campaign C
|
|||||||
app.use('/api/sms/conversations', smsConversationsRouter); // SMS conversation threads (ADMIN roles)
|
app.use('/api/sms/conversations', smsConversationsRouter); // SMS conversation threads (ADMIN roles)
|
||||||
app.use('/api/sms/messages', smsMessagesRouter); // SMS message history + ad-hoc send (ADMIN roles)
|
app.use('/api/sms/messages', smsMessagesRouter); // SMS message history + ad-hoc send (ADMIN roles)
|
||||||
app.use('/api/sms/device', smsDeviceRouter); // SMS device status + sync trigger (ADMIN roles)
|
app.use('/api/sms/device', smsDeviceRouter); // SMS device status + sync trigger (ADMIN roles)
|
||||||
|
app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles)
|
||||||
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
|
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
|
||||||
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
|
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
|
||||||
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
|
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
|
||||||
@ -256,6 +262,12 @@ app.use('/api/events', eventsListPublicRouter); // Public event
|
|||||||
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
|
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
|
||||||
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
|
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
|
||||||
app.use('/api/social', socialRouter); // Social connections (auth required)
|
app.use('/api/social', socialRouter); // Social connections (auth required)
|
||||||
|
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
|
||||||
|
|
||||||
|
// --- API 404 Handler (catch unmatched /api/* routes) ---
|
||||||
|
app.use('/api/*', (_req, res) => {
|
||||||
|
res.status(404).json({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
||||||
|
});
|
||||||
|
|
||||||
// --- Error Handler (must be last) ---
|
// --- Error Handler (must be last) ---
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
Subproject commit 2457662e12b5fd4c2e62a22503f3ffd93dc5e303
|
Subproject commit d9be9c961d4ffcf32abac81fd32589abfb146fd3
|
||||||
@ -91,6 +91,14 @@ LISTMONK_API_TOKEN={{secrets.listmonkApiToken}}
|
|||||||
LISTMONK_ADMIN_USER=v2-api
|
LISTMONK_ADMIN_USER=v2-api
|
||||||
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
|
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
|
||||||
LISTMONK_PROXY_PORT=9002
|
LISTMONK_PROXY_PORT=9002
|
||||||
|
LISTMONK_WEBHOOK_SECRET=
|
||||||
|
LISTMONK_DB_PORT=5434
|
||||||
|
LISTMONK_SMTP_HOST={{containerPrefix}}-mailhog
|
||||||
|
LISTMONK_SMTP_PORT=1025
|
||||||
|
LISTMONK_SMTP_USER=
|
||||||
|
LISTMONK_SMTP_PASSWORD=
|
||||||
|
LISTMONK_SMTP_TLS_TYPE=none
|
||||||
|
LISTMONK_SMTP_FROM={{name}} <noreply@{{domain}}>
|
||||||
|
|
||||||
# Media
|
# Media
|
||||||
{{#if enableMedia}}
|
{{#if enableMedia}}
|
||||||
@ -102,6 +110,13 @@ MEDIA_API_PORT=4100
|
|||||||
MEDIA_ROOT=/media/local
|
MEDIA_ROOT=/media/local
|
||||||
MEDIA_UPLOADS=/media/uploads
|
MEDIA_UPLOADS=/media/uploads
|
||||||
MAX_UPLOAD_SIZE_GB=10
|
MAX_UPLOAD_SIZE_GB=10
|
||||||
|
PUBLIC_MEDIA_PORT=3100
|
||||||
|
VIDEO_PLAYER_DEBUG=false
|
||||||
|
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||||
|
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
|
||||||
|
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
|
||||||
|
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
|
||||||
|
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||||
|
|
||||||
# NAR Data
|
# NAR Data
|
||||||
NAR_DATA_DIR=/data
|
NAR_DATA_DIR=/data
|
||||||
@ -109,21 +124,35 @@ NAR_DATA_DIR=/data
|
|||||||
# Platform Service URLs (used for health checks)
|
# Platform Service URLs (used for health checks)
|
||||||
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
|
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
|
||||||
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
|
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
|
||||||
|
EXCALIDRAW_WS_URL=wss://draw.{{domain}}
|
||||||
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
|
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
|
||||||
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
|
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
|
||||||
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
|
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
|
||||||
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
|
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
|
||||||
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
||||||
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
||||||
|
VAULTWARDEN_SMTP_SECURITY=off
|
||||||
|
|
||||||
# Geocoding
|
# Geocoding
|
||||||
MAPBOX_API_KEY=
|
MAPBOX_API_KEY=
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
GOOGLE_MAPS_ENABLED=false
|
GOOGLE_MAPS_ENABLED=false
|
||||||
|
GEOCODING_RATE_LIMIT_MS=1100
|
||||||
|
GEOCODING_CACHE_ENABLED=true
|
||||||
|
GEOCODING_CACHE_TTL_HOURS=24
|
||||||
|
GEOCODING_PARALLEL_ENABLED=true
|
||||||
|
GEOCODING_BATCH_SIZE=10
|
||||||
|
BULK_GEOCODE_ENABLED=true
|
||||||
|
BULK_GEOCODE_MAX_BATCH=5000
|
||||||
|
|
||||||
# Represent API
|
# Represent API
|
||||||
REPRESENT_API_URL=https://represent.opennorth.ca
|
REPRESENT_API_URL=https://represent.opennorth.ca
|
||||||
|
|
||||||
|
# Overpass / Area Import
|
||||||
|
OVERPASS_API_URL=https://overpass-api.de/api/interpreter
|
||||||
|
OVERPASS_MIN_DELAY_MS=30000
|
||||||
|
AREA_IMPORT_MAX_GRID_POINTS=500
|
||||||
|
|
||||||
# Pangolin Tunnel
|
# Pangolin Tunnel
|
||||||
PANGOLIN_API_URL=
|
PANGOLIN_API_URL=
|
||||||
PANGOLIN_API_KEY=
|
PANGOLIN_API_KEY=
|
||||||
@ -205,18 +234,42 @@ GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}}
|
|||||||
GRAFANA_ROOT_URL=https://grafana.{{domain}}
|
GRAFANA_ROOT_URL=https://grafana.{{domain}}
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
GRAFANA_PORT=3000
|
GRAFANA_PORT=3000
|
||||||
|
CADVISOR_PORT=8086
|
||||||
|
NODE_EXPORTER_PORT=9100
|
||||||
|
REDIS_EXPORTER_PORT=9121
|
||||||
|
ALERTMANAGER_PORT=9093
|
||||||
|
ALERTMANAGER_EMBED_PORT={{math ports.embed "+" 16}}
|
||||||
|
GOTIFY_PORT=8889
|
||||||
|
GOTIFY_ADMIN_USER=admin
|
||||||
|
GOTIFY_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# MkDocs
|
# MkDocs
|
||||||
MKDOCS_PORT={{math ports.embed "+" 8}}
|
MKDOCS_PORT={{math ports.embed "+" 8}}
|
||||||
|
MKDOCS_SITE_SERVER_PORT={{math ports.embed "+" 14}}
|
||||||
|
MKDOCS_PREVIEW_URL=http://{{containerPrefix}}-mkdocs:8000
|
||||||
|
MKDOCS_DOCS_PATH=/mkdocs/docs
|
||||||
CODE_SERVER_PORT={{math ports.embed "+" 7}}
|
CODE_SERVER_PORT={{math ports.embed "+" 7}}
|
||||||
|
CODE_SERVER_URL=http://{{containerPrefix}}-code-server:8080
|
||||||
|
USER_NAME=coder
|
||||||
BASE_DOMAIN=https://{{domain}}
|
BASE_DOMAIN=https://{{domain}}
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
GITEA_URL=http://{{containerPrefix}}-gitea:3000
|
GITEA_URL=http://{{containerPrefix}}-gitea:3000
|
||||||
|
GITEA_SSH_PORT=2222
|
||||||
|
GITEA_DB_TYPE=mysql
|
||||||
|
GITEA_DB_HOST={{containerPrefix}}-gitea-db:3306
|
||||||
|
GITEA_DB_NAME=gitea
|
||||||
|
GITEA_DB_USER=gitea
|
||||||
GITEA_DB_PASSWD={{secrets.giteaAdminPassword}}
|
GITEA_DB_PASSWD={{secrets.giteaAdminPassword}}
|
||||||
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
|
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
|
||||||
GITEA_ROOT_URL=https://git.{{domain}}
|
GITEA_ROOT_URL=https://git.{{domain}}
|
||||||
GITEA_DOMAIN=git.{{domain}}
|
GITEA_DOMAIN=git.{{domain}}
|
||||||
|
GITEA_COMMENTS_ENABLED=false
|
||||||
|
GITEA_API_TOKEN=
|
||||||
|
GITEA_COMMENTS_REPO_OWNER=
|
||||||
|
GITEA_COMMENTS_REPO_NAME=docs-comments
|
||||||
|
GITEA_OAUTH_CLIENT_ID=
|
||||||
|
GITEA_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# n8n
|
# n8n
|
||||||
N8N_HOST=n8n.{{domain}}
|
N8N_HOST=n8n.{{domain}}
|
||||||
@ -224,12 +277,17 @@ N8N_URL=http://{{containerPrefix}}-n8n:5678
|
|||||||
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
|
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
|
||||||
N8N_USER_EMAIL={{secrets.adminEmail}}
|
N8N_USER_EMAIL={{secrets.adminEmail}}
|
||||||
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
|
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
|
||||||
|
GENERIC_TIMEZONE=UTC
|
||||||
|
|
||||||
# MailHog
|
# MailHog
|
||||||
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
|
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
|
||||||
MAILHOG_SMTP_PORT=1025
|
MAILHOG_SMTP_PORT=1025
|
||||||
MAILHOG_WEB_PORT=8025
|
MAILHOG_WEB_PORT=8025
|
||||||
|
|
||||||
|
# Homepage
|
||||||
|
HOMEPAGE_PORT=3010
|
||||||
|
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||||
|
|
||||||
# Dev Tools
|
# Dev Tools
|
||||||
{{#if enableDevTools}}
|
{{#if enableDevTools}}
|
||||||
ENABLE_DEV_TOOLS=true
|
ENABLE_DEV_TOOLS=true
|
||||||
@ -251,6 +309,11 @@ VITE_MKDOCS_URL=http://{{containerPrefix}}-mkdocs:8000
|
|||||||
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
|
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
# Bunker Ops (Fleet Management)
|
||||||
|
INSTANCE_LABEL={{slug}}
|
||||||
|
BUNKER_OPS_ENABLED=false
|
||||||
|
BUNKER_OPS_REMOTE_WRITE_URL=
|
||||||
|
|
||||||
# Embed proxy ports (nginx proxy for iframe embedding in admin GUI)
|
# Embed proxy ports (nginx proxy for iframe embedding in admin GUI)
|
||||||
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
|
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
|
||||||
N8N_EMBED_PORT={{math ports.embed "+" 1}}
|
N8N_EMBED_PORT={{math ports.embed "+" 1}}
|
||||||
|
|||||||
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`
|
- Saves the API key to `~/.bashrc`
|
||||||
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
|
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
|
||||||
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
|
- 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`).
|
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
|
### Step 4: Prevent Android from Killing Termux
|
||||||
|
|
||||||
This is **required** for the server to run reliably in the background:
|
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
|
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Control
|
### Service Management
|
||||||
|
|
||||||
|
If you installed `termux-services` (recommended):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if the server is running
|
# Check status
|
||||||
curl http://127.0.0.1:5001/health
|
sv status sms-api
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
sv restart sms-api
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
sv down sms-api
|
||||||
|
|
||||||
|
# Start
|
||||||
|
sv up sms-api
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
tail -f ~/logs/sms-api.log
|
tail -f ~/logs/sms-api.log
|
||||||
|
|
||||||
# Stop the server
|
# Health check
|
||||||
pkill -f sms-watchdog.sh && pkill -f termux-sms-api-server.py
|
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
|
# Restart manually
|
||||||
cd ~/sms-server/android && bash sms-watchdog.sh
|
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
|
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
|
||||||
|
|
||||||
# Restart the server
|
# Restart the server
|
||||||
pkill -f termux-sms-api-server.py
|
sv restart sms-api
|
||||||
cd ~/sms-server/android && python termux-sms-api-server.py
|
# Or without termux-services: pkill -f termux-sms-api-server.py && cd ~/sms-server/android && python termux-sms-api-server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### SMS not sending
|
### 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.
|
**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**
|
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. Lock Termux in recent apps (long-press app card → Lock)
|
2. **Disable battery optimization:** Android Settings → Apps → Termux → Battery → **Unrestricted**
|
||||||
3. Some phones: Settings → Battery → Battery Optimization → find Termux → Don't Optimize
|
3. **Lock Termux in recent apps** — long-press the app card → Lock/Pin
|
||||||
4. Samsung: Settings → Device Care → Battery → App Power Management → add Termux to "Never sleeping apps"
|
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"
|
### Server won't start — "Missing SMS_API_SECRET"
|
||||||
|
|
||||||
@ -425,6 +457,6 @@ cd ~/sms-server
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
# Restart the server
|
# Restart the server
|
||||||
pkill -f termux-sms-api-server.py
|
sv restart sms-api
|
||||||
cd android && python termux-sms-api-server.py
|
# 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_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
|
||||||
| `LISTMONK_ADMIN_PASSWORD` | — | Same as `LISTMONK_API_TOKEN`. |
|
| `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_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. |
|
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
|
||||||
|
|
||||||
??? example "Listmonk SMTP settings"
|
??? example "Listmonk SMTP settings"
|
||||||
@ -253,6 +254,7 @@ Self-hosted Git repository. Optional service.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| 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_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
|
||||||
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
|
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
|
||||||
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
|
| `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_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
|
||||||
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
|
| `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:
|
## 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)
|
## MailHog (Development Email)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@ -474,6 +530,20 @@ docker compose --profile monitoring up -d
|
|||||||
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
|
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
|
||||||
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
|
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
|
||||||
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
|
| `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
|
# Gancio
|
||||||
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
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
|
!!! tip
|
||||||
@ -551,6 +626,8 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
|||||||
ENABLE_MEDIA_FEATURES=true
|
ENABLE_MEDIA_FEATURES=true
|
||||||
ENABLE_PAYMENTS=true
|
ENABLE_PAYMENTS=true
|
||||||
ENABLE_CHAT=true
|
ENABLE_CHAT=true
|
||||||
|
ENABLE_MEET=true
|
||||||
|
ENABLE_SMS=true
|
||||||
LISTMONK_SYNC_ENABLED=true
|
LISTMONK_SYNC_ENABLED=true
|
||||||
GANCIO_SYNC_ENABLED=true
|
GANCIO_SYNC_ENABLED=true
|
||||||
LISTMONK_DB_PASSWORD=...
|
LISTMONK_DB_PASSWORD=...
|
||||||
@ -564,6 +641,10 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
|||||||
VAULTWARDEN_ADMIN_TOKEN=...
|
VAULTWARDEN_ADMIN_TOKEN=...
|
||||||
ROCKETCHAT_ADMIN_PASSWORD=...
|
ROCKETCHAT_ADMIN_PASSWORD=...
|
||||||
GANCIO_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
|
EMAIL_TEST_MODE=false
|
||||||
SMTP_HOST=smtp.your-provider.com
|
SMTP_HOST=smtp.your-provider.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@ -1683,6 +1683,136 @@
|
|||||||
.btn-primary:focus-visible, .btn-secondary:focus-visible {
|
.btn-primary:focus-visible, .btn-secondary:focus-visible {
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FREE ASTERISK MODAL
|
||||||
|
============================================ */
|
||||||
|
.free-asterisk {
|
||||||
|
color: var(--primary-light);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
.free-asterisk:hover {
|
||||||
|
color: #C084FC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.25s ease, visibility 0.25s ease;
|
||||||
|
}
|
||||||
|
.free-modal-backdrop.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-width: 520px;
|
||||||
|
width: 90%;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
transform: translateY(20px) scale(0.97);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.free-modal-backdrop.active .free-modal {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
.free-modal-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal .free-modal-intro {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-list .dep-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--myc-node-bg);
|
||||||
|
border: 1px solid var(--myc-node-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-list strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-modal-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.free-modal {
|
||||||
|
max-width: 95%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
@ -1795,7 +1925,7 @@
|
|||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure.
|
Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure.
|
||||||
No corporate surveillance. No foreign interference. No monthly ransoms.
|
No corporate surveillance. No foreign interference. No monthly ransoms.
|
||||||
A free and open source toolkit built for growing political movements.
|
A <a href="#" class="free-asterisk" id="free-asterisk-link">free*</a> and open source toolkit built for growing political movements.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a href="mailto:cmlite@bnkops.ca?subject=Request%20to%20Chat%20-%20CMLITE&body=Hi%20CMlite%20Team%2C%20I%20would%20like%20to%20chat!%20Please%20send%20me%20a%20email%20back.%20Cheers%2C%20" class="btn-primary">Schedule a Chat <span aria-hidden="true">→</span></a>
|
<a href="mailto:cmlite@bnkops.ca?subject=Request%20to%20Chat%20-%20CMLITE&body=Hi%20CMlite%20Team%2C%20I%20would%20like%20to%20chat!%20Please%20send%20me%20a%20email%20back.%20Cheers%2C%20" class="btn-primary">Schedule a Chat <span aria-hidden="true">→</span></a>
|
||||||
@ -2649,6 +2779,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
FREE* MODAL
|
||||||
|
============================================ -->
|
||||||
|
<div class="free-modal-backdrop" id="free-modal-backdrop">
|
||||||
|
<div class="free-modal" role="dialog" aria-labelledby="free-modal-title" aria-modal="true">
|
||||||
|
<button class="free-modal-close" id="free-modal-close" aria-label="Close">×</button>
|
||||||
|
<h3 id="free-modal-title">What does free* mean?</h3>
|
||||||
|
<p class="free-modal-intro">
|
||||||
|
Changemaker Lite is 100% free and open source software — no license fees, no subscriptions, no vendor lock-in.
|
||||||
|
Running it in production does require a few external dependencies:
|
||||||
|
</p>
|
||||||
|
<ul class="free-modal-list">
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">🖥</span>
|
||||||
|
<span><strong>A Linux server or hardware</strong> — something to run the stack on (old laptop, mini PC, VPS)</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">🌐</span>
|
||||||
|
<span><strong>An internet connection</strong> — to serve traffic to the public</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">🏷</span>
|
||||||
|
<span><strong>A domain name</strong> — ~$10–15/yr for a custom domain</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">🔗</span>
|
||||||
|
<span><strong>A production URL / tunnel</strong> — for public-facing deployment (can be deployed privately without one)</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">✉</span>
|
||||||
|
<span><strong>An SMTP email provider</strong> — free tiers exist, but the most capable and secure are paid</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dep-icon">📱</span>
|
||||||
|
<span><strong>An Android phone</strong> — required for SMS campaigns (uses Termux as a bridge to send texts)</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="free-modal-footer">
|
||||||
|
None of these are unique to Changemaker Lite — any self-hosted platform needs them. The software itself will always be free.
|
||||||
|
Self-host at no cost, or pay for a pre-configured hardware device or a managed deployment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============================================
|
<!-- ============================================
|
||||||
SCRIPTS
|
SCRIPTS
|
||||||
============================================ -->
|
============================================ -->
|
||||||
@ -3358,6 +3532,26 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
FREE* MODAL
|
||||||
|
=========================================== */
|
||||||
|
const FreeModal = {
|
||||||
|
init() {
|
||||||
|
const link = document.getElementById('free-asterisk-link');
|
||||||
|
const backdrop = document.getElementById('free-modal-backdrop');
|
||||||
|
const closeBtn = document.getElementById('free-modal-close');
|
||||||
|
if (!link || !backdrop || !closeBtn) return;
|
||||||
|
|
||||||
|
const open = () => backdrop.classList.add('active');
|
||||||
|
const close = () => backdrop.classList.remove('active');
|
||||||
|
|
||||||
|
link.addEventListener('click', (e) => { e.preventDefault(); open(); });
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && backdrop.classList.contains('active')) close(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
BOOT
|
BOOT
|
||||||
=========================================== */
|
=========================================== */
|
||||||
@ -3370,6 +3564,7 @@
|
|||||||
RootNetwork.init();
|
RootNetwork.init();
|
||||||
FloatingElements.init();
|
FloatingElements.init();
|
||||||
SmoothScroll.init();
|
SmoothScroll.init();
|
||||||
|
FreeModal.init();
|
||||||
initSearch();
|
initSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ extra_javascript:
|
|||||||
- assets/js/image-gallery.js
|
- assets/js/image-gallery.js
|
||||||
- assets/js/gancio-events.js
|
- assets/js/gancio-events.js
|
||||||
- assets/js/payment-widgets.js
|
- assets/js/payment-widgets.js
|
||||||
|
- assets/js/scheduling-poll.js
|
||||||
- javascripts/ad-widgets.js
|
- javascripts/ad-widgets.js
|
||||||
- javascripts/docs-comments.js
|
- javascripts/docs-comments.js
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user