From 7352815e57675b64f74bbcf7f741a827f7be6d0e Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Sat, 21 Feb 2026 11:46:55 -0700 Subject: [PATCH] More control panel updates --- admin/index.html | 2 +- admin/src/components/GrapesJSEditor.tsx | 20 ++ admin/src/components/MediaPublicLayout.tsx | 4 +- admin/src/components/QrCodeModal.tsx | 89 +++++ .../canvass/ExportContactsModal.tsx | 6 +- .../canvass/HistoricalRoutesDrawer.tsx | 6 +- .../email-templates/TestEmailModal.tsx | 6 +- .../email-templates/VersionHistoryDrawer.tsx | 6 +- .../email-templates/VideoVariableEditor.tsx | 6 +- .../influence/CampaignFormWidget.tsx | 330 ++++++++++++++++++ .../src/components/map/CutOverlayControls.tsx | 8 +- admin/src/components/map/MapLegend.tsx | 2 +- .../components/media/AlbumDetailDrawer.tsx | 6 +- .../components/media/EditPlaylistModal.tsx | 6 +- .../components/media/FetchVideosDrawer.tsx | 5 +- admin/src/components/media/MediaBottomNav.tsx | 3 +- .../src/components/media/PhotoViewerModal.tsx | 7 +- .../src/components/media/PublicVideoCard.tsx | 24 +- .../components/media/QuickAnalyticsModal.tsx | 6 +- .../media/ScheduleCalendarDrawer.tsx | 6 +- .../components/media/SchedulePublishModal.tsx | 6 +- .../components/media/UploadVideoDrawer.tsx | 5 +- .../components/media/VideoAnalyticsModal.tsx | 6 +- .../src/components/media/VideoPickerModal.tsx | 5 +- .../components/payments/DonateInsertModal.tsx | 6 +- .../payments/ProductInsertModal.tsx | 6 +- admin/src/components/shifts/EditModeModal.tsx | 6 +- admin/src/pages/CampaignEmailsDrawer.tsx | 7 +- admin/src/pages/CampaignsPage.tsx | 93 ++++- admin/src/pages/CutsPage.tsx | 22 +- admin/src/pages/EmailTemplatesPage.tsx | 5 +- admin/src/pages/LandingPagesPage.tsx | 54 ++- admin/src/pages/ListmonkPage.tsx | 1 + admin/src/pages/LocationsPage.tsx | 14 +- admin/src/pages/PangolinPage.tsx | 6 +- admin/src/pages/RepresentativesPage.tsx | 6 +- admin/src/pages/ResponsesPage.tsx | 5 +- admin/src/pages/ShiftsPage.tsx | 24 +- admin/src/pages/UsersPage.tsx | 19 +- .../influence/CampaignModerationPage.tsx | 5 +- .../src/pages/media/CommentModerationPage.tsx | 5 +- admin/src/pages/media/GalleryAdsPage.tsx | 9 +- admin/src/pages/media/LibraryPage.tsx | 6 +- admin/src/pages/payments/ProductsPage.tsx | 6 +- admin/src/pages/payments/SubscribersPage.tsx | 6 +- admin/src/pages/public/CampaignPage.tsx | 13 + admin/src/pages/public/LandingPage.tsx | 53 +++ admin/src/pages/public/MapPage.tsx | 6 +- admin/src/types/api.ts | 3 + api/Dockerfile | 5 +- api/docker-entrypoint.sh | 24 +- api/prisma/schema.prisma | 1 + api/prisma/seed.ts | 17 +- api/src/config/env.ts | 2 +- api/src/modules/auth/auth.routes.ts | 8 +- .../docs-analytics/docs-analytics.routes.ts | 5 +- api/src/modules/docs/docs.routes.ts | 9 +- .../email-templates-admin.routes.ts | 22 +- .../influence/campaigns/campaigns.schemas.ts | 26 +- .../influence/campaigns/campaigns.service.ts | 1 + .../effectiveness/effectiveness.schemas.ts | 2 +- .../effectiveness/effectiveness.service.ts | 113 +++--- .../influence/responses/responses.schemas.ts | 10 +- .../modules/media/routes/comments.routes.ts | 4 +- .../media/routes/user-profile.routes.ts | 13 +- .../media/routes/video-actions.routes.ts | 42 ++- api/src/modules/pages/pages-admin.routes.ts | 29 ++ api/src/modules/pangolin/pangolin.routes.ts | 25 +- api/src/modules/payments/webhook.service.ts | 34 ++ api/src/modules/users/users.service.ts | 6 + .../volunteer-invite.service.ts | 2 +- api/src/server.ts | 14 +- .../services/listmonk-event-sync.service.ts | 107 ++++++ api/src/services/listmonk-sync.service.ts | 2 + api/src/services/listmonk.client.ts | 3 +- changemaker-control-panel | 1 + docker-compose.yml | 68 +++- mkdocs/docs/overrides/lander.html | 5 +- nginx/nginx.conf | 2 + 79 files changed, 1318 insertions(+), 240 deletions(-) create mode 100644 admin/src/components/QrCodeModal.tsx create mode 100644 admin/src/components/influence/CampaignFormWidget.tsx create mode 160000 changemaker-control-panel diff --git a/admin/index.html b/admin/index.html index 3559425..3ab8eec 100644 --- a/admin/index.html +++ b/admin/index.html @@ -2,7 +2,7 @@ - + Changemaker Lite - Admin diff --git a/admin/src/components/GrapesJSEditor.tsx b/admin/src/components/GrapesJSEditor.tsx index bdc305d..d2eb3d8 100644 --- a/admin/src/components/GrapesJSEditor.tsx +++ b/admin/src/components/GrapesJSEditor.tsx @@ -348,6 +348,26 @@ function generateBlockHtml(type: string, defaults: Record): str `; } + case 'campaign-form': { + const campaignSlug = (defaults.campaignSlug as string) || ''; + const compact = defaults.compact === true; + return ` +
+
+
+ + + +

