More control panel updates
This commit is contained in:
parent
435fb8150c
commit
7352815e57
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Changemaker Lite - Admin</title>
|
<title>Changemaker Lite - Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#1a1025">
|
<body style="margin:0;background:#1a1025">
|
||||||
|
|||||||
@ -348,6 +348,26 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
case 'campaign-form': {
|
||||||
|
const campaignSlug = (defaults.campaignSlug as string) || '';
|
||||||
|
const compact = defaults.compact === true;
|
||||||
|
return `
|
||||||
|
<section class="campaign-form-block"
|
||||||
|
data-campaign-slug="${campaignSlug}"
|
||||||
|
data-compact="${compact}"
|
||||||
|
style="padding: 60px 40px;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
|
||||||
|
<div style="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="M928 160H96c-17.7 0-32 14.3-32 32v640c0 17.7 14.3 32 32 32h832c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32zm-40 110.8V792H136V270.8l-27.6-21.5 39.3-50.5 42.8 33.3h643.1l42.8-33.3 39.3 50.5-27.7 21.5zM833.6 232L512 482 190.4 232l-42.8-33.3-39.3 50.5 27.6 21.5 356.9 277.7c11.7 9.1 28.4 9.1 40.1 0L889.7 270.8l27.6-21.5-39.3-50.5-44.4 33.2z"/>
|
||||||
|
</svg>
|
||||||
|
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Campaign Email Form</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">${campaignSlug || 'Set campaign slug in block properties'}</p>
|
||||||
|
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Interactive form will render on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
case 'gancio-events': {
|
case 'gancio-events': {
|
||||||
const maxlength = defaults.maxlength || 10;
|
const maxlength = defaults.maxlength || 10;
|
||||||
const evTheme = (defaults.theme as string) || 'dark';
|
const evTheme = (defaults.theme as string) || 'dark';
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export default function MediaPublicLayout() {
|
|||||||
marginLeft: mainContentMarginLeft,
|
marginLeft: mainContentMarginLeft,
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
paddingBottom: 48, // Space for bottom search bar
|
paddingBottom: 'calc(48px + env(safe-area-inset-bottom, 0px))', // Space for bottom search bar + iOS safe area
|
||||||
transition: 'margin-left 0.3s ease',
|
transition: 'margin-left 0.3s ease',
|
||||||
background: colorBgBase,
|
background: colorBgBase,
|
||||||
}}
|
}}
|
||||||
@ -97,7 +97,7 @@ export default function MediaPublicLayout() {
|
|||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: isMobile ? '8px 8px' : '12px 12px',
|
padding: isMobile ? '8px 12px' : '12px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
89
admin/src/components/QrCodeModal.tsx
Normal file
89
admin/src/components/QrCodeModal.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Modal, Button, Space, Input, message, Spin } from 'antd';
|
||||||
|
import { DownloadOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface QrCodeModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QrCodeModal({ open, onClose, url, title }: QrCodeModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
const qrSrc = `/api/qr?text=${encodeURIComponent(url)}&size=300`;
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `qr-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
message.success('URL copied');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`QR Code: ${title}`}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={400}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||||||
|
{loading && <Spin style={{ display: 'block', marginBottom: 16 }} />}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={qrSrc}
|
||||||
|
alt={`QR code for ${title}`}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
style={{ width: 300, height: 300, display: loading ? 'none' : 'block', margin: '0 auto' }}
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => { setLoading(false); message.error('Failed to generate QR code'); }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
readOnly
|
||||||
|
style={{ marginBottom: 12, textAlign: 'center' }}
|
||||||
|
addonAfter={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<DownloadOutlined />} type="primary" onClick={handleDownload}>
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button icon={copied ? <CheckOutlined /> : <CopyOutlined />} onClick={handleCopyUrl}>
|
||||||
|
Copy URL
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
|
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
|
||||||
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App,
|
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
|
import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -42,6 +42,8 @@ export default function ExportContactsModal({
|
|||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [preview, setPreview] = useState<ExportContactsPreviewResult | null>(null);
|
const [preview, setPreview] = useState<ExportContactsPreviewResult | null>(null);
|
||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@ -154,7 +156,7 @@ export default function ExportContactsModal({
|
|||||||
title="Export Canvass Contacts to Campaign"
|
title="Export Canvass Contacts to Campaign"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={640}
|
width={isMobile ? '95vw' : 640}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>Cancel</Button>,
|
<Button key="cancel" onClick={onClose}>Cancel</Button>,
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Drawer, Table, DatePicker, Select, Button, Space, Typography, Tag } from 'antd';
|
import { Drawer, Table, DatePicker, Select, Button, Space, Typography, Tag, Grid } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { HistoryOutlined } from '@ant-design/icons';
|
import { HistoryOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -24,6 +24,8 @@ export default function HistoricalRoutesDrawer({
|
|||||||
volunteers,
|
volunteers,
|
||||||
}: HistoricalRoutesDrawerProps) {
|
}: HistoricalRoutesDrawerProps) {
|
||||||
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
|
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [routeLoading, setRouteLoading] = useState<string | null>(null);
|
const [routeLoading, setRouteLoading] = useState<string | null>(null);
|
||||||
@ -142,7 +144,7 @@ export default function HistoricalRoutesDrawer({
|
|||||||
<Drawer
|
<Drawer
|
||||||
title={<><HistoryOutlined /> Route History</>}
|
title={<><HistoryOutlined /> Route History</>}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={600}
|
width={isMobile ? '100%' : 600}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography } from 'antd';
|
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
@ -22,6 +22,8 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
|
|||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [testData, setTestData] = useState<Record<string, string>>({});
|
const [testData, setTestData] = useState<Record<string, string>>({});
|
||||||
const [testLogs, setTestLogs] = useState<EmailTemplateTestLog[]>([]);
|
const [testLogs, setTestLogs] = useState<EmailTemplateTestLog[]>([]);
|
||||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||||
@ -120,7 +122,7 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
|
|||||||
title={`Send Test Email: ${template.name}`}
|
title={`Send Test Email: ${template.name}`}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={900}
|
width={isMobile ? '95vw' : 900}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>
|
<Button key="cancel" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Drawer, Timeline, Space, Tag, Typography, Button, Popconfirm, Input, message, Spin } from 'antd';
|
import { Drawer, Timeline, Space, Tag, Typography, Button, Popconfirm, Input, message, Spin, Grid } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -26,6 +26,8 @@ export default function VersionHistoryDrawer({
|
|||||||
onRollbackSuccess,
|
onRollbackSuccess,
|
||||||
}: VersionHistoryDrawerProps) {
|
}: VersionHistoryDrawerProps) {
|
||||||
const [versions, setVersions] = useState<EmailTemplateVersion[]>([]);
|
const [versions, setVersions] = useState<EmailTemplateVersion[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [rollbackNotes, setRollbackNotes] = useState('');
|
const [rollbackNotes, setRollbackNotes] = useState('');
|
||||||
const [rollingBack, setRollingBack] = useState<number | null>(null);
|
const [rollingBack, setRollingBack] = useState<number | null>(null);
|
||||||
@ -73,7 +75,7 @@ export default function VersionHistoryDrawer({
|
|||||||
title={`Version History: ${templateName}`}
|
title={`Version History: ${templateName}`}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width={600}
|
width={isMobile ? '100%' : 600}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, Form, Input, Button, Alert } from 'antd';
|
import { Modal, Form, Input, Button, Alert, Grid } from 'antd';
|
||||||
import { VideoPickerModal } from '../media/VideoPickerModal';
|
import { VideoPickerModal } from '../media/VideoPickerModal';
|
||||||
import type { Video } from '../media/VideoPickerModal';
|
import type { Video } from '../media/VideoPickerModal';
|
||||||
|
|
||||||
@ -23,6 +23,8 @@ export const VideoVariableEditor: React.FC<VideoVariableEditorProps> = ({
|
|||||||
existingKeys,
|
existingKeys,
|
||||||
}) => {
|
}) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||||
const [showVideoPicker, setShowVideoPicker] = useState(false);
|
const [showVideoPicker, setShowVideoPicker] = useState(false);
|
||||||
|
|
||||||
@ -77,7 +79,7 @@ export const VideoVariableEditor: React.FC<VideoVariableEditorProps> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
title="Add Video Variable"
|
title="Add Video Variable"
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>
|
<Button key="cancel" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
330
admin/src/components/influence/CampaignFormWidget.tsx
Normal file
330
admin/src/components/influence/CampaignFormWidget.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* Self-contained campaign email form 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 DonationWidget, PricingWidget, ProductWidget.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const apiBase = '/api';
|
||||||
|
|
||||||
|
// Theme colors matching the dark public pages
|
||||||
|
const COLORS = {
|
||||||
|
bg: '#0d1b2a',
|
||||||
|
card: '#1b2838',
|
||||||
|
primary: '#3498db',
|
||||||
|
primaryHover: '#2980b9',
|
||||||
|
text: '#fff',
|
||||||
|
textMuted: 'rgba(255,255,255,0.65)',
|
||||||
|
border: 'rgba(255,255,255,0.15)',
|
||||||
|
success: '#52c41a',
|
||||||
|
error: '#ff4d4f',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 buttonStyle: React.CSSProperties = {
|
||||||
|
padding: '10px 24px',
|
||||||
|
background: COLORS.primary,
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
emailSubject: string;
|
||||||
|
emailBody: string;
|
||||||
|
targetGovernmentLevels: string[];
|
||||||
|
allowSmtpEmail: boolean;
|
||||||
|
allowMailtoLink: boolean;
|
||||||
|
collectUserInfo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Representative {
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
representativeSetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignFormWidgetProps {
|
||||||
|
campaignSlug: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignFormWidget({ campaignSlug, compact = false }: CampaignFormWidgetProps) {
|
||||||
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 1: postal code + user info
|
||||||
|
const [postalCode, setPostalCode] = useState('');
|
||||||
|
const [userName, setUserName] = useState('');
|
||||||
|
const [userEmail, setUserEmail] = useState('');
|
||||||
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
|
|
||||||
|
// Step 2: representatives
|
||||||
|
const [representatives, setRepresentatives] = useState<Representative[]>([]);
|
||||||
|
const [step, setStep] = useState<'lookup' | 'reps' | 'done'>('lookup');
|
||||||
|
|
||||||
|
// Step 3: sending
|
||||||
|
const [sendingTo, setSendingTo] = useState<string | null>(null);
|
||||||
|
const [sentTo, setSentTo] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!campaignSlug) {
|
||||||
|
setError('No campaign slug configured');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axios.get<Campaign>(`${apiBase}/campaigns/${campaignSlug}/details`)
|
||||||
|
.then(({ data }) => setCampaign(data))
|
||||||
|
.catch(() => setError('Campaign not found'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [campaignSlug]);
|
||||||
|
|
||||||
|
const handleLookup = async () => {
|
||||||
|
if (!postalCode.trim()) return;
|
||||||
|
setLookupLoading(true);
|
||||||
|
try {
|
||||||
|
const code = postalCode.replace(/\s/g, '').toUpperCase();
|
||||||
|
const { data } = await axios.get<{ representatives: Representative[] }>(
|
||||||
|
`${apiBase}/representatives/by-postal/${code}`
|
||||||
|
);
|
||||||
|
let reps = data.representatives;
|
||||||
|
if (campaign?.targetGovernmentLevels?.length) {
|
||||||
|
const targets = new Set(campaign.targetGovernmentLevels);
|
||||||
|
reps = reps.filter(r => {
|
||||||
|
const setName = r.representativeSetName?.toLowerCase() || '';
|
||||||
|
if (targets.has('FEDERAL') && setName.includes('house of commons')) return true;
|
||||||
|
if (targets.has('PROVINCIAL') && (setName.includes('legislative') || setName.includes('national'))) return true;
|
||||||
|
if (targets.has('MUNICIPAL') && setName.includes('city')) return true;
|
||||||
|
if (targets.has('SCHOOL_BOARD') && setName.includes('school')) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRepresentatives(reps);
|
||||||
|
setStep('reps');
|
||||||
|
} catch {
|
||||||
|
setError('Could not look up representatives for this postal code');
|
||||||
|
} finally {
|
||||||
|
setLookupLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async (rep: Representative) => {
|
||||||
|
if (!campaign || !rep.email) return;
|
||||||
|
setSendingTo(rep.email);
|
||||||
|
try {
|
||||||
|
await axios.post(`${apiBase}/campaigns/${campaignSlug}/send-email`, {
|
||||||
|
userEmail: userEmail || 'anonymous@cmlite.org',
|
||||||
|
userName: userName || 'Anonymous',
|
||||||
|
postalCode: postalCode.replace(/\s/g, '').toUpperCase(),
|
||||||
|
recipientEmail: rep.email,
|
||||||
|
recipientName: rep.name,
|
||||||
|
});
|
||||||
|
setSentTo(prev => new Set(prev).add(rep.email!));
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user sees button unchanged
|
||||||
|
} finally {
|
||||||
|
setSendingTo(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMailto = (rep: Representative) => {
|
||||||
|
if (!campaign || !rep.email) return;
|
||||||
|
const subject = encodeURIComponent(campaign.emailSubject);
|
||||||
|
const body = encodeURIComponent(campaign.emailBody);
|
||||||
|
window.open(`mailto:${rep.email}?subject=${subject}&body=${body}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSent = representatives.length > 0 && representatives.every(r => !r.email || sentTo.has(r.email));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32, color: COLORS.textMuted }}>
|
||||||
|
Loading campaign...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !campaign) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32, color: COLORS.error }}>
|
||||||
|
{error || 'Campaign unavailable'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth: compact ? 480 : 600,
|
||||||
|
margin: '0 auto',
|
||||||
|
background: COLORS.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: compact ? 20 : 32,
|
||||||
|
color: COLORS.text,
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
}}>
|
||||||
|
{/* Title */}
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: compact ? 18 : 22, fontWeight: 700 }}>
|
||||||
|
{campaign.title}
|
||||||
|
</h3>
|
||||||
|
{campaign.description && !compact && (
|
||||||
|
<p style={{ margin: '0 0 20px', color: COLORS.textMuted, fontSize: 14, lineHeight: 1.5 }}>
|
||||||
|
{campaign.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Lookup */}
|
||||||
|
{step === 'lookup' && (
|
||||||
|
<div>
|
||||||
|
{campaign.collectUserInfo && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={userName}
|
||||||
|
onChange={e => setUserName(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Your email"
|
||||||
|
value={userEmail}
|
||||||
|
onChange={e => setUserEmail(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Postal code (e.g. K1A 0A6)"
|
||||||
|
value={postalCode}
|
||||||
|
onChange={e => setPostalCode(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleLookup()}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLookup}
|
||||||
|
disabled={lookupLoading || !postalCode.trim()}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
opacity: lookupLoading || !postalCode.trim() ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lookupLoading ? 'Looking up...' : 'Find Reps'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Representatives */}
|
||||||
|
{step === 'reps' && (
|
||||||
|
<div>
|
||||||
|
{allSent ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 8 }}>✅</div>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 600, color: COLORS.success }}>
|
||||||
|
All messages sent! Thank you.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep('lookup'); setSentTo(new Set()); setRepresentatives([]); }}
|
||||||
|
style={{ ...buttonStyle, background: 'rgba(255,255,255,0.1)', marginTop: 12, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Send again with different postal code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 13, color: COLORS.textMuted }}>
|
||||||
|
{representatives.length} representative{representatives.length !== 1 ? 's' : ''} found for {postalCode.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{representatives.map((rep, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${sentTo.has(rep.email || '') ? COLORS.success + '40' : COLORS.border}`,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{rep.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: COLORS.textMuted }}>{rep.representativeSetName}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{sentTo.has(rep.email || '') ? (
|
||||||
|
<span style={{ color: COLORS.success, fontSize: 13, fontWeight: 600 }}>Sent ✓</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{campaign.allowSmtpEmail && rep.email && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(rep)}
|
||||||
|
disabled={sendingTo === rep.email}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: sendingTo === rep.email ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendingTo === rep.email ? 'Sending...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{campaign.allowMailtoLink && rep.email && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMailto(rep)}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Email App
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('lookup')}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
background: 'transparent',
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 13,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Change postal code
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Checkbox, Button, Space } from 'antd';
|
import { Checkbox, Button, Space, Grid } from 'antd';
|
||||||
import type { Cut, PublicCut } from '@/types/api';
|
import type { Cut, PublicCut } from '@/types/api';
|
||||||
|
|
||||||
const VARIANT_BG = {
|
const VARIANT_BG = {
|
||||||
@ -15,6 +15,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, variant = 'admin', style }: Props) {
|
export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, variant = 'admin', style }: Props) {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const allVisible = cuts.every((c) => visibleCutIds.has(c.id));
|
const allVisible = cuts.every((c) => visibleCutIds.has(c.id));
|
||||||
const noneVisible = cuts.every((c) => !visibleCutIds.has(c.id));
|
const noneVisible = cuts.every((c) => !visibleCutIds.has(c.id));
|
||||||
|
|
||||||
@ -22,7 +24,7 @@ export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, v
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
bottom: 'max(24px, calc(24px + env(safe-area-inset-bottom, 0px)))',
|
||||||
left: 12,
|
left: 12,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
background: VARIANT_BG[variant],
|
background: VARIANT_BG[variant],
|
||||||
@ -31,7 +33,7 @@ export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, v
|
|||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
maxHeight: 280,
|
maxHeight: isMobile ? 120 : 280,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default function MapLegend({ variant = 'public' }: Props) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
bottom: 'max(24px, calc(24px + env(safe-area-inset-bottom, 0px)))',
|
||||||
right: 12,
|
right: 12,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
background: VARIANT_BG[variant],
|
background: VARIANT_BG[variant],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty } from 'antd';
|
import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty, Grid } from 'antd';
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
@ -28,6 +28,8 @@ interface AlbumDetailDrawerProps {
|
|||||||
|
|
||||||
export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) {
|
export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) {
|
||||||
const [album, setAlbum] = useState<PhotoAlbum | null>(null);
|
const [album, setAlbum] = useState<PhotoAlbum | null>(null);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -122,7 +124,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
|
|||||||
title={album?.title || 'Album Detail'}
|
title={album?.title || 'Album Detail'}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width={600}
|
width={isMobile ? '100%' : 600}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd';
|
import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme, Grid } from 'antd';
|
||||||
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
@ -29,6 +29,8 @@ export default function EditPlaylistModal({
|
|||||||
}: EditPlaylistModalProps) {
|
}: EditPlaylistModalProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [videos, setVideos] = useState<PlaylistVideoItem[]>([]);
|
const [videos, setVideos] = useState<PlaylistVideoItem[]>([]);
|
||||||
@ -126,7 +128,7 @@ export default function EditPlaylistModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
style={{ top: 64 }}
|
style={{ top: 64 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
List,
|
List,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
@ -76,6 +77,8 @@ const STATE_ICONS: Record<string, React.ReactNode> = {
|
|||||||
|
|
||||||
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
|
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
|
||||||
const [urls, setUrls] = useState('');
|
const [urls, setUrls] = useState('');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [jobs, setJobs] = useState<FetchJob[]>([]);
|
const [jobs, setJobs] = useState<FetchJob[]>([]);
|
||||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||||
@ -292,7 +295,7 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
|
|||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width={560}
|
width={isMobile ? '100%' : 560}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
{/* URL Input Section */}
|
{/* URL Input Section */}
|
||||||
|
|||||||
@ -66,7 +66,8 @@ export default function MediaBottomNav() {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 48,
|
height: `calc(48px + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
|
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
|
||||||
backdropFilter: isShorts ? 'blur(12px)' : undefined,
|
backdropFilter: isShorts ? 'blur(12px)' : undefined,
|
||||||
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
|
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Descriptions, Tag } from 'antd';
|
import { Modal, Descriptions, Tag, Grid } from 'antd';
|
||||||
import { CameraOutlined } from '@ant-design/icons';
|
import { CameraOutlined } from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { getAuthCallbacks } from '@/lib/api';
|
||||||
import type { Photo } from '@/types/media';
|
import type { Photo } from '@/types/media';
|
||||||
@ -19,6 +19,9 @@ interface PhotoViewerModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) {
|
export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
if (!photo) return null;
|
if (!photo) return null;
|
||||||
|
|
||||||
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
|
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
|
||||||
@ -28,7 +31,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={900}
|
width={isMobile ? '95vw' : 900}
|
||||||
centered
|
centered
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Card, Tag, Space, Typography, theme, Modal } from 'antd';
|
import { Card, Tag, Space, Typography, theme, Modal, Grid } from 'antd';
|
||||||
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||||
@ -27,6 +27,8 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { expandVideo } = useExpandedVideo();
|
const { expandVideo } = useExpandedVideo();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
// Hover video preview state
|
// Hover video preview state
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
@ -210,7 +212,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Play button overlay */}
|
{/* Play button overlay — always visible on mobile, hover-only on desktop */}
|
||||||
{!video.isLocked && (
|
{!video.isLocked && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -219,21 +221,21 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'rgba(0, 0, 0, 0.3)',
|
background: isMobile ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.3)',
|
||||||
opacity: 0,
|
opacity: isMobile ? 1 : 0,
|
||||||
transition: 'opacity 0.2s ease',
|
transition: 'opacity 0.2s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.opacity = '1';
|
if (!isMobile) e.currentTarget.style.opacity = '1';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.opacity = '0';
|
if (!isMobile) e.currentTarget.style.opacity = '0';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 64,
|
width: isMobile ? 48 : 64,
|
||||||
height: 64,
|
height: isMobile ? 48 : 64,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: hexToRgba(token.colorPrimary, 0.9),
|
background: hexToRgba(token.colorPrimary, 0.9),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -242,13 +244,13 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
transition: 'transform 0.2s ease',
|
transition: 'transform 0.2s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1.1)';
|
if (!isMobile) e.currentTarget.style.transform = 'scale(1.1)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
if (!isMobile) e.currentTarget.style.transform = 'scale(1)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlayCircleOutlined style={{ fontSize: 32, color: '#fff' }} />
|
<PlayCircleOutlined style={{ fontSize: isMobile ? 24 : 32, color: '#fff' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton } from 'antd';
|
import { Modal, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton, Grid } from 'antd';
|
||||||
import {
|
import {
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@ -24,6 +24,8 @@ export default function QuickAnalyticsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: QuickAnalyticsModalProps) {
|
}: QuickAnalyticsModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
|
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
|
||||||
|
|
||||||
@ -66,7 +68,7 @@ export default function QuickAnalyticsModal({
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={800}
|
width={isMobile ? '95vw' : 800}
|
||||||
aria-label="Video analytics modal"
|
aria-label="Video analytics modal"
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Drawer, Calendar, Badge, List, Tag, Button, Space, message, Empty, Alert, Skeleton } from 'antd';
|
import { Drawer, Calendar, Badge, List, Tag, Button, Space, message, Empty, Alert, Skeleton, Grid } from 'antd';
|
||||||
import type { CalendarProps } from 'antd';
|
import type { CalendarProps } from 'antd';
|
||||||
import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
@ -26,6 +26,8 @@ export default function ScheduleCalendarDrawer({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
}: ScheduleCalendarDrawerProps) {
|
}: ScheduleCalendarDrawerProps) {
|
||||||
const [schedules, setSchedules] = useState<ScheduleEvent[]>([]);
|
const [schedules, setSchedules] = useState<ScheduleEvent[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||||
@ -117,7 +119,7 @@ export default function ScheduleCalendarDrawer({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={700}
|
width={isMobile ? '100%' : 700}
|
||||||
mask={false}
|
mask={false}
|
||||||
destroyOnClose={false}
|
destroyOnClose={false}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, DatePicker, Select, Space, Alert, Switch, message } from 'antd';
|
import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd';
|
||||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
@ -39,6 +39,8 @@ export default function SchedulePublishModal({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
}: SchedulePublishModalProps) {
|
}: SchedulePublishModalProps) {
|
||||||
const [publishNow, setPublishNow] = useState(false);
|
const [publishNow, setPublishNow] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [publishAt, setPublishAt] = useState<Dayjs | null>(null);
|
const [publishAt, setPublishAt] = useState<Dayjs | null>(null);
|
||||||
const [selectedTimezone, setSelectedTimezone] = useState<string>('UTC');
|
const [selectedTimezone, setSelectedTimezone] = useState<string>('UTC');
|
||||||
const [unpublishEnabled, setUnpublishEnabled] = useState(false);
|
const [unpublishEnabled, setUnpublishEnabled] = useState(false);
|
||||||
@ -161,7 +163,7 @@ export default function SchedulePublishModal({
|
|||||||
onOk={handleSchedule}
|
onOk={handleSchedule}
|
||||||
okText={publishNow ? 'Publish Now' : 'Schedule'}
|
okText={publishNow ? 'Publish Now' : 'Schedule'}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
style={{ top: 20 }}
|
style={{ top: 20 }}
|
||||||
styles={{
|
styles={{
|
||||||
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
|
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
List,
|
List,
|
||||||
Tag,
|
Tag,
|
||||||
Button,
|
Button,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
@ -33,6 +34,8 @@ interface UploadResult {
|
|||||||
|
|
||||||
export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVideoDrawerProps) {
|
export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVideoDrawerProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
@ -149,7 +152,7 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
mask={false}
|
mask={false}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
closable={!uploading}
|
closable={!uploading}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Tabs, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton } from 'antd';
|
import { Modal, Tabs, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton, Grid } from 'antd';
|
||||||
import {
|
import {
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@ -27,6 +27,8 @@ export default function VideoAnalyticsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: VideoAnalyticsModalProps) {
|
}: VideoAnalyticsModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
|
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
|
||||||
|
|
||||||
@ -224,7 +226,7 @@ export default function VideoAnalyticsModal({
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={1000}
|
width={isMobile ? '95vw' : 1000}
|
||||||
style={{ top: 20 }}
|
style={{ top: 20 }}
|
||||||
styles={{
|
styles={{
|
||||||
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
|
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Tag,
|
Tag,
|
||||||
message,
|
message,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@ -60,6 +61,8 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
|||||||
title = 'Select Video',
|
title = 'Select Video',
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState('library');
|
const [activeTab, setActiveTab] = useState('library');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@ -172,7 +175,7 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
title={title}
|
title={title}
|
||||||
width={900}
|
width={isMobile ? '95vw' : 900}
|
||||||
footer={
|
footer={
|
||||||
mode === 'multiple' && activeTab === 'library' ? (
|
mode === 'multiple' && activeTab === 'library' ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme } from 'antd';
|
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme, Grid } from 'antd';
|
||||||
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
|
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@ -29,6 +29,8 @@ interface DonateInsertModalProps {
|
|||||||
|
|
||||||
export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModalProps) {
|
export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModalProps) {
|
||||||
const [variant, setVariant] = useState<DonateVariant>('simple');
|
const [variant, setVariant] = useState<DonateVariant>('simple');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [amount, setAmount] = useState<number | null>(25);
|
const [amount, setAmount] = useState<number | null>(25);
|
||||||
const [config, setConfig] = useState<PaymentConfig | null>(null);
|
const [config, setConfig] = useState<PaymentConfig | null>(null);
|
||||||
const [configLoading, setConfigLoading] = useState(false);
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
@ -85,7 +87,7 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
|
|||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
okText="Insert"
|
okText="Insert"
|
||||||
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
|
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
|
||||||
width={520}
|
width={isMobile ? '95vw' : 520}
|
||||||
>
|
>
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
Choose a donation block style to insert into your document.
|
Choose a donation block style to insert into your document.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input } from 'antd';
|
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Grid } from 'antd';
|
||||||
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Product, ProductType } from '@/types/api';
|
import type { Product, ProductType } from '@/types/api';
|
||||||
@ -24,6 +24,8 @@ const typeColors: Record<ProductType, string> = {
|
|||||||
|
|
||||||
export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertModalProps) {
|
export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertModalProps) {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@ -65,7 +67,7 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
|
|||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
okText="Insert"
|
okText="Insert"
|
||||||
okButtonProps={{ disabled: !selectedId }}
|
okButtonProps={{ disabled: !selectedId }}
|
||||||
width={640}
|
width={isMobile ? '95vw' : 640}
|
||||||
>
|
>
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
Select a product to embed as an inline purchase card.
|
Select a product to embed as an inline purchase card.
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Radio, Space, Typography } from 'antd';
|
import { Modal, Radio, Space, Typography, Grid } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { EditMode } from '@/types/api';
|
import type { EditMode } from '@/types/api';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -21,6 +21,8 @@ export default function EditModeModal({
|
|||||||
shiftsCount,
|
shiftsCount,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [mode, setMode] = useState<'THIS' | 'FUTURE' | 'ALL'>('THIS');
|
const [mode, setMode] = useState<'THIS' | 'FUTURE' | 'ALL'>('THIS');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const handleOk = () => {
|
const handleOk = () => {
|
||||||
onConfirm({
|
onConfirm({
|
||||||
@ -36,7 +38,7 @@ export default function EditModeModal({
|
|||||||
title="Edit Shift Series"
|
title="Edit Shift Series"
|
||||||
okText="Continue"
|
okText="Continue"
|
||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
width={500}
|
width={isMobile ? '95vw' : 500}
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
This shift is part of a repeating series. What would you like to edit?
|
This shift is part of a repeating series. What would you like to edit?
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Drawer, Table, Tag, Select, Statistic, Row, Col, Card, message } from 'antd';
|
import { Drawer, Table, Tag, Select, Statistic, Row, Col, Card, message, Grid } from 'antd';
|
||||||
import type { TablePaginationConfig } from 'antd/es/table';
|
import type { TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -36,6 +36,8 @@ const statusOptions: { value: CampaignEmailStatus; label: string }[] = [
|
|||||||
|
|
||||||
export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) {
|
export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) {
|
||||||
const [emails, setEmails] = useState<CampaignEmail[]>([]);
|
const [emails, setEmails] = useState<CampaignEmail[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<CampaignEmailStatus | undefined>();
|
const [statusFilter, setStatusFilter] = useState<CampaignEmailStatus | undefined>();
|
||||||
@ -123,7 +125,7 @@ export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props)
|
|||||||
title={`Emails — ${campaign?.title || ''}`}
|
title={`Emails — ${campaign?.title || ''}`}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width={720}
|
width={isMobile ? '100%' : 720}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
{stats && (
|
{stats && (
|
||||||
@ -166,6 +168,7 @@ export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props)
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="small"
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: pagination.page,
|
current: pagination.page,
|
||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Divider,
|
Divider,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -45,9 +47,30 @@ import type {
|
|||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
|
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
|
||||||
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
|
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
|
||||||
|
import QrCodeModal from '@/components/QrCodeModal';
|
||||||
|
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
||||||
|
import type { Video } from '@/components/media/VideoPickerModal';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
function CoverVideoField({ video, onChoose, onRemove, target }: {
|
||||||
|
video: Video | null;
|
||||||
|
onChoose: (target: 'create' | 'edit') => void;
|
||||||
|
onRemove: (target: 'create' | 'edit') => void;
|
||||||
|
target: 'create' | 'edit';
|
||||||
|
}) {
|
||||||
|
if (video) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Tag color="blue">Video #{video.id}: {video.title}</Tag>
|
||||||
|
<Button size="small" danger onClick={() => onRemove(target)}>Remove</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Button size="small" onClick={() => onChoose(target)}>Choose Cover Video</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
const statusColors: Record<CampaignStatus, string> = {
|
const statusColors: Record<CampaignStatus, string> = {
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
ACTIVE: 'green',
|
ACTIVE: 'green',
|
||||||
@ -92,8 +115,16 @@ export default function CampaignsPage() {
|
|||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [exportCampaignId, setExportCampaignId] = useState<string | undefined>();
|
const [exportCampaignId, setExportCampaignId] = useState<string | undefined>();
|
||||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||||
|
const [qrCampaign, setQrCampaign] = useState<Campaign | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
|
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
||||||
|
const [videoPickerTarget, setVideoPickerTarget] = useState<'create' | 'edit'>('create');
|
||||||
|
const [createSelectedVideo, setCreateSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const [editSelectedVideo, setEditSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const { settings: siteSettings } = useSettingsStore();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
@ -135,10 +166,11 @@ export default function CampaignsPage() {
|
|||||||
|
|
||||||
const handleCreate = async (values: CreateCampaignPayload) => {
|
const handleCreate = async (values: CreateCampaignPayload) => {
|
||||||
try {
|
try {
|
||||||
await api.post('/campaigns', values);
|
await api.post('/campaigns', { ...values, coverVideoId: createSelectedVideo?.id ?? null });
|
||||||
message.success('Campaign created');
|
message.success('Campaign created');
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
|
setCreateSelectedVideo(null);
|
||||||
fetchCampaigns({ page: 1 });
|
fetchCampaigns({ page: 1 });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
@ -151,11 +183,12 @@ export default function CampaignsPage() {
|
|||||||
const handleEdit = async (values: UpdateCampaignPayload) => {
|
const handleEdit = async (values: UpdateCampaignPayload) => {
|
||||||
if (!editingCampaign) return;
|
if (!editingCampaign) return;
|
||||||
try {
|
try {
|
||||||
await api.put(`/campaigns/${editingCampaign.id}`, values);
|
await api.put(`/campaigns/${editingCampaign.id}`, { ...values, coverVideoId: editSelectedVideo?.id ?? null });
|
||||||
message.success('Campaign updated');
|
message.success('Campaign updated');
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditingCampaign(null);
|
setEditingCampaign(null);
|
||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
|
setEditSelectedVideo(null);
|
||||||
fetchCampaigns();
|
fetchCampaigns();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
@ -205,7 +238,9 @@ export default function CampaignsPage() {
|
|||||||
showResponseWall: campaign.showResponseWall,
|
showResponseWall: campaign.showResponseWall,
|
||||||
highlightCampaign: campaign.highlightCampaign,
|
highlightCampaign: campaign.highlightCampaign,
|
||||||
coverPhoto: campaign.coverPhoto,
|
coverPhoto: campaign.coverPhoto,
|
||||||
|
coverVideoId: campaign.coverVideoId,
|
||||||
});
|
});
|
||||||
|
setEditSelectedVideo(campaign.coverVideoId ? { id: campaign.coverVideoId, title: `Video #${campaign.coverVideoId}` } as Video : null);
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -303,6 +338,14 @@ export default function CampaignsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="QR code">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
onClick={() => setQrCampaign(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Target from canvass">
|
<Tooltip title="Target from canvass">
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
@ -378,6 +421,20 @@ export default function CampaignsPage() {
|
|||||||
<Input placeholder="https://..." />
|
<Input placeholder="https://..." />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{siteSettings?.enableMediaFeatures !== false && (
|
||||||
|
<Form.Item label="Cover Video">
|
||||||
|
<CoverVideoField
|
||||||
|
video={videoPickerTarget === 'create' ? createSelectedVideo : editSelectedVideo}
|
||||||
|
onChoose={(target) => { setVideoPickerTarget(target); setVideoPickerOpen(true); }}
|
||||||
|
onRemove={(target) => {
|
||||||
|
if (target === 'create') { setCreateSelectedVideo(null); createForm.setFieldsValue({ coverVideoId: null }); }
|
||||||
|
else { setEditSelectedVideo(null); editForm.setFieldsValue({ coverVideoId: null }); }
|
||||||
|
}}
|
||||||
|
target={editModalOpen ? 'edit' : 'create'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider orientation="left" plain>
|
<Divider orientation="left" plain>
|
||||||
Campaign Options
|
Campaign Options
|
||||||
</Divider>
|
</Divider>
|
||||||
@ -494,6 +551,7 @@ export default function CampaignsPage() {
|
|||||||
showTotal: (total) => `${total} campaigns`,
|
showTotal: (total) => `${total} campaigns`,
|
||||||
}}
|
}}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
|
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -502,7 +560,7 @@ export default function CampaignsPage() {
|
|||||||
title="Create Campaign"
|
title="Create Campaign"
|
||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={640}
|
width={isMobile ? '95vw' : 640}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@ -520,7 +578,7 @@ export default function CampaignsPage() {
|
|||||||
title="Edit Campaign"
|
title="Edit Campaign"
|
||||||
open={editModalOpen}
|
open={editModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={640}
|
width={isMobile ? '95vw' : 640}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditingCampaign(null);
|
setEditingCampaign(null);
|
||||||
@ -544,6 +602,16 @@ export default function CampaignsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* QR Code Modal */}
|
||||||
|
{qrCampaign && (
|
||||||
|
<QrCodeModal
|
||||||
|
open={!!qrCampaign}
|
||||||
|
onClose={() => setQrCampaign(null)}
|
||||||
|
url={`${window.location.origin}/campaign/${qrCampaign.slug}`}
|
||||||
|
title={qrCampaign.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Export Canvass Contacts Modal */}
|
{/* Export Canvass Contacts Modal */}
|
||||||
<ExportContactsModal
|
<ExportContactsModal
|
||||||
open={exportOpen}
|
open={exportOpen}
|
||||||
@ -551,6 +619,23 @@ export default function CampaignsPage() {
|
|||||||
cuts={cuts}
|
cuts={cuts}
|
||||||
preselectedCampaignId={exportCampaignId}
|
preselectedCampaignId={exportCampaignId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Video Picker Modal */}
|
||||||
|
<VideoPickerModal
|
||||||
|
open={videoPickerOpen}
|
||||||
|
onClose={() => setVideoPickerOpen(false)}
|
||||||
|
onSelect={(video: Video) => {
|
||||||
|
if (videoPickerTarget === 'create') {
|
||||||
|
setCreateSelectedVideo(video);
|
||||||
|
createForm.setFieldsValue({ coverVideoId: video.id });
|
||||||
|
} else {
|
||||||
|
setEditSelectedVideo(video);
|
||||||
|
editForm.setFieldsValue({ coverVideoId: video.id });
|
||||||
|
}
|
||||||
|
setVideoPickerOpen(false);
|
||||||
|
}}
|
||||||
|
title="Select Cover Video"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Upload,
|
Upload,
|
||||||
Typography,
|
Typography,
|
||||||
|
Result,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -54,6 +56,8 @@ const categoryOptions = Object.entries(CUT_CATEGORY_LABELS).map(([value, label])
|
|||||||
export default function CutsPage() {
|
export default function CutsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -465,6 +469,7 @@ export default function CutsPage() {
|
|||||||
dataSource={cuts}
|
dataSource={cuts}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: pagination.page,
|
current: pagination.page,
|
||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
@ -479,6 +484,17 @@ export default function CutsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : isMobile ? (
|
||||||
|
<Result
|
||||||
|
status="warning"
|
||||||
|
title="Desktop Required"
|
||||||
|
subTitle="Cut drawing requires a mouse for precise polygon editing. Please use a desktop browser for the map editor. The table view above is fully usable on mobile."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => setActiveTab('table')}>
|
||||||
|
Switch to Table
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CutEditorMap
|
<CutEditorMap
|
||||||
cuts={cuts}
|
cuts={cuts}
|
||||||
@ -491,7 +507,7 @@ export default function CutsPage() {
|
|||||||
title="Create Cut"
|
title="Create Cut"
|
||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={500}
|
width={isMobile ? '95vw' : 500}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@ -516,7 +532,7 @@ export default function CutsPage() {
|
|||||||
title="Import GeoJSON"
|
title="Import GeoJSON"
|
||||||
open={importModalOpen}
|
open={importModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={500}
|
width={isMobile ? '95vw' : 500}
|
||||||
onCancel={() => setImportModalOpen(false)}
|
onCancel={() => setImportModalOpen(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
@ -543,7 +559,7 @@ export default function CutsPage() {
|
|||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Cut"
|
title="Edit Cut"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
width={480}
|
width={isMobile ? '100%' : 480}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingCut(null);
|
setEditingCut(null);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
Typography,
|
Typography,
|
||||||
Badge,
|
Badge,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@ -54,6 +55,8 @@ const activeOptions = [
|
|||||||
|
|
||||||
export default function EmailTemplatesPage() {
|
export default function EmailTemplatesPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -273,7 +276,7 @@ export default function EmailTemplatesPage() {
|
|||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
style={{ width: 200 }}
|
style={{ width: isMobile ? '100%' : 200 }}
|
||||||
allowClear
|
allowClear
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -26,6 +27,7 @@ import {
|
|||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
BuildOutlined,
|
BuildOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -33,6 +35,7 @@ import { useOutletContext } from 'react-router-dom';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||||
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
|
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
|
||||||
|
import QrCodeModal from '@/components/QrCodeModal';
|
||||||
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams, AppOutletContext } from '@/types/api';
|
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams, AppOutletContext } from '@/types/api';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -48,6 +51,8 @@ export default function LandingPagesPage() {
|
|||||||
const [pages, setPages] = useState<LandingPage[]>([]);
|
const [pages, setPages] = useState<LandingPage[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -58,6 +63,8 @@ export default function LandingPagesPage() {
|
|||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
||||||
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||||
|
const [qrPage, setQrPage] = useState<LandingPage | null>(null);
|
||||||
|
const [viewCounts, setViewCounts] = useState<Record<string, number>>({});
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [settingsForm] = Form.useForm();
|
const [settingsForm] = Form.useForm();
|
||||||
|
|
||||||
@ -95,6 +102,12 @@ export default function LandingPagesPage() {
|
|||||||
fetchPages({ page: 1 });
|
fetchPages({ page: 1 });
|
||||||
}, [debouncedSearch, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [debouncedSearch, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Record<string, number>>('/pages/view-counts')
|
||||||
|
.then(({ data }) => setViewCounts(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||||
fetchPages({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
fetchPages({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||||
};
|
};
|
||||||
@ -289,6 +302,12 @@ export default function LandingPagesPage() {
|
|||||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||||
responsive: ['md'],
|
responsive: ['md'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Views (30d)',
|
||||||
|
key: 'views',
|
||||||
|
render: (_: unknown, record: LandingPage) => viewCounts[record.slug] ?? 0,
|
||||||
|
responsive: ['md'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@ -309,13 +328,22 @@ export default function LandingPagesPage() {
|
|||||||
title="Page settings"
|
title="Page settings"
|
||||||
/>
|
/>
|
||||||
{record.published && (
|
{record.published && (
|
||||||
<Button
|
<>
|
||||||
type="link"
|
<Button
|
||||||
size="small"
|
type="link"
|
||||||
icon={<EyeOutlined />}
|
size="small"
|
||||||
onClick={() => window.open(`/p/${record.slug}`, '_blank')}
|
icon={<EyeOutlined />}
|
||||||
title="View page"
|
onClick={() => window.open(`/p/${record.slug}`, '_blank')}
|
||||||
/>
|
title="View page"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
onClick={() => setQrPage(record)}
|
||||||
|
title="QR code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{record.published ? (
|
{record.published ? (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
@ -481,12 +509,22 @@ export default function LandingPagesPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* QR Code Modal */}
|
||||||
|
{qrPage && (
|
||||||
|
<QrCodeModal
|
||||||
|
open={!!qrPage}
|
||||||
|
onClose={() => setQrPage(null)}
|
||||||
|
url={`${window.location.origin}/p/${qrPage.slug}`}
|
||||||
|
title={qrPage.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Page Settings"
|
title="Page Settings"
|
||||||
open={settingsModalOpen}
|
open={settingsModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={560}
|
width={isMobile ? '95vw' : 560}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
setEditingPage(null);
|
setEditingPage(null);
|
||||||
|
|||||||
@ -346,6 +346,7 @@ export default function ListmonkPage() {
|
|||||||
size="small"
|
size="small"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'List Name', dataIndex: 'name', key: 'name' },
|
{ title: 'List Name', dataIndex: 'name', key: 'name' },
|
||||||
{
|
{
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -101,6 +102,8 @@ function formatNarSize(bytes: number): string {
|
|||||||
export default function LocationsPage() {
|
export default function LocationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [locations, setLocations] = useState<Location[]>([]);
|
const [locations, setLocations] = useState<Location[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [stats, setStats] = useState<LocationStats | null>(null);
|
const [stats, setStats] = useState<LocationStats | null>(null);
|
||||||
@ -1195,6 +1198,7 @@ export default function LocationsPage() {
|
|||||||
dataSource={locations}
|
dataSource={locations}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
@ -1230,8 +1234,8 @@ export default function LocationsPage() {
|
|||||||
locations={allLocations}
|
locations={allLocations}
|
||||||
loading={mapLoading}
|
loading={mapLoading}
|
||||||
onEditLocation={openEdit}
|
onEditLocation={openEdit}
|
||||||
onAddLocationAtPoint={handleAddFromMap}
|
onAddLocationAtPoint={isMobile ? undefined : handleAddFromMap}
|
||||||
onMoveLocation={handleMoveLocation}
|
onMoveLocation={isMobile ? undefined : handleMoveLocation}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMapMove={handleMapMove}
|
onMapMove={handleMapMove}
|
||||||
visible={activeTab === 'map'}
|
visible={activeTab === 'map'}
|
||||||
@ -1246,7 +1250,7 @@ export default function LocationsPage() {
|
|||||||
title="Add Location"
|
title="Add Location"
|
||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@ -1263,7 +1267,7 @@ export default function LocationsPage() {
|
|||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Location"
|
title="Edit Location"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
width={700}
|
width={isMobile ? '100%' : 700}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingLocation(null);
|
setEditingLocation(null);
|
||||||
@ -1850,7 +1854,7 @@ export default function LocationsPage() {
|
|||||||
bulkGeocodeForm.resetFields();
|
bulkGeocodeForm.resetFields();
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
>
|
>
|
||||||
{!bulkGeocoding && !bulkGeocodeStatus ? (
|
{!bulkGeocoding && !bulkGeocodeStatus ? (
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, App,
|
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, App,
|
||||||
Modal, Checkbox, Select, Popconfirm,
|
Modal, Checkbox, Select, Popconfirm, Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudServerOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
CloudServerOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||||
@ -69,6 +69,8 @@ const suggestNextSubnet = (sites: PangolinSite[]): string => {
|
|||||||
export default function PangolinPage() {
|
export default function PangolinPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const [status, setStatus] = useState<PangolinStatus | null>(null);
|
const [status, setStatus] = useState<PangolinStatus | null>(null);
|
||||||
const [config, setConfig] = useState<PangolinConfig | null>(null);
|
const [config, setConfig] = useState<PangolinConfig | null>(null);
|
||||||
@ -697,7 +699,7 @@ export default function PangolinPage() {
|
|||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
>
|
>
|
||||||
<Form form={editForm} layout="vertical" onFinish={handleUpdateResource}>
|
<Form form={editForm} layout="vertical" onFinish={handleUpdateResource}>
|
||||||
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
|
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Statistic,
|
Statistic,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@ -50,6 +51,8 @@ export default function RepresentativesPage() {
|
|||||||
|
|
||||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [selectedRep, setSelectedRep] = useState<Representative | null>(null);
|
const [selectedRep, setSelectedRep] = useState<Representative | null>(null);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
@ -320,6 +323,7 @@ export default function RepresentativesPage() {
|
|||||||
showTotal: (total) => `${total} representatives`,
|
showTotal: (total) => `${total} representatives`,
|
||||||
}}
|
}}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
locale={{ emptyText: 'No cached representatives. Use the lookup above to fetch and cache results.' }}
|
locale={{ emptyText: 'No cached representatives. Use the lookup above to fetch and cache results.' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -350,7 +354,7 @@ export default function RepresentativesPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
width={640}
|
width={isMobile ? '95vw' : 640}
|
||||||
>
|
>
|
||||||
{selectedRep && (
|
{selectedRep && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
@ -53,6 +54,8 @@ export default function ResponsesPage() {
|
|||||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
const [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);
|
const [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const fetchResponses = useCallback(async (page = 1) => {
|
const fetchResponses = useCallback(async (page = 1) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -347,7 +350,7 @@ export default function ResponsesPage() {
|
|||||||
title="Response Details"
|
title="Response Details"
|
||||||
open={!!detailResponse}
|
open={!!detailResponse}
|
||||||
onClose={() => setDetailResponse(null)}
|
onClose={() => setDetailResponse(null)}
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
{detailResponse && (
|
{detailResponse && (
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Alert,
|
Alert,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -118,6 +119,8 @@ export default function ShiftsPage() {
|
|||||||
const [calendarData, setCalendarData] = useState<CalendarData['dates']>({});
|
const [calendarData, setCalendarData] = useState<CalendarData['dates']>({});
|
||||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||||
const [currentMonth] = useState(dayjs());
|
const [currentMonth] = useState(dayjs());
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
@ -811,7 +814,7 @@ export default function ShiftsPage() {
|
|||||||
{/* Main Content Container - shifts when drawer opens */}
|
{/* Main Content Container - shifts when drawer opens */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginRight: activeDrawerWidth,
|
marginRight: isMobile ? 0 : activeDrawerWidth,
|
||||||
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -902,6 +905,7 @@ export default function ShiftsPage() {
|
|||||||
onClick: () => openSignups(record),
|
onClick: () => openSignups(record),
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
})}
|
})}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
locale={{ emptyText: (debouncedSearch || statusFilter)
|
locale={{ emptyText: (debouncedSearch || statusFilter)
|
||||||
? 'No shifts match your filters.'
|
? 'No shifts match your filters.'
|
||||||
: <div style={{ padding: 16 }}>
|
: <div style={{ padding: 16 }}>
|
||||||
@ -952,13 +956,13 @@ export default function ShiftsPage() {
|
|||||||
|
|
||||||
{/* Create Drawer */}
|
{/* Create Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
mask={false}
|
mask={isMobile}
|
||||||
title={createMode === 'single' ? 'Create Shift' : 'Create Shift Series'}
|
title={createMode === 'single' ? 'Create Shift' : 'Create Shift Series'}
|
||||||
open={createDrawerOpen}
|
open={createDrawerOpen}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={createMode === 'series' ? 700 : 600}
|
width={isMobile ? '100%' : (createMode === 'series' ? 700 : 600)}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
{...(!isMobile && { rootStyle: { top: 64, height: 'calc(100vh - 64px)' } })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCreateDrawerOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@ -992,9 +996,9 @@ export default function ShiftsPage() {
|
|||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Shift"
|
title="Edit Shift"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
mask={false}
|
mask={isMobile}
|
||||||
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
{...(!isMobile && { rootStyle: { top: 64, height: 'calc(100vh - 64px)' } })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingShift(null);
|
setEditingShift(null);
|
||||||
@ -1020,9 +1024,9 @@ export default function ShiftsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
open={signupsDrawerOpen}
|
open={signupsDrawerOpen}
|
||||||
width={640}
|
width={isMobile ? '100%' : 640}
|
||||||
mask={false}
|
mask={isMobile}
|
||||||
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
{...(!isMobile && { rootStyle: { top: 64, height: 'calc(100vh - 64px)' } })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSignupsDrawerOpen(false);
|
setSignupsDrawerOpen(false);
|
||||||
setSignupsShift(null);
|
setSignupsShift(null);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
DatePicker,
|
DatePicker,
|
||||||
Modal,
|
Modal,
|
||||||
Badge,
|
Badge,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -94,6 +95,8 @@ export default function UsersPage() {
|
|||||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
const [rejectingUser, setRejectingUser] = useState<User | null>(null);
|
const [rejectingUser, setRejectingUser] = useState<User | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Users' });
|
setPageHeader({ title: 'Users' });
|
||||||
@ -263,6 +266,7 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
title: 'Roles',
|
title: 'Roles',
|
||||||
key: 'roles',
|
key: 'roles',
|
||||||
|
responsive: ['md'] as any,
|
||||||
render: (_: unknown, record: User) => {
|
render: (_: unknown, record: User) => {
|
||||||
const roles = getUserRoles(record);
|
const roles = getUserRoles(record);
|
||||||
return (
|
return (
|
||||||
@ -349,7 +353,7 @@ export default function UsersPage() {
|
|||||||
{/* Main Content Container - shifts when drawer opens */}
|
{/* Main Content Container - shifts when drawer opens */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginRight: activeDrawerWidth,
|
marginRight: isMobile ? 0 : activeDrawerWidth,
|
||||||
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -415,6 +419,7 @@ export default function UsersPage() {
|
|||||||
showTotal: (total) => `${total} users`,
|
showTotal: (total) => `${total} users`,
|
||||||
}}
|
}}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
locale={{ emptyText: 'No users found' }}
|
locale={{ emptyText: 'No users found' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -424,10 +429,10 @@ export default function UsersPage() {
|
|||||||
title="Create User"
|
title="Create User"
|
||||||
open={createDrawerOpen}
|
open={createDrawerOpen}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
mask={false}
|
mask={isMobile}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
{...(!isMobile && { rootStyle: { top: 64, height: 'calc(100vh - 64px)' } })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCreateDrawerOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@ -514,10 +519,10 @@ export default function UsersPage() {
|
|||||||
title="Edit User"
|
title="Edit User"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
mask={false}
|
mask={isMobile}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
{...(!isMobile && { rootStyle: { top: 64, height: 'calc(100vh - 64px)' } })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Modal,
|
Modal,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
@ -44,6 +45,8 @@ const { TextArea } = Input;
|
|||||||
|
|
||||||
export default function CampaignModerationPage() {
|
export default function CampaignModerationPage() {
|
||||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -283,7 +286,7 @@ export default function CampaignModerationPage() {
|
|||||||
title="Campaign Details"
|
title="Campaign Details"
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => { setDrawerOpen(false); setSelectedCampaign(null); }}
|
onClose={() => { setDrawerOpen(false); setSelectedCampaign(null); }}
|
||||||
width={600}
|
width={isMobile ? '100%' : 600}
|
||||||
>
|
>
|
||||||
{selectedCampaign && (
|
{selectedCampaign && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Form,
|
Form,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
@ -87,6 +88,8 @@ export default function CommentModerationPage() {
|
|||||||
|
|
||||||
// Comments state
|
// Comments state
|
||||||
const [comments, setComments] = useState<CommentRecord[]>([]);
|
const [comments, setComments] = useState<CommentRecord[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [stats, setStats] = useState<CommentStats>({ total: 0, pending: 0, flagged: 0, hidden: 0, safe: 0 });
|
const [stats, setStats] = useState<CommentStats>({ total: 0, pending: 0, flagged: 0, hidden: 0, safe: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -504,7 +507,7 @@ export default function CommentModerationPage() {
|
|||||||
title="Comment Details"
|
title="Comment Details"
|
||||||
open={!!selectedComment}
|
open={!!selectedComment}
|
||||||
onClose={() => setSelectedComment(null)}
|
onClose={() => setSelectedComment(null)}
|
||||||
width={480}
|
width={isMobile ? '100%' : 480}
|
||||||
>
|
>
|
||||||
{selectedComment && (
|
{selectedComment && (
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
ConfigProvider,
|
ConfigProvider,
|
||||||
theme as antTheme,
|
theme as antTheme,
|
||||||
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -68,6 +69,8 @@ export default function GalleryAdsPage() {
|
|||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [ads, setAds] = useState<GalleryAd[]>([]);
|
const [ads, setAds] = useState<GalleryAd[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editingAd, setEditingAd] = useState<GalleryAd | null>(null);
|
const [editingAd, setEditingAd] = useState<GalleryAd | null>(null);
|
||||||
@ -373,7 +376,7 @@ export default function GalleryAdsPage() {
|
|||||||
title={editingAd ? 'Edit Ad' : 'Create Ad'}
|
title={editingAd ? 'Edit Ad' : 'Create Ad'}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||||
Save
|
Save
|
||||||
@ -485,7 +488,7 @@ export default function GalleryAdsPage() {
|
|||||||
open={!!previewAd}
|
open={!!previewAd}
|
||||||
onCancel={() => setPreviewAd(null)}
|
onCancel={() => setPreviewAd(null)}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={360}
|
width={isMobile ? '95vw' : 360}
|
||||||
styles={{ content: { background: '#0d1b2a', padding: 24 } }}
|
styles={{ content: { background: '#0d1b2a', padding: 24 } }}
|
||||||
>
|
>
|
||||||
{previewAd && (
|
{previewAd && (
|
||||||
@ -508,7 +511,7 @@ export default function GalleryAdsPage() {
|
|||||||
title={analyticsAd ? `Analytics: ${analyticsAd.title}` : 'Ad Analytics'}
|
title={analyticsAd ? `Analytics: ${analyticsAd.title}` : 'Ad Analytics'}
|
||||||
open={!!analyticsAd}
|
open={!!analyticsAd}
|
||||||
onClose={() => { setAnalyticsAd(null); setAnalytics(null); }}
|
onClose={() => { setAnalyticsAd(null); setAnalytics(null); }}
|
||||||
width={520}
|
width={isMobile ? '100%' : 520}
|
||||||
>
|
>
|
||||||
{analyticsLoading ? (
|
{analyticsLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>
|
<div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal, Segmented } from 'antd';
|
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal, Segmented, Grid } from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
@ -49,6 +49,8 @@ type MediaTab = 'Videos' | 'Photos' | 'Albums';
|
|||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [mediaTab, setMediaTab] = useLocalStorage<MediaTab>('libraryMediaTab', 'Videos');
|
const [mediaTab, setMediaTab] = useLocalStorage<MediaTab>('libraryMediaTab', 'Videos');
|
||||||
|
|
||||||
// === Video state ===
|
// === Video state ===
|
||||||
@ -372,7 +374,7 @@ export default function LibraryPage() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 200 }}
|
style={{ width: isMobile ? '100%' : 200 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Orientation filter (Videos + Photos) */}
|
{/* Orientation filter (Videos + Photos) */}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Space, App, Modal, Form, Input, InputNumber, Select, Switch, Tag, Popconfirm } from 'antd';
|
import { Table, Button, Space, App, Modal, Form, Input, InputNumber, Select, Switch, Tag, Popconfirm, Grid } from 'antd';
|
||||||
import { PlusOutlined, SyncOutlined, EditOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SyncOutlined, EditOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -10,6 +10,8 @@ export default function ProductsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@ -176,7 +178,7 @@ export default function ProductsPage() {
|
|||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={() => { setModalOpen(false); setEditingProduct(null); form.resetFields(); }}
|
onCancel={() => { setModalOpen(false); setEditingProduct(null); form.resetFields(); }}
|
||||||
width={600}
|
width={isMobile ? '95vw' : 600}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" initialValues={{ type: 'DIGITAL', isActive: true }}>
|
<Form form={form} layout="vertical" initialValues={{ type: 'DIGITAL', isActive: true }}>
|
||||||
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
|
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Card, Input, Select, Button, Tag, Space, Typography, App, Popconfirm, Drawer } from 'antd';
|
import { Table, Card, Input, Select, Button, Tag, Space, Typography, App, Popconfirm, Drawer, Grid } from 'antd';
|
||||||
import { SearchOutlined, StopOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { SearchOutlined, StopOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -10,6 +10,8 @@ const { Text } = Typography;
|
|||||||
export default function SubscribersPage() {
|
export default function SubscribersPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const [subscriptions, setSubscriptions] = useState<UserSubscription[]>([]);
|
const [subscriptions, setSubscriptions] = useState<UserSubscription[]>([]);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -175,7 +177,7 @@ export default function SubscribersPage() {
|
|||||||
title="Subscription Details"
|
title="Subscription Details"
|
||||||
open={!!selectedSub}
|
open={!!selectedSub}
|
||||||
onClose={() => setSelectedSub(null)}
|
onClose={() => setSelectedSub(null)}
|
||||||
width={400}
|
width={isMobile ? '100%' : 400}
|
||||||
>
|
>
|
||||||
{selectedSub && (
|
{selectedSub && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import type {
|
|||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
||||||
import { mapRepSetToLevel } from '@/utils/representatives';
|
import { mapRepSetToLevel } from '@/utils/representatives';
|
||||||
|
import { VideoPlayer } from '@/components/media/VideoPlayer';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
@ -261,6 +262,18 @@ export default function CampaignPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Video */}
|
||||||
|
{campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && (
|
||||||
|
<div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<VideoPlayer
|
||||||
|
videoId={campaign.coverVideoId}
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Call to Action */}
|
{/* Call to Action */}
|
||||||
{campaign.callToAction && (
|
{campaign.callToAction && (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { AdvancedVideoPlayer } from '@/components/media/AdvancedVideoPlayer';
|
|||||||
import { DonationWidget } from '@/components/payments/DonationWidget';
|
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';
|
||||||
|
|
||||||
export default function PublicLandingPage() {
|
export default function PublicLandingPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
@ -18,6 +19,21 @@ export default function PublicLandingPage() {
|
|||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const videoRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const videoRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return;
|
||||||
|
const sessionHash = sessionStorage.getItem('cm_session') ||
|
||||||
|
Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
sessionStorage.setItem('cm_session', sessionHash);
|
||||||
|
|
||||||
|
axios.post('/api/docs-analytics/track', {
|
||||||
|
path: `/p/${slug}`,
|
||||||
|
referrer: document.referrer || null,
|
||||||
|
sessionHash,
|
||||||
|
}).catch(() => {}); // fire-and-forget
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPage = async () => {
|
const fetchPage = async () => {
|
||||||
@ -207,10 +223,42 @@ export default function PublicLandingPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hydrate campaign form blocks
|
||||||
|
const hydrateCampaignFormBlocks = () => {
|
||||||
|
const formBlocks = contentRef.current?.querySelectorAll('.campaign-form-block');
|
||||||
|
if (!formBlocks) return;
|
||||||
|
|
||||||
|
// Clean up previous roots
|
||||||
|
campaignFormRootsRef.current.forEach((root) => {
|
||||||
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount campaign form root:', err); }
|
||||||
|
});
|
||||||
|
campaignFormRootsRef.current = [];
|
||||||
|
|
||||||
|
formBlocks.forEach((blockEl) => {
|
||||||
|
const campaignSlug = blockEl.getAttribute('data-campaign-slug');
|
||||||
|
if (!campaignSlug) return;
|
||||||
|
|
||||||
|
const compact = blockEl.getAttribute('data-compact') === 'true';
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
blockEl.innerHTML = '';
|
||||||
|
blockEl.appendChild(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const root = createRoot(container);
|
||||||
|
campaignFormRootsRef.current.push(root);
|
||||||
|
root.render(<CampaignFormWidget campaignSlug={campaignSlug} compact={compact} />);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render campaign form 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);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
@ -223,6 +271,11 @@ export default function PublicLandingPage() {
|
|||||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount payment root on cleanup:', err); }
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount payment root on cleanup:', err); }
|
||||||
});
|
});
|
||||||
paymentRootsRef.current = [];
|
paymentRootsRef.current = [];
|
||||||
|
|
||||||
|
campaignFormRootsRef.current.forEach((root) => {
|
||||||
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount campaign form root on cleanup:', err); }
|
||||||
|
});
|
||||||
|
campaignFormRootsRef.current = [];
|
||||||
};
|
};
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message, Result } from 'antd';
|
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message, Result, Grid } from 'antd';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined, CalendarOutlined, SendOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined, CalendarOutlined, SendOutlined } from '@ant-design/icons';
|
||||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||||
@ -105,6 +105,8 @@ export default function MapPage() {
|
|||||||
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const { settings: siteSettings } = useSettingsStore();
|
const { settings: siteSettings } = useSettingsStore();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -374,6 +376,7 @@ export default function MapPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
size={isMobile ? 'large' : 'middle'}
|
||||||
icon={<AimOutlined />}
|
icon={<AimOutlined />}
|
||||||
onClick={handleGeolocate}
|
onClick={handleGeolocate}
|
||||||
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
||||||
@ -383,6 +386,7 @@ export default function MapPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
size={isMobile ? 'large' : 'middle'}
|
||||||
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
onClick={handleFullscreen}
|
onClick={handleFullscreen}
|
||||||
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
||||||
|
|||||||
@ -116,6 +116,7 @@ export interface Campaign {
|
|||||||
emailBody: string;
|
emailBody: string;
|
||||||
callToAction: string | null;
|
callToAction: string | null;
|
||||||
coverPhoto: string | null;
|
coverPhoto: string | null;
|
||||||
|
coverVideoId: number | null;
|
||||||
status: CampaignStatus;
|
status: CampaignStatus;
|
||||||
allowSmtpEmail: boolean;
|
allowSmtpEmail: boolean;
|
||||||
allowMailtoLink: boolean;
|
allowMailtoLink: boolean;
|
||||||
@ -167,6 +168,7 @@ export interface CreateCampaignPayload {
|
|||||||
showResponseWall?: boolean;
|
showResponseWall?: boolean;
|
||||||
highlightCampaign?: boolean;
|
highlightCampaign?: boolean;
|
||||||
coverPhoto?: string;
|
coverPhoto?: string;
|
||||||
|
coverVideoId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCampaignPayload {
|
export interface UpdateCampaignPayload {
|
||||||
@ -187,6 +189,7 @@ export interface UpdateCampaignPayload {
|
|||||||
showResponseWall?: boolean;
|
showResponseWall?: boolean;
|
||||||
highlightCampaign?: boolean;
|
highlightCampaign?: boolean;
|
||||||
coverPhoto?: string | null;
|
coverPhoto?: string | null;
|
||||||
|
coverVideoId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignsListParams {
|
export interface CampaignsListParams {
|
||||||
|
|||||||
@ -30,6 +30,9 @@ COPY --from=build /app/node_modules ./node_modules
|
|||||||
COPY --from=build /app/package.json ./
|
COPY --from=build /app/package.json ./
|
||||||
COPY --from=build /app/prisma ./prisma
|
COPY --from=build /app/prisma ./prisma
|
||||||
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
||||||
|
&& mkdir -p /app/uploads && chown -R node:node /app/uploads
|
||||||
|
|
||||||
|
USER node
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@ -1,13 +1,31 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "Running Prisma schema sync..."
|
# Block NODE_TLS_REJECT_UNAUTHORIZED=0 in production
|
||||||
npx prisma db push --skip-generate 2>&1
|
if [ "$NODE_ENV" = "production" ] && [ "$NODE_TLS_REJECT_UNAUTHORIZED" = "0" ]; then
|
||||||
echo "Schema sync complete."
|
echo "FATAL: NODE_TLS_REJECT_UNAUTHORIZED=0 is not allowed in production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running Prisma migrations..."
|
||||||
|
npx prisma migrate deploy 2>&1 || {
|
||||||
|
echo "Migration failed, falling back to schema push..."
|
||||||
|
npx prisma db push --skip-generate 2>&1
|
||||||
|
}
|
||||||
|
echo "Database sync complete."
|
||||||
|
|
||||||
echo "Running database seed..."
|
echo "Running database seed..."
|
||||||
npx prisma db seed 2>&1
|
npx prisma db seed 2>&1
|
||||||
echo "Seed complete."
|
echo "Seed complete."
|
||||||
|
|
||||||
|
# If running production mode (node dist/server.js) and dist is stale, recompile
|
||||||
|
if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then
|
||||||
|
if [ ! -f "dist/server.js" ] || [ "src/server.ts" -nt "dist/server.js" ]; then
|
||||||
|
echo "Compiling TypeScript (dist/ is missing or stale)..."
|
||||||
|
npx tsc 2>&1 || echo "WARNING: TypeScript compilation had errors"
|
||||||
|
echo "Compilation complete."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting server..."
|
echo "Starting server..."
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@ -189,6 +189,7 @@ model Campaign {
|
|||||||
emailBody String @db.Text
|
emailBody String @db.Text
|
||||||
callToAction String? @db.Text
|
callToAction String? @db.Text
|
||||||
coverPhoto String?
|
coverPhoto String?
|
||||||
|
coverVideoId Int?
|
||||||
status CampaignStatus @default(DRAFT)
|
status CampaignStatus @default(DRAFT)
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
|
|||||||
@ -28,7 +28,7 @@ async function main() {
|
|||||||
console.warn('⚠️ INITIAL_ADMIN_PASSWORD contains placeholder value');
|
console.warn('⚠️ INITIAL_ADMIN_PASSWORD contains placeholder value');
|
||||||
console.warn('⚠️ Skipping admin user creation. Please set a real password in .env');
|
console.warn('⚠️ Skipping admin user creation. Please set a real password in .env');
|
||||||
} else {
|
} else {
|
||||||
const hashedPassword = await bcrypt.hash(initialAdminPassword, 10);
|
const hashedPassword = await bcrypt.hash(initialAdminPassword, 12);
|
||||||
|
|
||||||
admin = await prisma.user.upsert({
|
admin = await prisma.user.upsert({
|
||||||
where: { email: initialAdminEmail },
|
where: { email: initialAdminEmail },
|
||||||
@ -311,6 +311,21 @@ async function main() {
|
|||||||
buttonText: 'Buy Now',
|
buttonText: 'Buy Now',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'default-campaign-form',
|
||||||
|
type: 'campaign-form',
|
||||||
|
label: 'Campaign Email Form',
|
||||||
|
category: 'Influence',
|
||||||
|
sortOrder: 13,
|
||||||
|
schema: {
|
||||||
|
campaignSlug: { type: 'string', label: 'Campaign Slug', required: true },
|
||||||
|
compact: { type: 'boolean', label: 'Compact Mode', default: false },
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
campaignSlug: '',
|
||||||
|
compact: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'default-gancio-events',
|
id: 'default-gancio-events',
|
||||||
type: 'gancio-events',
|
type: 'gancio-events',
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const envSchema = z.object({
|
|||||||
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
||||||
|
|
||||||
// Encryption (for DB-stored secrets like SMTP password; falls back to JWT_ACCESS_SECRET)
|
// Encryption (for DB-stored secrets like SMTP password; falls back to JWT_ACCESS_SECRET)
|
||||||
ENCRYPTION_KEY: z.string().optional(),
|
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters').optional(),
|
||||||
|
|
||||||
// Initial Super Admin (auto-created during database seeding)
|
// Initial Super Admin (auto-created during database seeding)
|
||||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||||
|
|||||||
@ -196,17 +196,19 @@ router.post(
|
|||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Update password, mark token used, invalidate all refresh tokens
|
// Update password, mark token used, invalidate all refresh tokens — all in one transaction
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: result.userId },
|
where: { id: result.userId },
|
||||||
data: { password: hashedPassword },
|
data: { password: hashedPassword },
|
||||||
});
|
});
|
||||||
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
|
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
|
||||||
|
await tx.passwordResetToken.update({
|
||||||
|
where: { token },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await passwordResetTokenService.markTokenUsed(token);
|
|
||||||
|
|
||||||
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
|
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -13,8 +13,11 @@ const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN,
|
|||||||
export const docsAnalyticsPublicRouter = Router();
|
export const docsAnalyticsPublicRouter = Router();
|
||||||
|
|
||||||
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
|
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
|
||||||
|
import { env } from '../../config/env';
|
||||||
|
const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`;
|
||||||
docsAnalyticsPublicRouter.use((_req, res, next) => {
|
docsAnalyticsPublicRouter.use((_req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
next();
|
next();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { rm } from 'fs/promises';
|
import { rm } from 'fs/promises';
|
||||||
import { extname } from 'path';
|
import { extname, basename } from 'path';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
@ -172,6 +172,7 @@ const upload = multer({
|
|||||||
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
||||||
router.post(
|
router.post(
|
||||||
'/upload',
|
'/upload',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
upload.single('file'),
|
upload.single('file'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const tempPath = req.file?.path;
|
const tempPath = req.file?.path;
|
||||||
@ -183,7 +184,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetDir = (req.body as { path?: string }).path || '';
|
const targetDir = (req.body as { path?: string }).path || '';
|
||||||
const fileName = req.file.originalname;
|
const fileName = basename(req.file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||||
|
|
||||||
await docsFilesService.uploadFile(relativePath, req.file.path);
|
await docsFilesService.uploadFile(relativePath, req.file.path);
|
||||||
@ -223,6 +224,7 @@ router.get(
|
|||||||
// POST /api/docs/files/rename — rename/move file
|
// POST /api/docs/files/rename — rename/move file
|
||||||
router.post(
|
router.post(
|
||||||
'/files/rename',
|
'/files/rename',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'rename' });
|
cm_docs_operations.inc({ operation: 'rename' });
|
||||||
@ -261,6 +263,7 @@ router.get(
|
|||||||
// PUT /api/docs/files/* — write/update file content
|
// PUT /api/docs/files/* — write/update file content
|
||||||
router.put(
|
router.put(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'write' });
|
cm_docs_operations.inc({ operation: 'write' });
|
||||||
@ -285,6 +288,7 @@ router.put(
|
|||||||
// POST /api/docs/files/* — create new file or folder
|
// POST /api/docs/files/* — create new file or folder
|
||||||
router.post(
|
router.post(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'create' });
|
cm_docs_operations.inc({ operation: 'create' });
|
||||||
@ -305,6 +309,7 @@ router.post(
|
|||||||
// DELETE /api/docs/files/* — delete file or empty folder
|
// DELETE /api/docs/files/* — delete file or empty folder
|
||||||
router.delete(
|
router.delete(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'delete' });
|
cm_docs_operations.inc({ operation: 'delete' });
|
||||||
|
|||||||
@ -315,16 +315,26 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
// This is a placeholder - the actual seeding is done via the script
|
// This is a placeholder - the actual seeding is done via the script
|
||||||
// But we keep this endpoint for manual triggering if needed
|
// But we keep this endpoint for manual triggering if needed
|
||||||
const { exec } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { promisify } = require('util');
|
const child = spawn('npx', ['tsx', 'src/scripts/seed-email-templates.ts'], {
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const result = await execAsync('npx tsx src/scripts/seed-email-templates.ts', {
|
|
||||||
cwd: '/app',
|
cwd: '/app',
|
||||||
|
shell: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
child.on('close', (code: number) => {
|
||||||
|
exitCode = code;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`Seed script exited with code ${exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Email templates seeded via API');
|
logger.info('Email templates seeded via API');
|
||||||
res.json({ success: true, output: result.stdout });
|
res.json({ success: true, message: 'Templates seeded successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error seeding templates:', error);
|
logger.error('Error seeding templates:', error);
|
||||||
res.status(500).json({ error: 'Failed to seed templates' });
|
res.status(500).json({ error: 'Failed to seed templates' });
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { z } from 'zod';
|
|||||||
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
|
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
|
||||||
|
|
||||||
export const createCampaignSchema = z.object({
|
export const createCampaignSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
description: z.string().optional(),
|
description: z.string().max(2000).optional(),
|
||||||
emailSubject: z.string().min(1, 'Email subject is required'),
|
emailSubject: z.string().min(1, 'Email subject is required').max(200),
|
||||||
emailBody: z.string().min(1, 'Email body is required'),
|
emailBody: z.string().min(1, 'Email body is required').max(10000),
|
||||||
callToAction: z.string().optional(),
|
callToAction: z.string().max(500).optional(),
|
||||||
status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),
|
status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),
|
||||||
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),
|
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),
|
||||||
allowSmtpEmail: z.boolean().optional().default(true),
|
allowSmtpEmail: z.boolean().optional().default(true),
|
||||||
@ -18,15 +18,16 @@ export const createCampaignSchema = z.object({
|
|||||||
allowCustomRecipients: z.boolean().optional().default(false),
|
allowCustomRecipients: z.boolean().optional().default(false),
|
||||||
showResponseWall: z.boolean().optional().default(false),
|
showResponseWall: z.boolean().optional().default(false),
|
||||||
highlightCampaign: z.boolean().optional().default(false),
|
highlightCampaign: z.boolean().optional().default(false),
|
||||||
coverPhoto: z.string().optional(),
|
coverPhoto: z.string().url().max(500).optional(),
|
||||||
|
coverVideoId: z.number().int().positive().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateCampaignSchema = z.object({
|
export const updateCampaignSchema = z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).max(200).optional(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().max(2000).nullable().optional(),
|
||||||
emailSubject: z.string().min(1).optional(),
|
emailSubject: z.string().min(1).max(200).optional(),
|
||||||
emailBody: z.string().min(1).optional(),
|
emailBody: z.string().min(1).max(10000).optional(),
|
||||||
callToAction: z.string().nullable().optional(),
|
callToAction: z.string().max(500).nullable().optional(),
|
||||||
status: z.nativeEnum(CampaignStatus).optional(),
|
status: z.nativeEnum(CampaignStatus).optional(),
|
||||||
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional(),
|
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional(),
|
||||||
allowSmtpEmail: z.boolean().optional(),
|
allowSmtpEmail: z.boolean().optional(),
|
||||||
@ -38,7 +39,8 @@ export const updateCampaignSchema = z.object({
|
|||||||
allowCustomRecipients: z.boolean().optional(),
|
allowCustomRecipients: z.boolean().optional(),
|
||||||
showResponseWall: z.boolean().optional(),
|
showResponseWall: z.boolean().optional(),
|
||||||
highlightCampaign: z.boolean().optional(),
|
highlightCampaign: z.boolean().optional(),
|
||||||
coverPhoto: z.string().nullable().optional(),
|
coverPhoto: z.string().url().max(500).nullable().optional(),
|
||||||
|
coverVideoId: z.number().int().positive().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listCampaignsSchema = z.object({
|
export const listCampaignsSchema = z.object({
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const campaignSelect = {
|
|||||||
emailBody: true,
|
emailBody: true,
|
||||||
callToAction: true,
|
callToAction: true,
|
||||||
coverPhoto: true,
|
coverPhoto: true,
|
||||||
|
coverVideoId: true,
|
||||||
status: true,
|
status: true,
|
||||||
allowSmtpEmail: true,
|
allowSmtpEmail: true,
|
||||||
allowMailtoLink: true,
|
allowMailtoLink: true,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const effectivenessQuerySchema = z.object({
|
export const effectivenessQuerySchema = z.object({
|
||||||
campaignId: z.string().optional(),
|
campaignId: z.string().uuid().optional(),
|
||||||
dateFrom: z.string().datetime({ offset: true }).optional(),
|
dateFrom: z.string().datetime({ offset: true }).optional(),
|
||||||
dateTo: z.string().datetime({ offset: true }).optional(),
|
dateTo: z.string().datetime({ offset: true }).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -286,27 +286,30 @@ export const effectivenessService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For city/province grouping, we need to join with postal_code_cache
|
// For city/province grouping, we need to join with postal_code_cache
|
||||||
const groupCol = query.groupBy === 'province' ? 'pcc.province' : 'pcc.city';
|
const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city');
|
||||||
const dateClause = dateFilter
|
const campaignFilter = query.campaignId
|
||||||
? `AND ce."sentAt" ${dateFilter.gte ? `>= '${dateFilter.gte.toISOString()}'` : ''} ${dateFilter.lte ? `AND ce."sentAt" <= '${dateFilter.lte.toISOString()}'` : ''}`
|
? Prisma.sql`AND ce."campaignId" = ${query.campaignId}`
|
||||||
: '';
|
: Prisma.sql``;
|
||||||
const campaignClause = query.campaignId
|
const dateGteFilter = dateFilter?.gte
|
||||||
? `AND ce."campaignId" = '${query.campaignId}'`
|
? Prisma.sql`AND ce."sentAt" >= ${dateFilter.gte}`
|
||||||
: '';
|
: Prisma.sql``;
|
||||||
|
const dateLteFilter = dateFilter?.lte
|
||||||
|
? Prisma.sql`AND ce."sentAt" <= ${dateFilter.lte}`
|
||||||
|
: Prisma.sql``;
|
||||||
|
|
||||||
const rawResults = await prisma.$queryRawUnsafe<Array<{ key: string; email_count: bigint }>>(
|
const rawResults = await prisma.$queryRaw<Array<{ key: string; email_count: bigint }>>`
|
||||||
`SELECT ${groupCol} as key, COUNT(*) as email_count
|
SELECT ${groupCol} as key, COUNT(*) as email_count
|
||||||
FROM campaign_emails ce
|
FROM campaign_emails ce
|
||||||
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
|
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
|
||||||
WHERE ce."userPostalCode" IS NOT NULL
|
WHERE ce."userPostalCode" IS NOT NULL
|
||||||
AND ${groupCol} IS NOT NULL
|
AND ${groupCol} IS NOT NULL
|
||||||
${campaignClause}
|
${campaignFilter}
|
||||||
${dateClause}
|
${dateGteFilter}
|
||||||
GROUP BY ${groupCol}
|
${dateLteFilter}
|
||||||
ORDER BY email_count DESC
|
GROUP BY ${groupCol}
|
||||||
LIMIT $1`,
|
ORDER BY email_count DESC
|
||||||
query.limit,
|
LIMIT ${query.limit}
|
||||||
);
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupBy: query.groupBy,
|
groupBy: query.groupBy,
|
||||||
@ -337,20 +340,23 @@ export const effectivenessService = {
|
|||||||
if (query.campaignId) callWhere.campaignId = query.campaignId;
|
if (query.campaignId) callWhere.campaignId = query.campaignId;
|
||||||
if (dateFilter) callWhere.calledAt = dateFilter;
|
if (dateFilter) callWhere.calledAt = dateFilter;
|
||||||
|
|
||||||
// Build date clause for raw SQL
|
// Build parameterized conditions for unique participant count
|
||||||
const dateClauseParts: string[] = [];
|
const campaignFilter = query.campaignId
|
||||||
if (query.campaignId) dateClauseParts.push(`"campaignId" = '${query.campaignId}'`);
|
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
|
||||||
if (dateFilter?.gte) dateClauseParts.push(`"sentAt" >= '${dateFilter.gte.toISOString()}'`);
|
: Prisma.sql``;
|
||||||
if (dateFilter?.lte) dateClauseParts.push(`"sentAt" <= '${dateFilter.lte.toISOString()}'`);
|
const dateGteFilter = dateFilter?.gte
|
||||||
const rawWhereClause = dateClauseParts.length > 0
|
? Prisma.sql`AND "sentAt" >= ${dateFilter.gte}`
|
||||||
? `WHERE ${dateClauseParts.join(' AND ')}`
|
: Prisma.sql``;
|
||||||
: '';
|
const dateLteFilter = dateFilter?.lte
|
||||||
|
? Prisma.sql`AND "sentAt" <= ${dateFilter.lte}`
|
||||||
|
: Prisma.sql``;
|
||||||
|
|
||||||
const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([
|
const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([
|
||||||
prisma.campaignEmail.count({ where: emailWhere }),
|
prisma.campaignEmail.count({ where: emailWhere }),
|
||||||
prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
prisma.$queryRaw<[{ count: bigint }]>`
|
||||||
`SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails ${rawWhereClause}`,
|
SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails
|
||||||
),
|
WHERE 1=1 ${campaignFilter} ${dateGteFilter} ${dateLteFilter}
|
||||||
|
`,
|
||||||
prisma.representativeResponse.count({
|
prisma.representativeResponse.count({
|
||||||
where: { ...responseWhere, status: ResponseStatus.APPROVED },
|
where: { ...responseWhere, status: ResponseStatus.APPROVED },
|
||||||
}),
|
}),
|
||||||
@ -397,32 +403,29 @@ export const effectivenessService = {
|
|||||||
const from = dateFilter?.gte || defaultFrom;
|
const from = dateFilter?.gte || defaultFrom;
|
||||||
const to = dateFilter?.lte || new Date();
|
const to = dateFilter?.lte || new Date();
|
||||||
|
|
||||||
const campaignClause = query.campaignId
|
const truncFnSql = Prisma.raw(`'${truncFn}'`);
|
||||||
? `AND "campaignId" = '${query.campaignId}'`
|
const campaignFilter = query.campaignId
|
||||||
: '';
|
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
|
||||||
|
: Prisma.sql``;
|
||||||
|
|
||||||
const [emailTrends, responseTrends] = await Promise.all([
|
const [emailTrends, responseTrends] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
|
prisma.$queryRaw<Array<{ period: Date; count: bigint }>>`
|
||||||
`SELECT DATE_TRUNC('${truncFn}', "sentAt") as period, COUNT(*) as count
|
SELECT DATE_TRUNC(${truncFnSql}, "sentAt") as period, COUNT(*) as count
|
||||||
FROM campaign_emails
|
FROM campaign_emails
|
||||||
WHERE "sentAt" >= $1 AND "sentAt" <= $2
|
WHERE "sentAt" >= ${from} AND "sentAt" <= ${to}
|
||||||
${campaignClause}
|
${campaignFilter}
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC`,
|
ORDER BY period ASC
|
||||||
from,
|
`,
|
||||||
to,
|
prisma.$queryRaw<Array<{ period: Date; count: bigint }>>`
|
||||||
),
|
SELECT DATE_TRUNC(${truncFnSql}, "createdAt") as period, COUNT(*) as count
|
||||||
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
|
FROM representative_responses
|
||||||
`SELECT DATE_TRUNC('${truncFn}', "createdAt") as period, COUNT(*) as count
|
WHERE "createdAt" >= ${from} AND "createdAt" <= ${to}
|
||||||
FROM representative_responses
|
AND status = 'APPROVED'
|
||||||
WHERE "createdAt" >= $1 AND "createdAt" <= $2
|
${campaignFilter}
|
||||||
AND status = 'APPROVED'
|
GROUP BY period
|
||||||
${campaignClause}
|
ORDER BY period ASC
|
||||||
GROUP BY period
|
`,
|
||||||
ORDER BY period ASC`,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Merge into a single series with both email and response counts
|
// Merge into a single series with both email and response counts
|
||||||
|
|||||||
@ -2,14 +2,14 @@ import { z } from 'zod';
|
|||||||
import { GovernmentLevel, ResponseType, ResponseStatus } from '@prisma/client';
|
import { GovernmentLevel, ResponseType, ResponseStatus } from '@prisma/client';
|
||||||
|
|
||||||
export const submitResponseSchema = z.object({
|
export const submitResponseSchema = z.object({
|
||||||
representativeName: z.string().min(1, 'Representative name is required'),
|
representativeName: z.string().min(1, 'Representative name is required').max(200),
|
||||||
representativeLevel: z.nativeEnum(GovernmentLevel),
|
representativeLevel: z.nativeEnum(GovernmentLevel),
|
||||||
responseType: z.nativeEnum(ResponseType),
|
responseType: z.nativeEnum(ResponseType),
|
||||||
responseText: z.string().min(1, 'Response text is required'),
|
responseText: z.string().min(1, 'Response text is required').max(5000),
|
||||||
representativeTitle: z.string().optional(),
|
representativeTitle: z.string().max(200).optional(),
|
||||||
representativeEmail: z.string().email().optional(),
|
representativeEmail: z.string().email().optional(),
|
||||||
userComment: z.string().optional(),
|
userComment: z.string().max(1000).optional(),
|
||||||
submittedByName: z.string().optional(),
|
submittedByName: z.string().max(200).optional(),
|
||||||
submittedByEmail: z.string().email().optional(),
|
submittedByEmail: z.string().email().optional(),
|
||||||
isAnonymous: z.boolean().optional().default(false),
|
isAnonymous: z.boolean().optional().default(false),
|
||||||
sendVerification: z.boolean().optional().default(false),
|
sendVerification: z.boolean().optional().default(false),
|
||||||
|
|||||||
@ -152,8 +152,8 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate limiting check
|
// Rate limiting check — use IP for anonymous users to prevent header-based bypass
|
||||||
const rateLimitKey = userId || sessionId;
|
const rateLimitKey = userId || `ip:${request.ip}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timestamps = commentRateLimitMap.get(rateLimitKey) || [];
|
const timestamps = commentRateLimitMap.get(rateLimitKey) || [];
|
||||||
const recentTimestamps = timestamps.filter(
|
const recentTimestamps = timestamps.filter(
|
||||||
|
|||||||
@ -380,12 +380,15 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.code(401).send({ message: 'Current password is incorrect' });
|
return reply.code(401).send({ message: 'Current password is incorrect' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash and save new password
|
// Hash and save new password, invalidate all sessions
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||||
await prisma.user.update({
|
await prisma.$transaction([
|
||||||
where: { id: userId },
|
prisma.user.update({
|
||||||
data: { password: hashedPassword },
|
where: { id: userId },
|
||||||
});
|
data: { password: hashedPassword },
|
||||||
|
}),
|
||||||
|
prisma.refreshToken.deleteMany({ where: { userId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return reply.send({ message: 'Password updated successfully' });
|
return reply.send({ message: 'Password updated successfully' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { logger } from '../../../utils/logger';
|
|||||||
import { sign } from 'jsonwebtoken';
|
import { sign } from 'jsonwebtoken';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { copyFile } from 'fs/promises';
|
import { copyFile } from 'fs/promises';
|
||||||
import { join, dirname, basename, extname } from 'path';
|
import { join, dirname, basename, extname, normalize } from 'path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const UpdateVideoSchema = z.object({
|
const UpdateVideoSchema = z.object({
|
||||||
@ -149,16 +149,18 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
|||||||
* Replace video file while keeping metadata and URL
|
* Replace video file while keeping metadata and URL
|
||||||
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
|
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
|
||||||
*/
|
*/
|
||||||
|
const ReplaceVideoSchema = z.object({
|
||||||
|
newPath: z.string().min(1).max(500),
|
||||||
|
newFilename: z.string().min(1).max(255),
|
||||||
|
durationSeconds: z.number().optional(),
|
||||||
|
width: z.number().int().optional(),
|
||||||
|
height: z.number().int().optional(),
|
||||||
|
fileSize: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: {
|
Body: z.infer<typeof ReplaceVideoSchema>;
|
||||||
newPath: string;
|
|
||||||
newFilename: string;
|
|
||||||
durationSeconds?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
fileSize?: number;
|
|
||||||
};
|
|
||||||
}>(
|
}>(
|
||||||
'/:id/replace',
|
'/:id/replace',
|
||||||
{
|
{
|
||||||
@ -166,7 +168,23 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const videoId = parseInt(request.params.id);
|
const videoId = parseInt(request.params.id);
|
||||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
|
|
||||||
|
// Validate input with Zod
|
||||||
|
const parseResult = ReplaceVideoSchema.safeParse(request.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return reply.code(400).send({ message: 'Invalid input' });
|
||||||
|
}
|
||||||
|
const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data;
|
||||||
|
|
||||||
|
// Path traversal protection
|
||||||
|
if (newPath.includes('\0') || newFilename.includes('\0')) {
|
||||||
|
return reply.code(400).send({ message: 'Invalid file path' });
|
||||||
|
}
|
||||||
|
const normalizedPath = normalize(newPath);
|
||||||
|
if (normalizedPath.includes('..') || normalizedPath.startsWith('/') || normalizedPath.startsWith('\\')) {
|
||||||
|
return reply.code(400).send({ message: 'Invalid file path: must be relative with no traversal' });
|
||||||
|
}
|
||||||
|
const sanitizedFilename = basename(newFilename);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingVideo = await prisma.video.findUnique({
|
const existingVideo = await prisma.video.findUnique({
|
||||||
@ -181,8 +199,8 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
|||||||
const updatedVideo = await prisma.video.update({
|
const updatedVideo = await prisma.video.update({
|
||||||
where: { id: videoId },
|
where: { id: videoId },
|
||||||
data: {
|
data: {
|
||||||
path: newPath,
|
path: normalizedPath,
|
||||||
filename: newFilename,
|
filename: sanitizedFilename,
|
||||||
originalPath: existingVideo.path, // Save old path for reference
|
originalPath: existingVideo.path, // Save old path for reference
|
||||||
originalFilename: existingVideo.filename,
|
originalFilename: existingVideo.filename,
|
||||||
durationSeconds: durationSeconds || existingVideo.durationSeconds,
|
durationSeconds: durationSeconds || existingVideo.durationSeconds,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchem
|
|||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||||
|
|
||||||
@ -13,6 +14,34 @@ const router = Router();
|
|||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...ADMIN_ROLES));
|
||||||
|
|
||||||
|
// GET /api/pages/view-counts — landing page view counts (last 30d)
|
||||||
|
router.get(
|
||||||
|
'/view-counts',
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const since = new Date();
|
||||||
|
since.setDate(since.getDate() - 30);
|
||||||
|
const rows = await prisma.docsPageView.groupBy({
|
||||||
|
by: ['path'],
|
||||||
|
where: {
|
||||||
|
path: { startsWith: '/p/' },
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
// Extract slug from /p/:slug
|
||||||
|
const slug = row.path.replace(/^\/p\//, '');
|
||||||
|
counts[slug] = row._count.id;
|
||||||
|
}
|
||||||
|
res.json(counts);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/pages/sync — sync MkDocs overrides (must be before /:id routes)
|
// POST /api/pages/sync — sync MkDocs overrides (must be before /:id routes)
|
||||||
router.post(
|
router.post(
|
||||||
'/sync',
|
'/sync',
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import RedisStore from 'rate-limit-redis';
|
import RedisStore from 'rate-limit-redis';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
@ -954,10 +955,24 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/pangolin/resource/:id — Update a resource
|
// PUT /api/pangolin/resource/:id — Update a resource
|
||||||
|
const updateResourceSchema = z.object({
|
||||||
|
name: z.string().max(200).optional(),
|
||||||
|
subdomain: z.string().max(200).optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
|
blockAccess: z.boolean().optional(),
|
||||||
|
proxyPort: z.number().int().optional(),
|
||||||
|
protocol: z.string().max(20).optional(),
|
||||||
|
domainId: z.string().max(200).optional(),
|
||||||
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
http: z.boolean().optional(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
router.put('/resource/:id', async (req: Request, res: Response) => {
|
router.put('/resource/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const resourceId = req.params.id as string;
|
const resourceId = req.params.id as string;
|
||||||
const resource = await pangolinClient.updateResource(resourceId, req.body);
|
const body = updateResourceSchema.parse(req.body);
|
||||||
|
const resource = await pangolinClient.updateResource(resourceId, body);
|
||||||
res.json({ success: true, resource });
|
res.json({ success: true, resource });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
@ -979,10 +994,16 @@ router.get('/certificate/:domainId/:domain', async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/pangolin/certificate/:certId — Update certificate
|
// POST /api/pangolin/certificate/:certId — Update certificate
|
||||||
|
const updateCertificateSchema = z.object({
|
||||||
|
autoRenew: z.boolean().optional(),
|
||||||
|
isWildcard: z.boolean().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
router.post('/certificate/:certId', async (req: Request, res: Response) => {
|
router.post('/certificate/:certId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const certId = req.params.certId as string;
|
const certId = req.params.certId as string;
|
||||||
const certificate = await pangolinClient.updateCertificate(certId, req.body);
|
const body = updateCertificateSchema.parse(req.body);
|
||||||
|
const certificate = await pangolinClient.updateCertificate(certId, body);
|
||||||
res.json({ success: true, certificate });
|
res.json({ success: true, certificate });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
|
|||||||
import { getStripe, getWebhookSecret } from '../../services/stripe.client';
|
import { getStripe, getWebhookSecret } from '../../services/stripe.client';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { paymentEmailService } from './payment-email.service';
|
import { paymentEmailService } from './payment-email.service';
|
||||||
|
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
||||||
|
|
||||||
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
|
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
|
||||||
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||||
@ -130,6 +131,18 @@ export const webhookService = {
|
|||||||
stripeSubscriptionId: subscriptionId,
|
stripeSubscriptionId: subscriptionId,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync to Listmonk Subscribers list (fire-and-forget)
|
||||||
|
const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
|
||||||
|
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } });
|
||||||
|
if (subUser) {
|
||||||
|
listmonkEventSyncService.onSubscriptionActivated({
|
||||||
|
email: subUser.email,
|
||||||
|
name: subUser.name || '',
|
||||||
|
planName: plan?.name || `Plan ${planId}`,
|
||||||
|
subscriptionId,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleProductCheckout(session: Stripe.Checkout.Session) {
|
async handleProductCheckout(session: Stripe.Checkout.Session) {
|
||||||
@ -185,6 +198,17 @@ export const webhookService = {
|
|||||||
completedAt: updatedOrder.completedAt,
|
completedAt: updatedOrder.completedAt,
|
||||||
product: updatedOrder.product,
|
product: updatedOrder.product,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync to Listmonk Donors list (fire-and-forget)
|
||||||
|
if (updatedOrder.buyerEmail) {
|
||||||
|
listmonkEventSyncService.onProductPurchased({
|
||||||
|
email: updatedOrder.buyerEmail,
|
||||||
|
name: updatedOrder.buyerName || '',
|
||||||
|
productTitle: updatedOrder.product?.title || 'Product',
|
||||||
|
amountCents: updatedOrder.amountCAD,
|
||||||
|
orderId: updatedOrder.id,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -228,6 +252,16 @@ export const webhookService = {
|
|||||||
isAnonymous: order.isAnonymous,
|
isAnonymous: order.isAnonymous,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync to Listmonk Donors list (fire-and-forget)
|
||||||
|
if (order.buyerEmail) {
|
||||||
|
listmonkEventSyncService.onDonationCompleted({
|
||||||
|
email: order.buyerEmail,
|
||||||
|
name: order.buyerName || '',
|
||||||
|
amountCents: order.amountCAD,
|
||||||
|
orderId: order.id,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleInvoicePaid(invoice: Stripe.Invoice) {
|
async handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||||
|
|||||||
@ -145,6 +145,12 @@ export const usersService = {
|
|||||||
select: userSelect,
|
select: userSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate sessions when user is deactivated
|
||||||
|
const deactivatedStatuses = ['INACTIVE', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
|
||||||
|
if (data.status && deactivatedStatuses.includes(data.status)) {
|
||||||
|
await prisma.refreshToken.deleteMany({ where: { userId: id } });
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export const volunteerInviteService = {
|
|||||||
|
|
||||||
// 4. Create new TEMP user with random password (never shown to user)
|
// 4. Create new TEMP user with random password (never shown to user)
|
||||||
const randomPassword = crypto.randomBytes(16).toString('hex');
|
const randomPassword = crypto.randomBytes(16).toString('hex');
|
||||||
const hashedPassword = await bcrypt.hash(randomPassword, 10);
|
const hashedPassword = await bcrypt.hash(randomPassword, 12);
|
||||||
|
|
||||||
const newUser = await prisma.user.create({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { prisma } from './config/database';
|
|||||||
import { redis } from './config/redis';
|
import { redis } from './config/redis';
|
||||||
import { register, httpRequestDuration, httpRequestsTotal } from './utils/metrics';
|
import { register, httpRequestDuration, httpRequestsTotal } from './utils/metrics';
|
||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
|
import { authenticate } from './middleware/auth.middleware';
|
||||||
|
import { requireRole } from './middleware/rbac.middleware';
|
||||||
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
||||||
import { authRouter } from './modules/auth/auth.routes';
|
import { authRouter } from './modules/auth/auth.routes';
|
||||||
import { usersRouter } from './modules/users/users.routes';
|
import { usersRouter } from './modules/users/users.routes';
|
||||||
@ -139,8 +141,8 @@ app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
|
|||||||
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
|
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Metrics Endpoint ---
|
// --- Metrics Endpoint (authenticated - SUPER_ADMIN only) ---
|
||||||
app.get('/api/metrics', healthMetricsRateLimit, async (_req, res) => {
|
app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsRateLimit, async (_req, res) => {
|
||||||
res.set('Content-Type', register.contentType);
|
res.set('Content-Type', register.contentType);
|
||||||
res.end(await register.metrics());
|
res.end(await register.metrics());
|
||||||
});
|
});
|
||||||
@ -214,8 +216,16 @@ async function start() {
|
|||||||
if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
|
if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
|
||||||
throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
|
throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
|
||||||
}
|
}
|
||||||
|
if (!env.ENCRYPTION_KEY) {
|
||||||
|
logger.warn('ENCRYPTION_KEY not set — falling back to JWT_ACCESS_SECRET for encryption. Set ENCRYPTION_KEY in production.');
|
||||||
|
}
|
||||||
initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
|
initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
|
||||||
|
|
||||||
|
// Warn if Listmonk sync is enabled but webhook secret is not configured
|
||||||
|
if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) {
|
||||||
|
logger.warn('LISTMONK_SYNC_ENABLED is true but LISTMONK_WEBHOOK_SECRET is not set. Unsubscribe events from Listmonk will not be processed.');
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
||||||
await emailService.rebuildTransporter();
|
await emailService.rebuildTransporter();
|
||||||
|
|
||||||
|
|||||||
@ -137,6 +137,113 @@ class ListmonkEventSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync an activated subscription to Listmonk "All Contacts" + "Subscribers" lists.
|
||||||
|
*/
|
||||||
|
async onSubscriptionActivated(data: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
planName: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listmonkSyncService.ensureInitialized();
|
||||||
|
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||||
|
const subscribersId = listmonkSyncService.getListId('Subscribers');
|
||||||
|
if (!allContactsId || !subscribersId) return;
|
||||||
|
|
||||||
|
await listmonkClient.upsertSubscriber(
|
||||||
|
data.email,
|
||||||
|
data.name,
|
||||||
|
[allContactsId, subscribersId],
|
||||||
|
{
|
||||||
|
source: 'subscription',
|
||||||
|
plan_name: data.planName,
|
||||||
|
subscription_id: data.subscriptionId,
|
||||||
|
last_synced: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.incrementCounter();
|
||||||
|
logger.debug(`Listmonk event sync: subscription activated for ${data.email}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Listmonk event sync failed (onSubscriptionActivated):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a completed donation to Listmonk "All Contacts" + "Donors" lists.
|
||||||
|
*/
|
||||||
|
async onDonationCompleted(data: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
amountCents: number;
|
||||||
|
orderId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listmonkSyncService.ensureInitialized();
|
||||||
|
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||||
|
const donorsId = listmonkSyncService.getListId('Donors');
|
||||||
|
if (!allContactsId || !donorsId) return;
|
||||||
|
|
||||||
|
await listmonkClient.upsertSubscriber(
|
||||||
|
data.email,
|
||||||
|
data.name,
|
||||||
|
[allContactsId, donorsId],
|
||||||
|
{
|
||||||
|
source: 'donation',
|
||||||
|
last_donation_amount: data.amountCents,
|
||||||
|
last_order_id: data.orderId,
|
||||||
|
last_synced: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.incrementCounter();
|
||||||
|
logger.debug(`Listmonk event sync: donation completed for ${data.email}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Listmonk event sync failed (onDonationCompleted):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a product purchase to Listmonk "All Contacts" + "Donors" lists.
|
||||||
|
*/
|
||||||
|
async onProductPurchased(data: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
productTitle: string;
|
||||||
|
amountCents: number;
|
||||||
|
orderId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listmonkSyncService.ensureInitialized();
|
||||||
|
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||||
|
const donorsId = listmonkSyncService.getListId('Donors');
|
||||||
|
if (!allContactsId || !donorsId) return;
|
||||||
|
|
||||||
|
await listmonkClient.upsertSubscriber(
|
||||||
|
data.email,
|
||||||
|
data.name,
|
||||||
|
[allContactsId, donorsId],
|
||||||
|
{
|
||||||
|
source: 'product_purchase',
|
||||||
|
last_product: data.productTitle,
|
||||||
|
last_purchase_amount: data.amountCents,
|
||||||
|
last_order_id: data.orderId,
|
||||||
|
last_synced: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.incrementCounter();
|
||||||
|
logger.debug(`Listmonk event sync: product purchased for ${data.email}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Listmonk event sync failed (onProductPurchased):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getStats(): {
|
getStats(): {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
|
|||||||
@ -17,6 +17,8 @@ const LIST_DEFINITIONS: Array<{ name: string; tags: string[] }> = [
|
|||||||
{ name: 'Users', tags: ['v2', 'users'] },
|
{ name: 'Users', tags: ['v2', 'users'] },
|
||||||
{ name: 'Volunteers', tags: ['v2', 'map', 'shifts'] },
|
{ name: 'Volunteers', tags: ['v2', 'map', 'shifts'] },
|
||||||
{ name: 'Canvassers', tags: ['v2', 'map', 'canvass'] },
|
{ name: 'Canvassers', tags: ['v2', 'map', 'canvass'] },
|
||||||
|
{ name: 'Subscribers', tags: ['v2', 'payments'] },
|
||||||
|
{ name: 'Donors', tags: ['v2', 'payments'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
|
const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
|
||||||
|
|||||||
@ -131,7 +131,8 @@ class ListmonkClient {
|
|||||||
async findSubscriberByEmail(email: string): Promise<ListmonkSubscriber | null> {
|
async findSubscriberByEmail(email: string): Promise<ListmonkSubscriber | null> {
|
||||||
this.assertEnabled();
|
this.assertEnabled();
|
||||||
try {
|
try {
|
||||||
const query = encodeURIComponent(`subscribers.email='${email}'`);
|
const safeEmail = email.replace(/'/g, "''");
|
||||||
|
const query = encodeURIComponent(`subscribers.email='${safeEmail}'`);
|
||||||
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
|
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
|
||||||
'GET',
|
'GET',
|
||||||
`/api/subscribers?query=${query}&per_page=1`,
|
`/api/subscribers?query=${query}&per_page=1`,
|
||||||
|
|||||||
1
changemaker-control-panel
Submodule
1
changemaker-control-panel
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit d4cd2d2cd5d2dd33d49c7d6feaed975741e0925a
|
||||||
@ -11,7 +11,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
target: development
|
target: ${BUILD_TARGET:-development}
|
||||||
container_name: changemaker-v2-api
|
container_name: changemaker-v2-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@ -59,7 +59,7 @@ services:
|
|||||||
- PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT:-}
|
- PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT:-}
|
||||||
- PANGOLIN_NEWT_ID=${PANGOLIN_NEWT_ID:-}
|
- PANGOLIN_NEWT_ID=${PANGOLIN_NEWT_ID:-}
|
||||||
- PANGOLIN_NEWT_SECRET=${PANGOLIN_NEWT_SECRET:-}
|
- PANGOLIN_NEWT_SECRET=${PANGOLIN_NEWT_SECRET:-}
|
||||||
- NODE_TLS_REJECT_UNAUTHORIZED=${NODE_TLS_REJECT_UNAUTHORIZED:-}
|
# NODE_TLS_REJECT_UNAUTHORIZED removed — never disable TLS validation globally
|
||||||
- EXCALIDRAW_URL=${EXCALIDRAW_URL:-http://excalidraw-changemaker:80}
|
- EXCALIDRAW_URL=${EXCALIDRAW_URL:-http://excalidraw-changemaker:80}
|
||||||
- EXCALIDRAW_PORT=${EXCALIDRAW_PORT:-8090}
|
- EXCALIDRAW_PORT=${EXCALIDRAW_PORT:-8090}
|
||||||
- EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886}
|
- EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886}
|
||||||
@ -84,7 +84,15 @@ services:
|
|||||||
- ./mkdocs:/mkdocs:rw
|
- ./mkdocs:/mkdocs:rw
|
||||||
- ./data:/data:ro
|
- ./data:/data:ro
|
||||||
- ./configs:/app/configs:ro
|
- ./configs:/app/configs:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
# Docker socket access removed for security — use docker-socket-proxy if container info needed
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 256M
|
||||||
depends_on:
|
depends_on:
|
||||||
v2-postgres:
|
v2-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -98,7 +106,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: Dockerfile.media
|
dockerfile: Dockerfile.media
|
||||||
target: development
|
target: ${BUILD_TARGET:-development}
|
||||||
container_name: changemaker-media-api
|
container_name: changemaker-media-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@ -129,6 +137,14 @@ services:
|
|||||||
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
|
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
|
||||||
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
|
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
|
||||||
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
|
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 256M
|
||||||
depends_on:
|
depends_on:
|
||||||
v2-postgres:
|
v2-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -139,7 +155,7 @@ services:
|
|||||||
admin:
|
admin:
|
||||||
build:
|
build:
|
||||||
context: ./admin
|
context: ./admin
|
||||||
target: development
|
target: ${BUILD_TARGET:-development}
|
||||||
container_name: changemaker-v2-admin
|
container_name: changemaker-v2-admin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@ -240,7 +256,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NC_DB: "pg://changemaker-v2-postgres:5432?u=${V2_POSTGRES_USER:-changemaker}&p=${V2_POSTGRES_PASSWORD:-changemaker}&d=nocodb_meta"
|
NC_DB: "pg://changemaker-v2-postgres:5432?u=${V2_POSTGRES_USER:-changemaker}&p=${V2_POSTGRES_PASSWORD:-changemaker}&d=nocodb_meta"
|
||||||
NC_ADMIN_EMAIL: ${NC_ADMIN_EMAIL:-admin@cmlite.org}
|
NC_ADMIN_EMAIL: ${NC_ADMIN_EMAIL:-admin@cmlite.org}
|
||||||
NC_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD:-admin123}
|
NC_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD:?NC_ADMIN_PASSWORD must be set in .env}
|
||||||
NC_PUBLIC_URL: ${NC_PUBLIC_URL:-http://localhost:8091}
|
NC_PUBLIC_URL: ${NC_PUBLIC_URL:-http://localhost:8091}
|
||||||
volumes:
|
volumes:
|
||||||
- nocodb-v2-data:/usr/app/data
|
- nocodb-v2-data:/usr/app/data
|
||||||
@ -260,7 +276,7 @@ services:
|
|||||||
container_name: redis-changemaker
|
container_name: redis-changemaker
|
||||||
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
|
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "127.0.0.1:6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
restart: always
|
restart: always
|
||||||
@ -492,10 +508,10 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- WEBHOOK_URL=https://${N8N_HOST:-n8n.cmlite.org}/
|
- WEBHOOK_URL=https://${N8N_HOST:-n8n.cmlite.org}/
|
||||||
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
|
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
|
||||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-changeMe}
|
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:?N8N_ENCRYPTION_KEY must be set in .env}
|
||||||
- N8N_USER_MANAGEMENT_DISABLED=false
|
- N8N_USER_MANAGEMENT_DISABLED=false
|
||||||
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
|
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
|
||||||
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
|
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:?N8N_USER_PASSWORD must be set in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- n8n-data:/home/node/.n8n
|
- n8n-data:/home/node/.n8n
|
||||||
- ./local-files:/files
|
- ./local-files:/files
|
||||||
@ -512,7 +528,7 @@ services:
|
|||||||
- ./configs/homepage:/app/config
|
- ./configs/homepage:/app/config
|
||||||
- ./assets/icons:/app/public/icons
|
- ./assets/icons:/app/public/icons
|
||||||
- ./assets/images:/app/public/images
|
- ./assets/images:/app/public/images
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
# Docker socket access removed for security — configure homepage widgets via config files instead
|
||||||
environment:
|
environment:
|
||||||
- PUID=${USER_ID:-1000}
|
- PUID=${USER_ID:-1000}
|
||||||
- PGID=${DOCKER_GROUP_ID:-984}
|
- PGID=${DOCKER_GROUP_ID:-984}
|
||||||
@ -728,7 +744,7 @@ services:
|
|||||||
- ADMIN_USERNAME=${ROCKETCHAT_ADMIN_USER:-rcadmin}
|
- ADMIN_USERNAME=${ROCKETCHAT_ADMIN_USER:-rcadmin}
|
||||||
- ADMIN_NAME=Admin
|
- ADMIN_NAME=Admin
|
||||||
- ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
- ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
||||||
- ADMIN_PASS=${ROCKETCHAT_ADMIN_PASSWORD:-changeme}
|
- ADMIN_PASS=${ROCKETCHAT_ADMIN_PASSWORD:?ROCKETCHAT_ADMIN_PASSWORD must be set in .env}
|
||||||
- CREATE_TOKENS_FOR_USERS=true
|
- CREATE_TOKENS_FOR_USERS=true
|
||||||
- OVERWRITE_SETTING_Iframe_Integration_send_enable=true
|
- OVERWRITE_SETTING_Iframe_Integration_send_enable=true
|
||||||
- OVERWRITE_SETTING_Iframe_Integration_receive_enable=true
|
- OVERWRITE_SETTING_Iframe_Integration_receive_enable=true
|
||||||
@ -801,6 +817,32 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite
|
||||||
|
|
||||||
|
# Gancio Init — Seeds default theme settings after Gancio creates its tables
|
||||||
|
# Runs once after Gancio is healthy, then exits. Idempotent (ON CONFLICT DO NOTHING).
|
||||||
|
gancio-init:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: gancio-init
|
||||||
|
depends_on:
|
||||||
|
gancio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- PGHOST=changemaker-v2-postgres
|
||||||
|
- PGUSER=${V2_POSTGRES_USER:-changemaker}
|
||||||
|
- PGPASSWORD=${V2_POSTGRES_PASSWORD:-changemaker}
|
||||||
|
- PGDATABASE=gancio
|
||||||
|
entrypoint: ["sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
echo "Seeding Gancio default theme settings..."
|
||||||
|
psql -c "INSERT INTO settings (key, value, is_secret, \"createdAt\", \"updatedAt\") VALUES
|
||||||
|
('dark_colors', '{\"primary\": \"#FF6E40\", \"error\": \"#FF5252\", \"info\": \"#2196F3\", \"success\": \"#4CAF50\", \"warning\": \"#FB8C00\"}', false, NOW(), NOW()),
|
||||||
|
('light_colors', '{\"primary\": \"#FF4500\", \"error\": \"#FF5252\", \"info\": \"#2196F3\", \"success\": \"#4CAF50\", \"warning\": \"#FB8C00\"}', false, NOW(), NOW())
|
||||||
|
ON CONFLICT (key) DO NOTHING;"
|
||||||
|
echo "Gancio theme settings seeded."
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- changemaker-lite
|
||||||
|
|
||||||
# MailHog — Email testing (dev)
|
# MailHog — Email testing (dev)
|
||||||
mailhog:
|
mailhog:
|
||||||
image: mailhog/mailhog:latest
|
image: mailhog/mailhog:latest
|
||||||
@ -863,7 +905,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${GRAFANA_PORT:-3001}:3000"
|
- "${GRAFANA_PORT:-3001}:3000"
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set in .env}
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
|
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
|
||||||
- GF_SECURITY_ALLOW_EMBEDDING=true
|
- GF_SECURITY_ALLOW_EMBEDDING=true
|
||||||
@ -958,7 +1000,7 @@ services:
|
|||||||
- "${GOTIFY_PORT:-8889}:80"
|
- "${GOTIFY_PORT:-8889}:80"
|
||||||
environment:
|
environment:
|
||||||
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
|
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
|
||||||
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}
|
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:?GOTIFY_ADMIN_PASSWORD must be set in .env}
|
||||||
- TZ=Etc/UTC
|
- TZ=Etc/UTC
|
||||||
volumes:
|
volumes:
|
||||||
- gotify-data:/app/data
|
- gotify-data:/app/data
|
||||||
|
|||||||
@ -1430,8 +1430,8 @@
|
|||||||
|
|
||||||
.hero { min-height: auto; padding-top: calc(var(--header-height) + 2rem); padding-bottom: 3rem; }
|
.hero { min-height: auto; padding-top: calc(var(--header-height) + 2rem); padding-bottom: 3rem; }
|
||||||
.hero h1 { font-size: 2rem; }
|
.hero h1 { font-size: 2rem; }
|
||||||
.hero-root-glow { width: 300px; height: 300px; top: auto; bottom: 0; }
|
.hero-root-glow { width: 250px; height: 250px; top: 50%; bottom: auto; }
|
||||||
.hero-root-svg { top: auto; bottom: 0; transform: translate(-50%, 20%); }
|
.hero-root-svg { top: 50%; bottom: auto; transform: translate(-50%, -50%); width: 250px; height: 250px; }
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@ -1449,7 +1449,6 @@
|
|||||||
.branch { padding-left: 0; }
|
.branch { padding-left: 0; }
|
||||||
.root-network-svg { display: none; }
|
.root-network-svg { display: none; }
|
||||||
.floating-elements { display: none; }
|
.floating-elements { display: none; }
|
||||||
.hero-root-svg { width: 300px; height: 300px; }
|
|
||||||
|
|
||||||
.sites-grid {
|
.sites-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -16,6 +16,8 @@ http {
|
|||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
tcp_nodelay on;
|
tcp_nodelay on;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user