Campaign Email Form

+

${campaignSlug || 'Set campaign slug in block properties'}

+

Interactive form will render on published page

+
+
+
`; + } case 'gancio-events': { const maxlength = defaults.maxlength || 10; const evTheme = (defaults.theme as string) || 'dark'; diff --git a/admin/src/components/MediaPublicLayout.tsx b/admin/src/components/MediaPublicLayout.tsx index ba6ddeb..4711e3f 100644 --- a/admin/src/components/MediaPublicLayout.tsx +++ b/admin/src/components/MediaPublicLayout.tsx @@ -88,7 +88,7 @@ export default function MediaPublicLayout() { marginLeft: mainContentMarginLeft, minHeight: '100vh', 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', background: colorBgBase, }} @@ -97,7 +97,7 @@ export default function MediaPublicLayout() { style={{ width: '100%', margin: '0 auto', - padding: isMobile ? '8px 8px' : '12px 12px', + padding: isMobile ? '8px 12px' : '12px 12px', }} > diff --git a/admin/src/components/QrCodeModal.tsx b/admin/src/components/QrCodeModal.tsx new file mode 100644 index 0000000..419a0c3 --- /dev/null +++ b/admin/src/components/QrCodeModal.tsx @@ -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(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 ( + +
+ {loading && } + {`QR setLoading(false)} + onError={() => { setLoading(false); message.error('Failed to generate QR code'); }} + /> +
+ : } + onClick={handleCopyUrl} + /> + } + /> + + + + +
+
+
+ ); +} diff --git a/admin/src/components/canvass/ExportContactsModal.tsx b/admin/src/components/canvass/ExportContactsModal.tsx index 71fed03..b6c5fc4 100644 --- a/admin/src/components/canvass/ExportContactsModal.tsx +++ b/admin/src/components/canvass/ExportContactsModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { 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'; import { ExportOutlined, EyeOutlined } from '@ant-design/icons'; import { api } from '@/lib/api'; @@ -42,6 +42,8 @@ export default function ExportContactsModal({ const { message } = App.useApp(); const [form] = Form.useForm(); const [campaigns, setCampaigns] = useState([]); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [preview, setPreview] = useState(null); const [previewing, setPreviewing] = useState(false); const [exporting, setExporting] = useState(false); @@ -154,7 +156,7 @@ export default function ExportContactsModal({ title="Export Canvass Contacts to Campaign" open={open} onCancel={onClose} - width={640} + width={isMobile ? '95vw' : 640} footer={[ , + + + )} + + {/* Step: Representatives */} + {step === 'reps' && ( +
+ {allSent ? ( +
+
+

+ All messages sent! Thank you. +

+ +
+ ) : ( + <> +

+ {representatives.length} representative{representatives.length !== 1 ? 's' : ''} found for {postalCode.toUpperCase()} +

+
+ {representatives.map((rep, i) => ( +
+
+
{rep.name}
+
{rep.representativeSetName}
+
+
+ {sentTo.has(rep.email || '') ? ( + Sent ✓ + ) : ( + <> + {campaign.allowSmtpEmail && rep.email && ( + + )} + {campaign.allowMailtoLink && rep.email && ( + + )} + + )} +
+
+ ))} +
+ + + )} +
+ )} + + ); +} diff --git a/admin/src/components/map/CutOverlayControls.tsx b/admin/src/components/map/CutOverlayControls.tsx index 1666be4..3bd259e 100644 --- a/admin/src/components/map/CutOverlayControls.tsx +++ b/admin/src/components/map/CutOverlayControls.tsx @@ -1,4 +1,4 @@ -import { Checkbox, Button, Space } from 'antd'; +import { Checkbox, Button, Space, Grid } from 'antd'; import type { Cut, PublicCut } from '@/types/api'; const VARIANT_BG = { @@ -15,6 +15,8 @@ interface 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 noneVisible = cuts.every((c) => !visibleCutIds.has(c.id)); @@ -22,7 +24,7 @@ export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, v
(null); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [loading, setLoading] = useState(false); const [editing, setEditing] = useState(false); const [title, setTitle] = useState(''); @@ -122,7 +124,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: title={album?.title || 'Album Detail'} open={open} onClose={onClose} - width={600} + width={isMobile ? '100%' : 600} loading={loading} footer={
diff --git a/admin/src/components/media/EditPlaylistModal.tsx b/admin/src/components/media/EditPlaylistModal.tsx index 779eddd..215a9ed 100644 --- a/admin/src/components/media/EditPlaylistModal.tsx +++ b/admin/src/components/media/EditPlaylistModal.tsx @@ -1,5 +1,5 @@ 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 { mediaApi } from '@/lib/media-api'; import { mediaPublicApi } from '@/lib/media-public-api'; @@ -29,6 +29,8 @@ export default function EditPlaylistModal({ }: EditPlaylistModalProps) { const [form] = Form.useForm(); const { token } = theme.useToken(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [videos, setVideos] = useState([]); @@ -126,7 +128,7 @@ export default function EditPlaylistModal({ onClose(); }} placement="right" - width={520} + width={isMobile ? '100%' : 520} style={{ top: 64 }} loading={loading} > diff --git a/admin/src/components/media/FetchVideosDrawer.tsx b/admin/src/components/media/FetchVideosDrawer.tsx index fb8c496..fe858d0 100644 --- a/admin/src/components/media/FetchVideosDrawer.tsx +++ b/admin/src/components/media/FetchVideosDrawer.tsx @@ -13,6 +13,7 @@ import { Collapse, List, Tooltip, + Grid, } from 'antd'; import { CloudDownloadOutlined, @@ -76,6 +77,8 @@ const STATE_ICONS: Record = { export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) { const [urls, setUrls] = useState(''); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [submitting, setSubmitting] = useState(false); const [jobs, setJobs] = useState([]); const [expandedJobId, setExpandedJobId] = useState(null); @@ -292,7 +295,7 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid } open={open} onClose={onClose} - width={560} + width={isMobile ? '100%' : 560} destroyOnClose > {/* URL Input Section */} diff --git a/admin/src/components/media/MediaBottomNav.tsx b/admin/src/components/media/MediaBottomNav.tsx index 3602328..25e7219 100644 --- a/admin/src/components/media/MediaBottomNav.tsx +++ b/admin/src/components/media/MediaBottomNav.tsx @@ -66,7 +66,8 @@ export default function MediaBottomNav() { bottom: 0, left: 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, backdropFilter: isShorts ? 'blur(12px)' : undefined, borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`, diff --git a/admin/src/components/media/PhotoViewerModal.tsx b/admin/src/components/media/PhotoViewerModal.tsx index 2d4070f..0418829 100644 --- a/admin/src/components/media/PhotoViewerModal.tsx +++ b/admin/src/components/media/PhotoViewerModal.tsx @@ -1,4 +1,4 @@ -import { Modal, Descriptions, Tag } from 'antd'; +import { Modal, Descriptions, Tag, Grid } from 'antd'; import { CameraOutlined } from '@ant-design/icons'; import { getAuthCallbacks } from '@/lib/api'; import type { Photo } from '@/types/media'; @@ -19,6 +19,9 @@ interface PhotoViewerModalProps { } export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) { + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + if (!photo) return null; const adminImageUrl = `/media/photos/${photo.id}/image?size=large`; @@ -28,7 +31,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo open={open} onCancel={onClose} footer={null} - width={900} + width={isMobile ? '95vw' : 900} centered styles={{ body: { padding: 0 } }} > diff --git a/admin/src/components/media/PublicVideoCard.tsx b/admin/src/components/media/PublicVideoCard.tsx index d4a3237..241df07 100644 --- a/admin/src/components/media/PublicVideoCard.tsx +++ b/admin/src/components/media/PublicVideoCard.tsx @@ -1,5 +1,5 @@ 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 { useNavigate } from 'react-router-dom'; import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; @@ -27,6 +27,8 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) { const { token } = theme.useToken(); const navigate = useNavigate(); const { expandVideo } = useExpandedVideo(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; // Hover video preview state const [hovering, setHovering] = useState(false); @@ -210,7 +212,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
)} - {/* Play button overlay */} + {/* Play button overlay — always visible on mobile, hover-only on desktop */} {!video.isLocked && (
{ - e.currentTarget.style.opacity = '1'; + if (!isMobile) e.currentTarget.style.opacity = '1'; }} onMouseLeave={(e) => { - e.currentTarget.style.opacity = '0'; + if (!isMobile) e.currentTarget.style.opacity = '0'; }} >
{ - e.currentTarget.style.transform = 'scale(1.1)'; + if (!isMobile) e.currentTarget.style.transform = 'scale(1.1)'; }} onMouseLeave={(e) => { - e.currentTarget.style.transform = 'scale(1)'; + if (!isMobile) e.currentTarget.style.transform = 'scale(1)'; }} > - +
)} diff --git a/admin/src/components/media/QuickAnalyticsModal.tsx b/admin/src/components/media/QuickAnalyticsModal.tsx index 7a3b599..b5d4c95 100644 --- a/admin/src/components/media/QuickAnalyticsModal.tsx +++ b/admin/src/components/media/QuickAnalyticsModal.tsx @@ -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 { EyeOutlined, UserOutlined, @@ -24,6 +24,8 @@ export default function QuickAnalyticsModal({ onClose, }: QuickAnalyticsModalProps) { const [loading, setLoading] = useState(false); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [error, setError] = useState(null); const [analytics, setAnalytics] = useState(null); @@ -66,7 +68,7 @@ export default function QuickAnalyticsModal({ open={open} onCancel={onClose} footer={null} - width={800} + width={isMobile ? '95vw' : 800} aria-label="Video analytics modal" > {error ? ( diff --git a/admin/src/components/media/ScheduleCalendarDrawer.tsx b/admin/src/components/media/ScheduleCalendarDrawer.tsx index 0606ec8..02c7284 100644 --- a/admin/src/components/media/ScheduleCalendarDrawer.tsx +++ b/admin/src/components/media/ScheduleCalendarDrawer.tsx @@ -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 { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; import { useState, useEffect } from 'react'; @@ -26,6 +26,8 @@ export default function ScheduleCalendarDrawer({ onRefresh, }: ScheduleCalendarDrawerProps) { const [schedules, setSchedules] = useState([]); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedDate, setSelectedDate] = useState(dayjs()); @@ -117,7 +119,7 @@ export default function ScheduleCalendarDrawer({ open={open} onClose={onClose} placement="right" - width={700} + width={isMobile ? '100%' : 700} mask={false} destroyOnClose={false} styles={{ diff --git a/admin/src/components/media/SchedulePublishModal.tsx b/admin/src/components/media/SchedulePublishModal.tsx index 4693b55..72f857d 100644 --- a/admin/src/components/media/SchedulePublishModal.tsx +++ b/admin/src/components/media/SchedulePublishModal.tsx @@ -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 { useState, useEffect } from 'react'; import dayjs, { Dayjs } from 'dayjs'; @@ -39,6 +39,8 @@ export default function SchedulePublishModal({ onSuccess, }: SchedulePublishModalProps) { const [publishNow, setPublishNow] = useState(false); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [publishAt, setPublishAt] = useState(null); const [selectedTimezone, setSelectedTimezone] = useState('UTC'); const [unpublishEnabled, setUnpublishEnabled] = useState(false); @@ -161,7 +163,7 @@ export default function SchedulePublishModal({ onOk={handleSchedule} okText={publishNow ? 'Publish Now' : 'Schedule'} confirmLoading={loading} - width={600} + width={isMobile ? '95vw' : 600} style={{ top: 20 }} styles={{ body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' } diff --git a/admin/src/components/media/UploadVideoDrawer.tsx b/admin/src/components/media/UploadVideoDrawer.tsx index 950476a..91a1e95 100644 --- a/admin/src/components/media/UploadVideoDrawer.tsx +++ b/admin/src/components/media/UploadVideoDrawer.tsx @@ -11,6 +11,7 @@ import { List, Tag, Button, + Grid, } from 'antd'; import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; @@ -33,6 +34,8 @@ interface UploadResult { export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVideoDrawerProps) { const [form] = Form.useForm(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); @@ -149,7 +152,7 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi open={open} onClose={handleClose} placement="right" - width={520} + width={isMobile ? '100%' : 520} mask={false} destroyOnClose closable={!uploading} diff --git a/admin/src/components/media/VideoAnalyticsModal.tsx b/admin/src/components/media/VideoAnalyticsModal.tsx index dcca912..5b8610a 100644 --- a/admin/src/components/media/VideoAnalyticsModal.tsx +++ b/admin/src/components/media/VideoAnalyticsModal.tsx @@ -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 { EyeOutlined, UserOutlined, @@ -27,6 +27,8 @@ export default function VideoAnalyticsModal({ onClose, }: VideoAnalyticsModalProps) { const [loading, setLoading] = useState(false); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [error, setError] = useState(null); const [analytics, setAnalytics] = useState(null); @@ -224,7 +226,7 @@ export default function VideoAnalyticsModal({ open={open} onCancel={onClose} footer={null} - width={1000} + width={isMobile ? '95vw' : 1000} style={{ top: 20 }} styles={{ body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' } diff --git a/admin/src/components/media/VideoPickerModal.tsx b/admin/src/components/media/VideoPickerModal.tsx index 408f8a5..18fdc78 100644 --- a/admin/src/components/media/VideoPickerModal.tsx +++ b/admin/src/components/media/VideoPickerModal.tsx @@ -13,6 +13,7 @@ import { Button, Tag, message, + Grid, } from 'antd'; import { SearchOutlined, @@ -60,6 +61,8 @@ export const VideoPickerModal: React.FC = ({ title = 'Select Video', }) => { const [activeTab, setActiveTab] = useState('library'); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [loading, setLoading] = useState(false); const [videos, setVideos] = useState([]); const [total, setTotal] = useState(0); @@ -172,7 +175,7 @@ export const VideoPickerModal: React.FC = ({ open={open} onCancel={onClose} title={title} - width={900} + width={isMobile ? '95vw' : 900} footer={ mode === 'multiple' && activeTab === 'library' ? (
diff --git a/admin/src/components/payments/DonateInsertModal.tsx b/admin/src/components/payments/DonateInsertModal.tsx index e1e7334..8b0daa0 100644 --- a/admin/src/components/payments/DonateInsertModal.tsx +++ b/admin/src/components/payments/DonateInsertModal.tsx @@ -1,5 +1,5 @@ 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 axios from 'axios'; @@ -29,6 +29,8 @@ interface DonateInsertModalProps { export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModalProps) { const [variant, setVariant] = useState('simple'); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [amount, setAmount] = useState(25); const [config, setConfig] = useState(null); const [configLoading, setConfigLoading] = useState(false); @@ -85,7 +87,7 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal onOk={handleOk} okText="Insert" okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }} - width={520} + width={isMobile ? '95vw' : 520} > Choose a donation block style to insert into your document. diff --git a/admin/src/components/payments/ProductInsertModal.tsx b/admin/src/components/payments/ProductInsertModal.tsx index e68be58..92f409d 100644 --- a/admin/src/components/payments/ProductInsertModal.tsx +++ b/admin/src/components/payments/ProductInsertModal.tsx @@ -1,5 +1,5 @@ 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 axios from 'axios'; import type { Product, ProductType } from '@/types/api'; @@ -24,6 +24,8 @@ const typeColors: Record = { export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertModalProps) { const [products, setProducts] = useState([]); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); @@ -65,7 +67,7 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod onOk={handleOk} okText="Insert" okButtonProps={{ disabled: !selectedId }} - width={640} + width={isMobile ? '95vw' : 640} > Select a product to embed as an inline purchase card. diff --git a/admin/src/components/shifts/EditModeModal.tsx b/admin/src/components/shifts/EditModeModal.tsx index adfe12c..0278458 100644 --- a/admin/src/components/shifts/EditModeModal.tsx +++ b/admin/src/components/shifts/EditModeModal.tsx @@ -1,4 +1,4 @@ -import { Modal, Radio, Space, Typography } from 'antd'; +import { Modal, Radio, Space, Typography, Grid } from 'antd'; import { useState } from 'react'; import type { EditMode } from '@/types/api'; import dayjs from 'dayjs'; @@ -21,6 +21,8 @@ export default function EditModeModal({ shiftsCount, }: Props) { const [mode, setMode] = useState<'THIS' | 'FUTURE' | 'ALL'>('THIS'); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const handleOk = () => { onConfirm({ @@ -36,7 +38,7 @@ export default function EditModeModal({ title="Edit Shift Series" okText="Continue" onOk={handleOk} - width={500} + width={isMobile ? '95vw' : 500} > This shift is part of a repeating series. What would you like to edit? diff --git a/admin/src/pages/CampaignEmailsDrawer.tsx b/admin/src/pages/CampaignEmailsDrawer.tsx index 60c5738..feb140b 100644 --- a/admin/src/pages/CampaignEmailsDrawer.tsx +++ b/admin/src/pages/CampaignEmailsDrawer.tsx @@ -1,5 +1,5 @@ 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 dayjs from 'dayjs'; import { api } from '@/lib/api'; @@ -36,6 +36,8 @@ const statusOptions: { value: CampaignEmailStatus; label: string }[] = [ export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) { const [emails, setEmails] = useState([]); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 }); const [loading, setLoading] = useState(false); const [statusFilter, setStatusFilter] = useState(); @@ -123,7 +125,7 @@ export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) title={`Emails — ${campaign?.title || ''}`} open={open} onClose={onClose} - width={720} + width={isMobile ? '100%' : 720} destroyOnClose > {stats && ( @@ -166,6 +168,7 @@ export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) rowKey="id" loading={loading} size="small" + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/CampaignsPage.tsx b/admin/src/pages/CampaignsPage.tsx index 390ae77..9862b53 100644 --- a/admin/src/pages/CampaignsPage.tsx +++ b/admin/src/pages/CampaignsPage.tsx @@ -15,6 +15,7 @@ import { Col, Divider, Tooltip, + Grid, } from 'antd'; import { PlusOutlined, @@ -27,6 +28,7 @@ import { EyeOutlined, QuestionCircleOutlined, ExportOutlined, + QrcodeOutlined, } from '@ant-design/icons'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import dayjs from 'dayjs'; @@ -45,9 +47,30 @@ import type { } from '@/types/api'; import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer'; 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; +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 ( + + Video #{video.id}: {video.title} + + + ); + } + return ; +} + const statusColors: Record = { DRAFT: 'default', ACTIVE: 'green', @@ -92,8 +115,16 @@ export default function CampaignsPage() { const [exportOpen, setExportOpen] = useState(false); const [exportCampaignId, setExportCampaignId] = useState(); const [cuts, setCuts] = useState([]); + const [qrCampaign, setQrCampaign] = useState(null); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); + const [videoPickerOpen, setVideoPickerOpen] = useState(false); + const [videoPickerTarget, setVideoPickerTarget] = useState<'create' | 'edit'>('create'); + const [createSelectedVideo, setCreateSelectedVideo] = useState
+ {/* Cover Video */} + {campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && ( +
+ +
+ )} + {/* Call to Action */} {campaign.callToAction && ( (); @@ -18,6 +19,21 @@ export default function PublicLandingPage() { const contentRef = useRef(null); const videoRootsRef = useRef>>([]); const paymentRootsRef = useRef>>([]); + const campaignFormRootsRef = useRef>>([]); + + // 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(() => { 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(); + } catch (err) { + console.error('Failed to render campaign form widget:', err); + } + }); + }; + // Hydrate after DOM is ready setTimeout(hydrateVideoBlocks, 100); setTimeout(hydrateVideoCards, 200); setTimeout(hydratePaymentBlocks, 150); + setTimeout(hydrateCampaignFormBlocks, 175); // Cleanup on unmount return () => { @@ -223,6 +271,11 @@ export default function PublicLandingPage() { try { root.unmount(); } catch (err) { console.error('Failed to unmount payment root on cleanup:', err); } }); 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]); diff --git a/admin/src/pages/public/MapPage.tsx b/admin/src/pages/public/MapPage.tsx index efbf7be..a12ba66 100644 --- a/admin/src/pages/public/MapPage.tsx +++ b/admin/src/pages/public/MapPage.tsx @@ -1,5 +1,5 @@ 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 { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined, CalendarOutlined, SendOutlined } from '@ant-design/icons'; import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet'; @@ -105,6 +105,8 @@ export default function MapPage() { const fetchTimerRef = useRef>(undefined); const abortControllerRef = useRef(null); const { settings: siteSettings } = useSettingsStore(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const navigate = useNavigate(); useEffect(() => { @@ -374,6 +376,7 @@ export default function MapPage() {